feat(FN-4069): add direct merge commit routing to merger
Adds direct merge commit routing to the merger, allowing non-squash merges to bypass the squash-audit path when configured. The feature includes new `mergeCommitStrategy` settings, updated dashboard UI, expanded merger lifecycle tests, and documentation. Fusion-Task-Id: FN-4069
This commit is contained in:
5
.changeset/fn-4069-direct-merge-routing.md
Normal file
5
.changeset/fn-4069-direct-merge-routing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Route multi-substantive direct-merge task branches through a history-preserving merge path by default, with new project and per-task controls for forcing squash vs rebase-style commit preservation.
|
||||
@@ -217,7 +217,7 @@ Two rules, learned the hard way (FN-2370 silently reverted three commits' work):
|
||||
|
||||
1. **If a branch contains commits that duplicate work already on main, rebase the branch onto main and drop the duplicates *before* merging.** This usually happens when a branch was rebased from a stale base while the same work was also landed directly on main. Subjects that match recent main commits are the tell — `git log main..branch --format=%s` should not overlap with `git log <base>..main --format=%s`. Auto-resolvers cannot tell which side of a duplicated change is canonical and will silently drop refinements from the newer side.
|
||||
|
||||
2. **Prefer rebase-and-merge over squash for branches spanning multiple feature commits.** Squash collapses authorship and makes per-commit reverts impossible. Rebase-and-merge preserves the commit boundary so a regression can be reverted cleanly without losing the rest of the branch.
|
||||
2. **Prefer rebase-and-merge over squash for branches spanning multiple substantive commits.** Fusion's direct merger now defaults `directMergeCommitStrategy="auto"`, which keeps squash for branches with 0–1 substantive commits but automatically switches multi-substantive branches to a history-preserving rebase/cherry-pick path. Use the project setting `directMergeCommitStrategy` or the task-level `**Direct Merge Commit Strategy:** auto|always-squash|always-rebase` PROMPT line when you need to force a route.
|
||||
|
||||
After any squash that auto-resolved conflicts, the merger now runs the post-squash audit as a blocking gate before auto-completing the task. Flagged merges stay in `in-review` for inspection, and only a clean audit proceeds to `done`.
|
||||
|
||||
|
||||
@@ -182,8 +182,27 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
||||
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
|
||||
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. |
|
||||
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
|
||||
| `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"auto"` | Direct-merge commit routing mode. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-squash` forces the legacy squash path; `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. |
|
||||
| `mergeConflictStrategy` | `"smart-prefer-main" \| "smart-prefer-branch" \| "ai-only" \| "abort"` | `"smart-prefer-main"` | Controls the merger's conflict-resolution cascade. `smart-prefer-main` fast-forwards local main from `origin` when possible, then tries AI resolution, then auto-resolve heuristics, then a final `-X ours` fallback that prefers main unless the overlap guard below says otherwise. `smart-prefer-branch` uses the same cascade but ends with `-X theirs` so the task branch wins. `ai-only` never silently picks a side, and `abort` stops after the first AI attempt. Legacy `smart` / `prefer-main` values are normalized automatically. |
|
||||
| `mergeStrategyOverlapBehavior` | `"flip-to-prefer-branch" \| "warn-only" \| "ignore"` | `"flip-to-prefer-branch"` | Safety control for `mergeConflictStrategy="smart-prefer-main"`. Before the Attempt 3 `-X ours` fallback, Fusion checks whether the task branch and recent `main` history overlap on the same files (30-commit lookback, matching the squash audit heuristics). `flip-to-prefer-branch` makes overlapping files prefer the task branch so hardening is not silently discarded (the FN-3936 class of regression). `warn-only` logs the overlap but keeps the legacy main-wins fallback. `ignore` disables the overlap guard and preserves legacy behavior exactly. |
|
||||
|
||||
### Per-task direct-merge override
|
||||
|
||||
When a project uses `mergeStrategy: "direct"`, an individual task can override the project-level `directMergeCommitStrategy` by adding this line anywhere in `PROMPT.md`:
|
||||
|
||||
```md
|
||||
**Direct Merge Commit Strategy:** auto
|
||||
```
|
||||
|
||||
Accepted values:
|
||||
- `auto` — squash if the branch has 0–1 substantive commits; preserve per-commit history if it has 2+
|
||||
- `always-squash` — force the legacy squash path for this task
|
||||
- `always-rebase` — force the history-preserving path for this task
|
||||
|
||||
Override precedence for direct merges is:
|
||||
1. Task `PROMPT.md` line `**Direct Merge Commit Strategy:** ...`
|
||||
2. Project `directMergeCommitStrategy`
|
||||
3. Default `"auto"`
|
||||
| `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. |
|
||||
| `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. |
|
||||
| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation. For pnpm repos, prefer `pnpm install --frozen-lockfile` for deterministic bootstrap. |
|
||||
|
||||
@@ -77,6 +77,12 @@ describe("settings key parity", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off");
|
||||
});
|
||||
|
||||
it("keeps directMergeCommitStrategy project-scoped with auto default", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.directMergeCommitStrategy).toBe("auto");
|
||||
expect(isProjectSettingsKey("directMergeCommitStrategy")).toBe(true);
|
||||
expect(isGlobalSettingsKey("directMergeCommitStrategy")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps task stuck timeout active by default without coupling to workflow step timeout", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.taskStuckTimeoutMs).toBe(600_000);
|
||||
expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateDirectMergeCommitStrategy,
|
||||
validateGithubAuthMode,
|
||||
validateGithubRepoSlug,
|
||||
validateUnavailableNodePolicy,
|
||||
@@ -19,6 +20,20 @@ describe("settings-validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDirectMergeCommitStrategy", () => {
|
||||
it("accepts supported direct-merge routing values", () => {
|
||||
expect(validateDirectMergeCommitStrategy("auto")).toBe("auto");
|
||||
expect(validateDirectMergeCommitStrategy("always-squash")).toBe("always-squash");
|
||||
expect(validateDirectMergeCommitStrategy("always-rebase")).toBe("always-rebase");
|
||||
});
|
||||
|
||||
it("returns undefined for invalid routing values", () => {
|
||||
expect(validateDirectMergeCommitStrategy("squash")).toBeUndefined();
|
||||
expect(validateDirectMergeCommitStrategy(123)).toBeUndefined();
|
||||
expect(validateDirectMergeCommitStrategy(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateGithubAuthMode", () => {
|
||||
it("accepts supported auth modes", () => {
|
||||
expect(validateGithubAuthMode("gh-cli")).toBe("gh-cli");
|
||||
|
||||
@@ -61,6 +61,15 @@ describe("TaskStore", () => {
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.mergeStrategy).toBe("pull-request");
|
||||
});
|
||||
|
||||
it("defaults directMergeCommitStrategy to auto and persists updates", async () => {
|
||||
const defaults = await harness.store().getSettings();
|
||||
expect(defaults.directMergeCommitStrategy).toBe("auto");
|
||||
|
||||
await harness.store().updateSettings({ directMergeCommitStrategy: "always-rebase" });
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.directMergeCommitStrategy).toBe("always-rebase");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Planning/Validator Model Settings ────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
||||
export type {
|
||||
@@ -161,7 +161,12 @@ export {
|
||||
type NodeOverrideValidationResult,
|
||||
type NodeOverrideBlockReason,
|
||||
} from "./node-override-guard.js";
|
||||
export { validateUnavailableNodePolicy } from "./settings-validation.js";
|
||||
export {
|
||||
validateDirectMergeCommitStrategy,
|
||||
validateGithubAuthMode,
|
||||
validateGithubRepoSlug,
|
||||
validateUnavailableNodePolicy,
|
||||
} from "./settings-validation.js";
|
||||
|
||||
// ── Routine System ───────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -170,6 +170,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
directMergeCommitStrategy: "auto",
|
||||
requirePrApproval: false,
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GithubAuthMode, UnavailableNodePolicy } from "./types.js";
|
||||
import type { DirectMergeCommitStrategy, GithubAuthMode, UnavailableNodePolicy } from "./types.js";
|
||||
|
||||
const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const;
|
||||
const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const;
|
||||
const GITHUB_AUTH_MODES: readonly GithubAuthMode[] = ["gh-cli", "token"] as const;
|
||||
const GITHUB_REPO_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
@@ -21,6 +22,19 @@ export function validateUnavailableNodePolicy(value: unknown): UnavailableNodePo
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns a validated direct-merge commit strategy for project settings, otherwise undefined. */
|
||||
export function validateDirectMergeCommitStrategy(value: unknown): DirectMergeCommitStrategy | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return (DIRECT_MERGE_COMMIT_STRATEGIES as readonly string[]).includes(value)
|
||||
? (value as DirectMergeCommitStrategy)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns a validated GitHub auth mode for project settings, otherwise undefined. */
|
||||
export function validateGithubAuthMode(value: unknown): GithubAuthMode | undefined {
|
||||
if (value === undefined) {
|
||||
|
||||
@@ -118,6 +118,8 @@ export type ColorTheme = (typeof COLOR_THEMES)[number];
|
||||
|
||||
export type PrStatus = "open" | "closed" | "merged";
|
||||
export type MergeStrategy = "direct" | "pull-request";
|
||||
export const DIRECT_MERGE_COMMIT_STRATEGIES = ["auto", "always-squash", "always-rebase"] as const;
|
||||
export type DirectMergeCommitStrategy = (typeof DIRECT_MERGE_COMMIT_STRATEGIES)[number];
|
||||
/** How merge conflicts are resolved when the AI agent can't (or shouldn't) decide.
|
||||
*
|
||||
* Both `smart-*` strategies share the same cascade: pre-merge fetch +
|
||||
@@ -2013,6 +2015,12 @@ export interface ProjectSettings {
|
||||
* be enforced server-side. Only applies when `mergeStrategy === "pull-request"`.
|
||||
* Default: false. */
|
||||
requirePrApproval?: boolean;
|
||||
/** Direct-merge commit routing mode.
|
||||
* - "auto": squash single-substantive branches, preserve history for multi-substantive branches
|
||||
* - "always-squash": always use the legacy squash path for direct merges
|
||||
* - "always-rebase": always preserve individual branch commits during direct merges
|
||||
* Only applies when mergeStrategy is "direct". Default: "auto". */
|
||||
directMergeCommitStrategy?: DirectMergeCommitStrategy;
|
||||
/** When true, automatically push to the configured remote after a successful direct merge.
|
||||
* The push process includes pulling the latest from the remote (rebase) first.
|
||||
* If conflicts arise during the pull, they are resolved using the AI conflict resolution pipeline.
|
||||
|
||||
@@ -3682,10 +3682,36 @@ export function SettingsModal({
|
||||
<details className="settings-option-details">
|
||||
<summary>More details</summary>
|
||||
<small>
|
||||
Controls what happens after a task reaches In Review. Direct mode preserves Fusion's current local squash-merge behavior. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR.
|
||||
Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR.
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
{form.mergeStrategy !== "pull-request" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="directMergeCommitStrategy">Direct merge commit routing</label>
|
||||
<select
|
||||
id="directMergeCommitStrategy"
|
||||
className="select"
|
||||
value={form.directMergeCommitStrategy ?? "auto"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto — squash single-substantive branches, preserve multi-substantive history</option>
|
||||
<option value="always-squash">Always squash direct merges</option>
|
||||
<option value="always-rebase">Always preserve direct-merge commit history</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>More details</summary>
|
||||
<small>
|
||||
Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with <code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{form.mergeStrategy === "pull-request" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="requirePrApproval" className="checkbox-label">
|
||||
|
||||
@@ -177,6 +177,7 @@ const defaultSettings = {
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
directMergeCommitStrategy: "auto",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
verificationFixRetries: 2,
|
||||
@@ -248,6 +249,17 @@ describe("SettingsModal", () => {
|
||||
expect(container.querySelectorAll(".settings-section-heading").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows direct merge commit routing only for direct merges", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Merge$/ }));
|
||||
expect(screen.getByLabelText("Direct merge commit routing")).toHaveValue("auto");
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Auto-completion mode"), "pull-request");
|
||||
expect(screen.queryByLabelText("Direct merge commit routing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearPluginUiSlotsCache();
|
||||
|
||||
@@ -15,6 +15,7 @@ const defaultSettings = {
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
directMergeCommitStrategy: "auto",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
recycleWorktrees: false,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
type TaskWithPromptOverride = Partial<Task> & Pick<Task, "id"> & { prompt?: string };
|
||||
import { DEFAULT_SETTINGS } from "@fusion/core";
|
||||
import { aiMergeTask } from "../merger.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
|
||||
function git(repo: string, command: string): string {
|
||||
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
function makeTask(overrides: TaskWithPromptOverride): Task {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
...rest,
|
||||
id,
|
||||
title: overrides.title ?? id,
|
||||
description: overrides.description ?? id,
|
||||
column: overrides.column ?? "in-review",
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
steps: overrides.steps ?? [],
|
||||
currentStep: overrides.currentStep ?? 0,
|
||||
log: overrides.log ?? [],
|
||||
createdAt: overrides.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task, settings: Partial<Settings>): TaskStore {
|
||||
let currentTask = { ...task };
|
||||
const mergedSettings: Settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeStrategy: "direct",
|
||||
directMergeCommitStrategy: "auto",
|
||||
autoMerge: true,
|
||||
includeTaskIdInCommit: false,
|
||||
commitAuthorEnabled: false,
|
||||
useAiMergeCommitSummary: false,
|
||||
...settings,
|
||||
} as Settings;
|
||||
|
||||
return {
|
||||
getTask: vi.fn(async () => currentTask),
|
||||
getSettings: vi.fn(async () => mergedSettings),
|
||||
listTasks: vi.fn(async () => [currentTask]),
|
||||
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => {
|
||||
currentTask = { ...currentTask, ...updates, updatedAt: new Date().toISOString() } as Task;
|
||||
return currentTask;
|
||||
}),
|
||||
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
|
||||
currentTask = {
|
||||
...currentTask,
|
||||
column,
|
||||
columnMovedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
return currentTask;
|
||||
}),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
appendAgentLog: vi.fn(async () => undefined),
|
||||
updateSettings: vi.fn(async () => mergedSettings),
|
||||
getActiveMergingTask: vi.fn(() => null),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
|
||||
getVerificationCacheHit: vi.fn(() => null),
|
||||
recordVerificationCachePass: vi.fn(() => undefined),
|
||||
upsertTaskCommitAssociation: vi.fn(async () => undefined),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describeIfGit("aiMergeTask direct merge commit routing (real git)", () => {
|
||||
const repos: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const repo of repos.splice(0)) {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupRepo(): { repo: string; initSha: string } {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-commit-strategy-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
writeFileSync(join(repo, "README.md"), "init\n", "utf-8");
|
||||
git(repo, "git add README.md && git commit -m 'chore: init'");
|
||||
return { repo, initSha: git(repo, "git rev-parse HEAD") };
|
||||
}
|
||||
|
||||
it("auto-routes multi-substantive branches to history-preserving direct merge", async () => {
|
||||
const { repo, initSha } = setupRepo();
|
||||
const branch = "fusion/fn-4069-test";
|
||||
|
||||
git(repo, `git checkout -b ${branch}`);
|
||||
writeFileSync(join(repo, "src-fix.ts"), "export const fix = 1;\n", "utf-8");
|
||||
git(repo, "git add src-fix.ts && git commit -m 'fix: preserve original bugfix'");
|
||||
|
||||
writeFileSync(join(repo, ".changeset-fn-4069.md"), "noop\n", "utf-8");
|
||||
git(repo, "mkdir -p .changeset && mv .changeset-fn-4069.md .changeset/fn-4069.md && git add .changeset/fn-4069.md && git commit -m 'chore: add changeset'");
|
||||
|
||||
writeFileSync(join(repo, "src-style.css"), ".root { display: block; }\n", "utf-8");
|
||||
git(repo, "git add src-style.css && git commit -m 'feat: preserve follow-up polish'");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const task = makeTask({
|
||||
id: "FN-4069",
|
||||
branch,
|
||||
baseBranch: "main",
|
||||
column: "in-review",
|
||||
prompt: "# Task\n",
|
||||
});
|
||||
const store = createStore(task, {});
|
||||
|
||||
await aiMergeTask(store, repo, "FN-4069");
|
||||
|
||||
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(true);
|
||||
|
||||
const subjects = git(repo, `git log --reverse --format=%s ${initSha}..HEAD`).split("\n");
|
||||
expect(subjects).toEqual([
|
||||
"fix: preserve original bugfix",
|
||||
"chore: add changeset",
|
||||
"feat: preserve follow-up polish",
|
||||
]);
|
||||
|
||||
const landedShas = git(repo, `git rev-list --reverse ${initSha}..HEAD`).split("\n");
|
||||
expect(landedShas).toHaveLength(3);
|
||||
for (const sha of landedShas) {
|
||||
const body = git(repo, `git log -1 --format=%B ${sha}`);
|
||||
expect(body).toContain("Fusion-Task-Id: FN-4069");
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -124,8 +124,10 @@ vi.mock("../context-limit-detector.js", () => ({
|
||||
vi.mock("../merger-squash-audit.js", () => ({
|
||||
MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS: 30,
|
||||
auditSquashMerge: vi.fn(async () => ({
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
auditTargetLabel: "mergedcommit123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: [],
|
||||
@@ -2349,8 +2351,10 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
},
|
||||
} as any);
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
auditTargetLabel: "mergedcommit123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: [],
|
||||
@@ -2396,10 +2400,13 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function createAuditStore(overrides: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
function createAuditStore(
|
||||
overrides: Partial<typeof DEFAULT_SETTINGS> = {},
|
||||
taskOverrides: Partial<Task> & { prompt?: string } = {},
|
||||
) {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", ...taskOverrides } as Task & { prompt?: string },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review", ...taskOverrides } as Task & { prompt?: string }],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -2486,6 +2493,41 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function setupRebaseRouteExecSync() {
|
||||
let headIndex = 0;
|
||||
const landedHeads = ["landedcommit001", "landedcommit002"];
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("symbolic-ref --short HEAD")) return "main" as any;
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) {
|
||||
return (headIndex > 0 ? landedHeads[Math.min(headIndex - 1, landedHeads.length - 1)] : "basehead123") as any;
|
||||
}
|
||||
if (cmdStr.includes('git rev-parse "main"') || cmdStr.includes("git rev-parse main")) return "basehead123" as any;
|
||||
if (cmdStr.includes('git rev-list --count "main..fusion/fn-050"')) return "2\n" as any;
|
||||
if (cmdStr.includes('git rev-list --reverse "main..fusion/fn-050"') || cmdStr.includes('git rev-list --reverse "basehead123..fusion/fn-050"')) {
|
||||
return "commit-a\ncommit-b\ncommit-c\n" as any;
|
||||
}
|
||||
if (cmdStr.includes('git log -1 --format=%s "commit-a"')) return "fix: substantive one" as any;
|
||||
if (cmdStr.includes('git log -1 --format=%s "commit-b"')) return "chore: changeset only" as any;
|
||||
if (cmdStr.includes('git log -1 --format=%s "commit-c"')) return "feat: substantive two" as any;
|
||||
if (cmdStr.includes('git log "main..fusion/fn-050" --format="- %s"')) return "- fix: substantive one\n- chore: changeset only\n- feat: substantive two" as any;
|
||||
if (cmdStr.includes("git log -1 --pretty=%B")) return "commit body" as any;
|
||||
if (cmdStr.includes('git diff-tree --root --no-commit-id --name-status -r "commit-a"')) return "M\tsrc/feature-a.ts\n" as any;
|
||||
if (cmdStr.includes('git diff-tree --root --no-commit-id --name-status -r "commit-b"')) return "A\t.changeset/fn-050.md\n" as any;
|
||||
if (cmdStr.includes('git diff-tree --root --no-commit-id --name-status -r "commit-c"')) return "M\tsrc/feature-b.ts\n" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any;
|
||||
if (cmdStr.includes('git cherry-pick "commit-a"') || cmdStr.includes('git cherry-pick "commit-b"') || cmdStr.includes('git cherry-pick "commit-c"')) {
|
||||
headIndex += 1;
|
||||
return Buffer.from("");
|
||||
}
|
||||
if (cmdStr.includes("git -c trailer.ifExists=addIfDifferent commit --amend --no-edit")) return Buffer.from("");
|
||||
if (cmdStr.includes('git diff --shortstat "basehead123..HEAD"')) return "2 files changed, 6 insertions(+), 1 deletion(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D") || cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
it("moves the task to done when the post-squash audit is clean", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
@@ -2495,17 +2537,107 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
expect(result.merged).toBe(true);
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-squash audit clean", "text", undefined, "merger");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
|
||||
});
|
||||
|
||||
it("routes multi-substantive auto branches through rebase range audit", async () => {
|
||||
setupRebaseRouteExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: "basehead123",
|
||||
rangeHeadSha: "landedcommit002",
|
||||
parentSha: "basehead123",
|
||||
auditTargetLabel: "basehead123..landedcommit002",
|
||||
lookback: 30,
|
||||
branchSubjects: ["fix: substantive one", "feat: substantive two"],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: ["src/feature-a.ts", "src/feature-b.ts"],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
});
|
||||
const store = createAuditStore();
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: "basehead123",
|
||||
rangeHeadSha: "landedcommit002",
|
||||
});
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "post-rebase range audit clean", "text", undefined, "merger");
|
||||
});
|
||||
|
||||
it("honors the per-task always-rebase override", async () => {
|
||||
setupRebaseRouteExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: "basehead123",
|
||||
rangeHeadSha: "landedcommit002",
|
||||
parentSha: "basehead123",
|
||||
auditTargetLabel: "basehead123..landedcommit002",
|
||||
lookback: 30,
|
||||
branchSubjects: ["fix: substantive one", "feat: substantive two"],
|
||||
recentMainSubjects: [],
|
||||
duplicateSubjects: [],
|
||||
touchedFiles: [],
|
||||
touchedFileOverlaps: [],
|
||||
findings: [],
|
||||
issueCount: 0,
|
||||
clean: true,
|
||||
});
|
||||
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-rebase" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: "basehead123",
|
||||
rangeHeadSha: "landedcommit002",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the squash path for single-substantive branches in auto mode", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
const store = createAuditStore();
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors the per-task always-squash override", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
const store = createAuditStore({}, { prompt: "**Direct Merge Commit Strategy:** always-squash" });
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks completion and logs duplicate-subject findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
auditTargetLabel: "mergedcommit123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: duplicate subject"],
|
||||
@@ -2537,8 +2669,10 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
it("blocks completion and logs touched-file-overlap findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
auditTargetLabel: "mergedcommit123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: branch change"],
|
||||
@@ -2578,8 +2712,10 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
it("blocks completion and logs combined duplicate-subject and touched-file findings", async () => {
|
||||
setupAutoResolvedMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
auditTargetLabel: "mergedcommit123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: duplicate subject", "feat: branch change"],
|
||||
@@ -2634,8 +2770,10 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
it("runs the post-squash audit after the attempt 3 -X ours fallback path", async () => {
|
||||
setupAttempt3FallbackMergeExecSync();
|
||||
mockedAuditSquashMerge.mockResolvedValue({
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
parentSha: "parent123",
|
||||
auditTargetLabel: "mergedcommit123",
|
||||
squashSubject: "feat: squash merge",
|
||||
lookback: 30,
|
||||
branchSubjects: ["feat: duplicate subject"],
|
||||
@@ -2662,6 +2800,7 @@ describe("aiMergeTask post-squash audit gate", () => {
|
||||
|
||||
expect(mockedAuditSquashMerge).toHaveBeenCalledWith({
|
||||
rootDir: "/tmp/root",
|
||||
strategy: "squash",
|
||||
squashSha: "mergedcommit123",
|
||||
});
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-050", { status: null });
|
||||
|
||||
@@ -23,10 +23,34 @@ export interface SquashAuditTouchedFileOverlapFinding {
|
||||
|
||||
export type SquashAuditFinding = SquashAuditDuplicateSubjectFinding | SquashAuditTouchedFileOverlapFinding;
|
||||
|
||||
export interface SquashAuditFindings {
|
||||
export type PostMergeAuditStrategy = "squash" | "rebase";
|
||||
|
||||
interface PostMergeAuditBaseInput {
|
||||
rootDir: string;
|
||||
lookback?: number;
|
||||
}
|
||||
|
||||
export interface PostSquashAuditInput extends PostMergeAuditBaseInput {
|
||||
strategy?: "squash";
|
||||
squashSha: string;
|
||||
}
|
||||
|
||||
export interface PostRebaseAuditInput extends PostMergeAuditBaseInput {
|
||||
strategy: "rebase";
|
||||
rangeBaseSha: string;
|
||||
rangeHeadSha: string;
|
||||
}
|
||||
|
||||
export type PostMergeAuditInput = PostSquashAuditInput | PostRebaseAuditInput;
|
||||
|
||||
export interface SquashAuditFindings {
|
||||
strategy: PostMergeAuditStrategy;
|
||||
squashSha?: string;
|
||||
rangeBaseSha?: string;
|
||||
rangeHeadSha?: string;
|
||||
parentSha: string;
|
||||
squashSubject: string;
|
||||
squashSubject?: string;
|
||||
auditTargetLabel: string;
|
||||
lookback: number;
|
||||
branchSubjects: string[];
|
||||
recentMainSubjects: string[];
|
||||
@@ -38,30 +62,137 @@ export interface SquashAuditFindings {
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export async function auditSquashMerge({
|
||||
rootDir,
|
||||
squashSha,
|
||||
lookback = MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS,
|
||||
}: {
|
||||
rootDir: string;
|
||||
squashSha: string;
|
||||
lookback?: number;
|
||||
}): Promise<SquashAuditFindings> {
|
||||
const normalizedLookback = normalizeMergeOverlapLookback(lookback);
|
||||
const parentSha = await git(rootDir, ["rev-parse", `${squashSha}^`]);
|
||||
const squashSubject = await git(rootDir, ["log", "-1", "--format=%s", squashSha]);
|
||||
const branchSubjects = normalizeLines(await git(rootDir, ["log", "-1", "--format=%b", squashSha]))
|
||||
/**
|
||||
* Strategy-aware post-merge audit.
|
||||
*
|
||||
* - squash: audit the synthetic squash commit and compare its branch-subject list
|
||||
* against recent pre-squash main history.
|
||||
* - rebase: audit the landed commit range base..head and compare those preserved
|
||||
* commit subjects/files against recent pre-merge main history.
|
||||
*/
|
||||
export async function auditSquashMerge(input: PostMergeAuditInput): Promise<SquashAuditFindings> {
|
||||
const normalizedLookback = normalizeMergeOverlapLookback(input.lookback);
|
||||
|
||||
if (input.strategy === "rebase") {
|
||||
const parentSha = input.rangeBaseSha;
|
||||
const auditTargetLabel = `${input.rangeBaseSha.slice(0, 8)}..${input.rangeHeadSha.slice(0, 8)}`;
|
||||
const branchSubjects = normalizeLines(
|
||||
await git(input.rootDir, ["log", "--format=%s", `${input.rangeBaseSha}..${input.rangeHeadSha}`]),
|
||||
);
|
||||
const recentMainCommits = await listRecentMainCommits(input.rootDir, parentSha, normalizedLookback);
|
||||
const recentMainSubjects = recentMainCommits.map((entry) => entry.subject);
|
||||
const duplicateSubjects = branchSubjects
|
||||
.filter((subject) => recentMainSubjects.includes(subject))
|
||||
.map((subject) => ({ type: "duplicate-subject", subject }) satisfies SquashAuditDuplicateSubjectFinding);
|
||||
const touchedFiles = normalizeLines(await git(input.rootDir, ["diff", "--name-only", input.rangeBaseSha, input.rangeHeadSha]));
|
||||
const touchedFileOverlaps = await collectTouchedFileOverlaps(input.rootDir, touchedFiles, recentMainCommits);
|
||||
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
|
||||
|
||||
return {
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: input.rangeBaseSha,
|
||||
rangeHeadSha: input.rangeHeadSha,
|
||||
parentSha,
|
||||
auditTargetLabel,
|
||||
lookback: normalizedLookback,
|
||||
branchSubjects,
|
||||
recentMainSubjects,
|
||||
duplicateSubjects,
|
||||
touchedFiles,
|
||||
touchedFileOverlaps,
|
||||
findings,
|
||||
issueCount: findings.length,
|
||||
clean: findings.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const squashSha = input.squashSha;
|
||||
const parentSha = await git(input.rootDir, ["rev-parse", `${squashSha}^`]);
|
||||
const squashSubject = await git(input.rootDir, ["log", "-1", "--format=%s", squashSha]);
|
||||
const branchSubjects = normalizeLines(await git(input.rootDir, ["log", "-1", "--format=%b", squashSha]))
|
||||
.map((line) => line.replace(/^- /, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const recentMainCommits = await listRecentMainCommits(rootDir, parentSha, normalizedLookback);
|
||||
const recentMainCommits = await listRecentMainCommits(input.rootDir, parentSha, normalizedLookback);
|
||||
const recentMainSubjects = recentMainCommits.map((entry) => entry.subject);
|
||||
|
||||
const duplicateSubjects = branchSubjects
|
||||
.filter((subject) => recentMainSubjects.includes(subject))
|
||||
.map((subject) => ({ type: "duplicate-subject", subject }) satisfies SquashAuditDuplicateSubjectFinding);
|
||||
|
||||
const touchedFiles = normalizeLines(await git(rootDir, ["diff", "--name-only", parentSha, squashSha]));
|
||||
const touchedFiles = normalizeLines(await git(input.rootDir, ["diff", "--name-only", parentSha, squashSha]));
|
||||
const touchedFileOverlaps = await collectTouchedFileOverlaps(input.rootDir, touchedFiles, recentMainCommits);
|
||||
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
|
||||
|
||||
return {
|
||||
strategy: "squash",
|
||||
squashSha,
|
||||
parentSha,
|
||||
squashSubject,
|
||||
auditTargetLabel: squashSha,
|
||||
lookback: normalizedLookback,
|
||||
branchSubjects,
|
||||
recentMainSubjects,
|
||||
duplicateSubjects,
|
||||
touchedFiles,
|
||||
touchedFileOverlaps,
|
||||
findings,
|
||||
issueCount: findings.length,
|
||||
clean: findings.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSquashAuditReport(findings: SquashAuditFindings): string {
|
||||
const heading = findings.strategy === "rebase"
|
||||
? `Auditing landed range: ${findings.auditTargetLabel}`
|
||||
: `Auditing squash: ${findings.squashSha} — ${findings.squashSubject}`;
|
||||
const parentLabel = findings.strategy === "rebase"
|
||||
? `Base (main before preserved-commit landing): ${findings.parentSha}`
|
||||
: `Parent (main before squash): ${findings.parentSha}`;
|
||||
const lines: string[] = [heading, parentLabel, `Lookback window on main: ${findings.lookback} commits`, "", "=== Duplicate-cherry-pick risk ==="];
|
||||
|
||||
if (findings.duplicateSubjects.length === 0) {
|
||||
lines.push("(none — no branch commit subjects match recent main commits)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"WARN: branch contains commits whose subjects match recent main commits.",
|
||||
"Auto-resolve may have picked the older side, dropping refinements.",
|
||||
"Action: diff each main commit below against HEAD and confirm its",
|
||||
"net contribution survived. Restore anything dropped as a follow-up.",
|
||||
"",
|
||||
...findings.duplicateSubjects.map((entry) => ` - ${entry.subject}`),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`=== Touched-file overlap (${findings.touchedFiles.length} files in ${findings.strategy === "rebase" ? "landed range" : "squash"}) ===`);
|
||||
if (findings.touchedFileOverlaps.length === 0) {
|
||||
lines.push("(none — merged result touches files no recent main commit touched)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"Files the merged result touched that also have recent main activity.",
|
||||
"Action: for each commit below, verify its changes still appear",
|
||||
"in HEAD. Reapply any silently dropped changes on the same branch.",
|
||||
"",
|
||||
);
|
||||
for (const overlap of findings.touchedFileOverlaps) {
|
||||
lines.push(` ${overlap.file}`);
|
||||
for (const commit of overlap.recentMainCommits) {
|
||||
lines.push(` - ${commit.sha} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`Audit complete. ${findings.issueCount} item(s) for the calling agent to review.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function collectTouchedFileOverlaps(
|
||||
rootDir: string,
|
||||
touchedFiles: string[],
|
||||
recentMainCommits: Array<{ sha: string; shortSha: string; subject: string }>,
|
||||
): Promise<SquashAuditTouchedFileOverlapFinding[]> {
|
||||
const touchedFileOverlaps: SquashAuditTouchedFileOverlapFinding[] = [];
|
||||
|
||||
for (const file of touchedFiles) {
|
||||
@@ -82,68 +213,7 @@ export async function auditSquashMerge({
|
||||
}
|
||||
}
|
||||
|
||||
const findings: SquashAuditFinding[] = [...duplicateSubjects, ...touchedFileOverlaps];
|
||||
|
||||
return {
|
||||
squashSha,
|
||||
parentSha,
|
||||
squashSubject,
|
||||
lookback: normalizedLookback,
|
||||
branchSubjects,
|
||||
recentMainSubjects,
|
||||
duplicateSubjects,
|
||||
touchedFiles,
|
||||
touchedFileOverlaps,
|
||||
findings,
|
||||
issueCount: findings.length,
|
||||
clean: findings.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSquashAuditReport(findings: SquashAuditFindings): string {
|
||||
const lines: string[] = [
|
||||
`Auditing squash: ${findings.squashSha} — ${findings.squashSubject}`,
|
||||
`Parent (main before squash): ${findings.parentSha}`,
|
||||
`Lookback window on main: ${findings.lookback} commits`,
|
||||
"",
|
||||
"=== Duplicate-cherry-pick risk ===",
|
||||
];
|
||||
|
||||
if (findings.duplicateSubjects.length === 0) {
|
||||
lines.push("(none — no branch commit subjects match recent main commits)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"WARN: branch contains commits whose subjects match recent main commits.",
|
||||
"Auto-resolve may have picked the older side, dropping refinements.",
|
||||
"Action: diff each main commit below against HEAD and confirm its",
|
||||
"net contribution survived. Restore anything dropped as a follow-up.",
|
||||
"",
|
||||
...findings.duplicateSubjects.map((entry) => ` - ${entry.subject}`),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`=== Touched-file overlap (${findings.touchedFiles.length} files in squash) ===`);
|
||||
if (findings.touchedFileOverlaps.length === 0) {
|
||||
lines.push("(none — squash touches files no recent main commit touched)", "");
|
||||
} else {
|
||||
lines.push(
|
||||
"Files the squash touched that also have recent main activity.",
|
||||
"Action: for each commit below, verify its changes still appear",
|
||||
"in HEAD. Reapply any silently dropped changes on the same branch.",
|
||||
"",
|
||||
);
|
||||
for (const overlap of findings.touchedFileOverlaps) {
|
||||
lines.push(` ${overlap.file}`);
|
||||
for (const commit of overlap.recentMainCommits) {
|
||||
lines.push(` - ${commit.sha} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`Audit complete. ${findings.issueCount} item(s) for the calling agent to review.`);
|
||||
return lines.join("\n");
|
||||
return touchedFileOverlaps;
|
||||
}
|
||||
|
||||
async function git(rootDir: string, args: string[]): Promise<string> {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type Settings,
|
||||
type AgentPromptsConfig,
|
||||
type CanonicalMergeConflictStrategy,
|
||||
type DirectMergeCommitStrategy,
|
||||
type TaskSourceIssue,
|
||||
type Task,
|
||||
type AutostashOrphanRecord,
|
||||
@@ -71,7 +72,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
|
||||
import { createWebFetchTool } from "./agent-tools.js";
|
||||
import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type SquashAuditFindings } from "./merger-squash-audit.js";
|
||||
import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeAuditStrategy, type SquashAuditFindings } from "./merger-squash-audit.js";
|
||||
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
|
||||
|
||||
/** Conflict type classification for merge conflict resolution */
|
||||
@@ -484,7 +485,7 @@ export class SquashAuditError extends Error {
|
||||
public readonly squashSha: string,
|
||||
public readonly findings: SquashAuditFindings,
|
||||
) {
|
||||
super(buildSquashAuditBlockingMessage(taskId, squashSha, findings));
|
||||
super(buildPostMergeAuditBlockingMessage(taskId, findings));
|
||||
this.name = "SquashAuditError";
|
||||
}
|
||||
}
|
||||
@@ -3957,6 +3958,126 @@ async function ensureTaskTrailersOnHead(rootDir: string, task: Pick<Task, "id">
|
||||
}
|
||||
}
|
||||
|
||||
async function cherryPickCommitPreservingTaskTrailers(
|
||||
rootDir: string,
|
||||
commitSha: string,
|
||||
task: Pick<Task, "id"> & { lineageId?: string },
|
||||
mergeConflictStrategy: CanonicalMergeConflictStrategy,
|
||||
smartConflictResolution: boolean,
|
||||
result: MergeResult,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git cherry-pick ${quoteArg(commitSha)}`, { cwd: rootDir });
|
||||
} catch (error) {
|
||||
const conflictedFiles = await getConflictedFiles(rootDir);
|
||||
if (conflictedFiles.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (smartConflictResolution) {
|
||||
let unresolvedComplex = 0;
|
||||
for (const file of conflictedFiles) {
|
||||
const type = await classifyConflict(file, rootDir);
|
||||
if (type === "lockfile-ours") {
|
||||
await resolveWithOurs(file, rootDir);
|
||||
result.autoResolvedCount = (result.autoResolvedCount ?? 0) + 1;
|
||||
} else if (type === "generated-theirs") {
|
||||
await resolveWithTheirs(file, rootDir);
|
||||
result.autoResolvedCount = (result.autoResolvedCount ?? 0) + 1;
|
||||
} else if (type === "trivial-whitespace") {
|
||||
await resolveTrivialWhitespace(file, rootDir);
|
||||
result.autoResolvedCount = (result.autoResolvedCount ?? 0) + 1;
|
||||
} else {
|
||||
unresolvedComplex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedComplex === 0) {
|
||||
await execAsync("git cherry-pick --continue", { cwd: rootDir });
|
||||
await ensureTaskTrailersOnHead(rootDir, task);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync("git cherry-pick --abort", { cwd: rootDir });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
if (mergeConflictStrategy === "smart-prefer-main") {
|
||||
await execAsync(`git cherry-pick -X ours ${quoteArg(commitSha)}`, { cwd: rootDir });
|
||||
} else if (mergeConflictStrategy === "smart-prefer-branch") {
|
||||
await execAsync(`git cherry-pick -X theirs ${quoteArg(commitSha)}`, { cwd: rootDir });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureTaskTrailersOnHead(rootDir, task);
|
||||
}
|
||||
|
||||
async function applyBranchCommitsPreservingHistory(params: {
|
||||
rootDir: string;
|
||||
baseRef: string;
|
||||
branch: string;
|
||||
task: Pick<Task, "id"> & { lineageId?: string };
|
||||
taskId: string;
|
||||
store: TaskStore;
|
||||
mergeConflictStrategy: CanonicalMergeConflictStrategy;
|
||||
smartConflictResolution: boolean;
|
||||
result: MergeResult;
|
||||
testCommand?: string;
|
||||
buildCommand?: string;
|
||||
testSource?: "explicit" | "inferred";
|
||||
buildSource?: "explicit" | "inferred";
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ landedCommitCount: number; landedCommitShas: string[]; baseSha: string }> {
|
||||
const { rootDir, baseRef, branch, task, taskId, store, mergeConflictStrategy, smartConflictResolution, result, testCommand, buildCommand, testSource, buildSource, signal } = params;
|
||||
const { stdout: baseShaStdout } = await execAsync(`git rev-parse ${quoteArg(baseRef)}`, { cwd: rootDir, encoding: "utf-8" });
|
||||
const baseSha = baseShaStdout.trim();
|
||||
const { stdout: commitStdout } = await execAsync(`git rev-list --reverse ${quoteArg(`${baseSha}..${branch}`)}`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const commitShas = commitStdout.trim().split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const landedCommitShas: string[] = [];
|
||||
|
||||
for (const commitSha of commitShas) {
|
||||
throwIfAborted(signal, taskId);
|
||||
await cherryPickCommitPreservingTaskTrailers(
|
||||
rootDir,
|
||||
commitSha,
|
||||
task,
|
||||
mergeConflictStrategy,
|
||||
smartConflictResolution,
|
||||
result,
|
||||
);
|
||||
const { stdout: landedShaOut } = await execAsync("git rev-parse HEAD", { cwd: rootDir, encoding: "utf-8" });
|
||||
landedCommitShas.push(landedShaOut.trim());
|
||||
}
|
||||
|
||||
if (testCommand || buildCommand) {
|
||||
throwIfAborted(signal, taskId);
|
||||
await runDeterministicVerification(
|
||||
store,
|
||||
rootDir,
|
||||
taskId,
|
||||
testCommand,
|
||||
buildCommand,
|
||||
testSource,
|
||||
buildSource,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
landedCommitCount: landedCommitShas.length,
|
||||
landedCommitShas,
|
||||
baseSha,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the --author flag for git commits based on project settings. */
|
||||
function getCommitAuthorArg(settings: {
|
||||
commitAuthorEnabled?: boolean;
|
||||
@@ -4157,14 +4278,115 @@ function quoteArg(value: string): string {
|
||||
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
function shouldRunPostSquashAudit(result: MergeResult, mergeWasEmpty: boolean, isEmptyCommit: boolean, commitSha?: string): boolean {
|
||||
function parseDirectMergeCommitStrategyOverride(prompt: string | undefined): DirectMergeCommitStrategy | undefined {
|
||||
if (!prompt) return undefined;
|
||||
const match = prompt.match(/^\*\*Direct Merge Commit Strategy:\*\*\s*(auto|always-squash|always-rebase)\s*$/im);
|
||||
return match?.[1] as DirectMergeCommitStrategy | undefined;
|
||||
}
|
||||
|
||||
function resolveDirectMergeCommitStrategy(
|
||||
settings: Pick<Settings, "directMergeCommitStrategy">,
|
||||
prompt: string | undefined,
|
||||
): { strategy: DirectMergeCommitStrategy; source: "project" | "prompt" } {
|
||||
const promptOverride = parseDirectMergeCommitStrategyOverride(prompt);
|
||||
if (promptOverride) {
|
||||
return { strategy: promptOverride, source: "prompt" };
|
||||
}
|
||||
return {
|
||||
strategy: settings.directMergeCommitStrategy ?? "auto",
|
||||
source: "project",
|
||||
};
|
||||
}
|
||||
|
||||
interface BranchCommitClassification {
|
||||
sha: string;
|
||||
subject: string;
|
||||
substantive: boolean;
|
||||
}
|
||||
|
||||
function isGeneratedOnlyPath(filePath: string): boolean {
|
||||
return GENERATED_PATTERNS.some((pattern) => matchGlob(filePath, pattern))
|
||||
|| LOCKFILE_PATTERNS.some((pattern) => matchGlob(filePath, pattern));
|
||||
}
|
||||
|
||||
function isNonSubstantiveCommitChange(change: { status: string; filePath: string }): boolean {
|
||||
if (change.filePath.startsWith(".changeset/")) {
|
||||
return change.status === "A";
|
||||
}
|
||||
return isGeneratedOnlyPath(change.filePath);
|
||||
}
|
||||
|
||||
async function classifyBranchCommitsForDirectMerge(
|
||||
rootDir: string,
|
||||
baseRef: string,
|
||||
branch: string,
|
||||
): Promise<{ commits: BranchCommitClassification[]; substantiveCommitCount: number }> {
|
||||
const { stdout: commitStdout } = await execAsync(`git rev-list --reverse ${quoteArg(`${baseRef}..${branch}`)}`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const commitShas = commitStdout.trim().split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const commits: BranchCommitClassification[] = [];
|
||||
|
||||
for (const sha of commitShas) {
|
||||
let subject = sha;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git log -1 --format=%s ${quoteArg(sha)}`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
subject = stdout.trim() || sha;
|
||||
} catch {
|
||||
// best-effort subject lookup
|
||||
}
|
||||
|
||||
let substantive = true;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff-tree --root --no-commit-id --name-status -r ${quoteArg(sha)}`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const changes = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [status, ...pathParts] = line.split(/\s+/);
|
||||
return { status: status ?? "", filePath: pathParts[pathParts.length - 1] ?? "" };
|
||||
})
|
||||
.filter((change) => change.filePath);
|
||||
substantive = changes.length === 0 || changes.some((change) => !isNonSubstantiveCommitChange(change));
|
||||
} catch {
|
||||
substantive = true;
|
||||
}
|
||||
|
||||
commits.push({ sha, subject, substantive });
|
||||
}
|
||||
|
||||
return {
|
||||
commits,
|
||||
substantiveCommitCount: commits.filter((commit) => commit.substantive).length,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRunPostMergeAudit(
|
||||
strategy: PostMergeAuditStrategy,
|
||||
result: MergeResult,
|
||||
mergeWasEmpty: boolean,
|
||||
isEmptyCommit: boolean,
|
||||
commitSha?: string,
|
||||
): boolean {
|
||||
if (mergeWasEmpty || isEmptyCommit || !commitSha) {
|
||||
return false;
|
||||
}
|
||||
if (strategy === "rebase") {
|
||||
return true;
|
||||
}
|
||||
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
|
||||
}
|
||||
|
||||
function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, findings: SquashAuditFindings): string {
|
||||
function buildPostMergeAuditBlockingMessage(taskId: string, findings: SquashAuditFindings): string {
|
||||
const riskParts: string[] = [];
|
||||
if (findings.duplicateSubjects.length > 0) {
|
||||
riskParts.push(`${findings.duplicateSubjects.length} duplicate-subject risk${findings.duplicateSubjects.length === 1 ? "" : "s"}`);
|
||||
@@ -4173,7 +4395,8 @@ function buildSquashAuditBlockingMessage(taskId: string, squashSha: string, find
|
||||
riskParts.push(`${findings.touchedFileOverlaps.length} touched-file overlap risk${findings.touchedFileOverlaps.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
const summary = riskParts.length > 0 ? riskParts.join(", ") : `${findings.issueCount} audit finding(s)`;
|
||||
return `${taskId}: post-squash audit blocked auto-completion for ${squashSha.slice(0, 8)} (${summary})`;
|
||||
const label = findings.strategy === "rebase" ? "post-rebase range audit" : "post-squash audit";
|
||||
return `${taskId}: ${label} blocked auto-completion for ${findings.auditTargetLabel.slice(0, 8)} (${summary})`;
|
||||
}
|
||||
|
||||
function formatSquashAuditAgentLog(findings: SquashAuditFindings): string {
|
||||
@@ -5607,6 +5830,37 @@ export async function aiMergeTask(
|
||||
diffStat = "(unable to read diff)";
|
||||
}
|
||||
|
||||
let selectedPostMergeAuditStrategy: PostMergeAuditStrategy = "squash";
|
||||
let classifiedBranchCommits: BranchCommitClassification[] = [];
|
||||
if (settings.mergeStrategy !== "pull-request") {
|
||||
const configuredRoute = resolveDirectMergeCommitStrategy(settings, task.prompt);
|
||||
if (configuredRoute.strategy === "auto") {
|
||||
try {
|
||||
const classification = await classifyBranchCommitsForDirectMerge(
|
||||
rootDir,
|
||||
diffBaseRef || mergeTarget.branch,
|
||||
branch,
|
||||
);
|
||||
classifiedBranchCommits = classification.commits;
|
||||
selectedPostMergeAuditStrategy = classification.substantiveCommitCount >= 2 ? "rebase" : "squash";
|
||||
} catch (error) {
|
||||
mergerLog.warn(`${taskId}: failed to classify branch commits for direct-merge routing: ${getCommandErrorMessage(error)}`);
|
||||
selectedPostMergeAuditStrategy = "squash";
|
||||
}
|
||||
} else {
|
||||
selectedPostMergeAuditStrategy = configuredRoute.strategy === "always-rebase" ? "rebase" : "squash";
|
||||
}
|
||||
|
||||
const classificationSummary = classifiedBranchCommits.length > 0
|
||||
? ` [${classifiedBranchCommits.map((commit) => `${commit.substantive ? "substantive" : "generated-only"}:${commit.subject}`).join("; ")}]`
|
||||
: "";
|
||||
const routeMessage =
|
||||
`Direct merge commit routing: ${selectedPostMergeAuditStrategy} ` +
|
||||
`(setting ${configuredRoute.strategy} from ${configuredRoute.source})${classificationSummary}`;
|
||||
mergerLog.log(`${taskId}: ${routeMessage}`);
|
||||
await store.appendAgentLog(taskId, routeMessage, "text", undefined, "merger");
|
||||
}
|
||||
|
||||
const aiMergeSummary = settings.useAiMergeCommitSummary
|
||||
? await generateAiMergeSummary(commitLog, diffStat, settings, rootDir)
|
||||
: null;
|
||||
@@ -6061,48 +6315,70 @@ export async function aiMergeTask(
|
||||
|
||||
// Track AI agent invocation for resolutionMethod calculation
|
||||
const aiTracker: AiInvocationTracker = { aiWasInvoked: false };
|
||||
let rebaseMergeBaseSha: string | undefined;
|
||||
|
||||
// Execute attempts with escalation
|
||||
let merged = false;
|
||||
|
||||
// Attempt 1: Standard AI merge
|
||||
merged = await mergeAttempt(1);
|
||||
|
||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
|
||||
// Skipped for "abort" — that strategy gives the user one AI shot, no more.
|
||||
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
|
||||
merged = await mergeAttempt(2);
|
||||
}
|
||||
|
||||
// Attempt 3: -X theirs (smart-prefer-branch) or -X ours (smart-prefer-main) fallback.
|
||||
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
|
||||
//
|
||||
// Also skipped when `preMergeRebaseFallthrough` is set: under prefer-main
|
||||
// the whole purpose of refusing -X ours after a failed rebase is to
|
||||
// prevent silent re-introduction of main's deletions. Layers 1+2 couldn't
|
||||
// unblock the rebase, so the worktree is still in a state where -X ours
|
||||
// would re-introduce branch-only content. Trust only AI Attempts 1+2 here
|
||||
// — their output is gated by deterministic verification (test + build),
|
||||
// which is what enforces the prefer-main safety contract.
|
||||
if (
|
||||
!merged
|
||||
&& smartConflictResolution
|
||||
&& mergeConflictStrategy !== "ai-only"
|
||||
&& mergeConflictStrategy !== "abort"
|
||||
&& !preMergeRebaseFallthrough
|
||||
) {
|
||||
merged = await mergeAttempt(3);
|
||||
} else if (!merged && preMergeRebaseFallthrough) {
|
||||
await store.logEntry(
|
||||
if (selectedPostMergeAuditStrategy === "rebase") {
|
||||
const rebaseResult = await applyBranchCommitsPreservingHistory({
|
||||
rootDir,
|
||||
baseRef: diffBaseRef || mergeTarget.branch,
|
||||
branch,
|
||||
task,
|
||||
taskId,
|
||||
`Attempt 3 (-X ours fallback) suppressed: pre-merge rebase recovery layers 1+2 failed under smart-prefer-main, so the unsafe ours-side fallback is skipped to honor the strategy's safety contract. Verification-gated AI Attempts 1+2 already exhausted; merge cannot complete safely without manual intervention.`,
|
||||
"PreMergeRebaseFallthrough",
|
||||
);
|
||||
}
|
||||
store,
|
||||
mergeConflictStrategy,
|
||||
smartConflictResolution,
|
||||
result,
|
||||
testCommand: effectiveTestCommand,
|
||||
buildCommand: effectiveBuildCommand,
|
||||
testSource: effectiveTestSource,
|
||||
buildSource: effectiveBuildSource,
|
||||
signal: options.signal,
|
||||
});
|
||||
rebaseMergeBaseSha = rebaseResult.baseSha;
|
||||
merged = true;
|
||||
} else {
|
||||
// Attempt 1: Standard AI merge
|
||||
merged = await mergeAttempt(1);
|
||||
|
||||
// Bubble the empty-merge flag up to the metadata block.
|
||||
if (aiTracker.mergeWasEmpty) {
|
||||
mergeWasEmpty = true;
|
||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled).
|
||||
// Skipped for "abort" — that strategy gives the user one AI shot, no more.
|
||||
if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
|
||||
merged = await mergeAttempt(2);
|
||||
}
|
||||
|
||||
// Attempt 3: -X theirs (smart-prefer-branch) or -X ours (smart-prefer-main) fallback.
|
||||
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
|
||||
//
|
||||
// Also skipped when `preMergeRebaseFallthrough` is set: under prefer-main
|
||||
// the whole purpose of refusing -X ours after a failed rebase is to
|
||||
// prevent silent re-introduction of main's deletions. Layers 1+2 couldn't
|
||||
// unblock the rebase, so the worktree is still in a state where -X ours
|
||||
// would re-introduce branch-only content. Trust only AI Attempts 1+2 here
|
||||
// — their output is gated by deterministic verification (test + build),
|
||||
// which is what enforces the prefer-main safety contract.
|
||||
if (
|
||||
!merged
|
||||
&& smartConflictResolution
|
||||
&& mergeConflictStrategy !== "ai-only"
|
||||
&& mergeConflictStrategy !== "abort"
|
||||
&& !preMergeRebaseFallthrough
|
||||
) {
|
||||
merged = await mergeAttempt(3);
|
||||
} else if (!merged && preMergeRebaseFallthrough) {
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Attempt 3 (-X ours fallback) suppressed: pre-merge rebase recovery layers 1+2 failed under smart-prefer-main, so the unsafe ours-side fallback is skipped to honor the strategy's safety contract. Verification-gated AI Attempts 1+2 already exhausted; merge cannot complete safely without manual intervention.`,
|
||||
"PreMergeRebaseFallthrough",
|
||||
);
|
||||
}
|
||||
|
||||
// Bubble the empty-merge flag up to the metadata block.
|
||||
if (aiTracker.mergeWasEmpty) {
|
||||
mergeWasEmpty = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts failed
|
||||
@@ -6134,7 +6410,10 @@ export async function aiMergeTask(
|
||||
let deletions: number | undefined;
|
||||
|
||||
try {
|
||||
const { stdout: statsOutput } = await execAsync("git show --shortstat --format= HEAD", {
|
||||
const statsCommand = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
||||
? `git diff --shortstat ${quoteArg(`${rebaseMergeBaseSha}..HEAD`)}`
|
||||
: "git show --shortstat --format= HEAD";
|
||||
const { stdout: statsOutput } = await execAsync(statsCommand, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
@@ -6163,11 +6442,19 @@ export async function aiMergeTask(
|
||||
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
|
||||
|
||||
const auditSha = recordedSha;
|
||||
if (auditSha && shouldRunPostSquashAudit(result, mergeWasEmpty, isEmptyCommit, auditSha)) {
|
||||
const auditFindings = await auditSquashMerge({
|
||||
rootDir,
|
||||
squashSha: auditSha,
|
||||
});
|
||||
if (auditSha && shouldRunPostMergeAudit(selectedPostMergeAuditStrategy, result, mergeWasEmpty, isEmptyCommit, auditSha)) {
|
||||
const auditFindings = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
||||
? await auditSquashMerge({
|
||||
rootDir,
|
||||
strategy: "rebase",
|
||||
rangeBaseSha: rebaseMergeBaseSha,
|
||||
rangeHeadSha: auditSha,
|
||||
})
|
||||
: await auditSquashMerge({
|
||||
rootDir,
|
||||
strategy: "squash",
|
||||
squashSha: auditSha,
|
||||
});
|
||||
if (!auditFindings.clean) {
|
||||
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
|
||||
await store.appendAgentLog(
|
||||
@@ -6180,7 +6467,13 @@ export async function aiMergeTask(
|
||||
await store.updateTask(taskId, { status: null });
|
||||
throw auditError;
|
||||
}
|
||||
await store.appendAgentLog(taskId, "post-squash audit clean", "text", undefined, "merger");
|
||||
await store.appendAgentLog(
|
||||
taskId,
|
||||
selectedPostMergeAuditStrategy === "rebase" ? "post-rebase range audit clean" : "post-squash audit clean",
|
||||
"text",
|
||||
undefined,
|
||||
"merger",
|
||||
);
|
||||
}
|
||||
if (isEmptyCommit) {
|
||||
mergerLog.warn(
|
||||
|
||||
Reference in New Issue
Block a user