Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -952,4 +952,75 @@ describe("conflictService conflict context integrity", () => {
expect(rebased.success).toBe(true);
expect(git(repoRoot, ["rev-list", "--count", "HEAD..main"])).toBe("0");
});

it("prefers the current parent lane branch when baseRef is stale", async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-parent-branch-"));
const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger());
const projectId = randomUUID();
const now = "2026-03-24T12:00:00.000Z";

try {
db.run(
"insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
[projectId, repoRoot, "demo", "main", now, now]
);

fs.writeFileSync(path.join(repoRoot, "file.txt"), "base\n", "utf8");
git(repoRoot, ["init", "-b", "main"]);
git(repoRoot, ["config", "user.email", "ade@test.local"]);
git(repoRoot, ["config", "user.name", "ADE Test"]);
git(repoRoot, ["add", "."]);
git(repoRoot, ["commit", "-m", "base"]);

git(repoRoot, ["checkout", "-b", "feature/parent-current"]);
git(repoRoot, ["checkout", "-b", "feature/child"]);
fs.writeFileSync(path.join(repoRoot, "file.txt"), "child\n", "utf8");
git(repoRoot, ["add", "file.txt"]);
git(repoRoot, ["commit", "-m", "child work"]);

git(repoRoot, ["checkout", "main"]);
fs.writeFileSync(path.join(repoRoot, "main.txt"), "main advance\n", "utf8");
git(repoRoot, ["add", "main.txt"]);
git(repoRoot, ["commit", "-m", "main advance"]);
git(repoRoot, ["checkout", "feature/child"]);

const parentLane = {
...createLaneSummary(repoRoot, {
id: "lane-parent",
name: "Primary",
branchRef: "feature/parent-current",
baseRef: "main",
parentLaneId: null
}),
laneType: "primary" as const,
};
const childLane = createLaneSummary(repoRoot, {
id: "lane-child",
name: "Child",
branchRef: "feature/child",
baseRef: "main",
parentLaneId: "lane-parent"
});

const service = createConflictService({
db,
logger: createLogger(),
projectId,
projectRoot: repoRoot,
laneService: {
list: async () => [parentLane, childLane],
getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/child" })
} as any,
projectConfigService: {
get: () => ({ effective: { providerMode: "guest" }, local: {} })
} as any,
});

expect(await service.scanRebaseNeeds()).toEqual([]);
expect(await service.getRebaseNeed("lane-child")).toBeNull();
} finally {
db.close();
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
Comment on lines +956 to +1025
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Harden this test to avoid false positives from missing primary remote-tracking refs.

Because Line 995 sets the parent as a primary lane, the production path may resolve origin/<branch>. This fixture never creates tracking refs, so the test can pass due to ref-resolution failure paths instead of validating the intended parent-branch behavior.

🧪 Suggested fixture hardening
       git(repoRoot, ["init", "-b", "main"]);
       git(repoRoot, ["config", "user.email", "ade@test.local"]);
       git(repoRoot, ["config", "user.name", "ADE Test"]);
       git(repoRoot, ["add", "."]);
       git(repoRoot, ["commit", "-m", "base"]);
+      const originRoot = path.join(repoRoot, "..", "origin.git");
+      fs.mkdirSync(originRoot, { recursive: true });
+      git(originRoot, ["init", "--bare"]);
+      git(repoRoot, ["remote", "add", "origin", originRoot]);
+      git(repoRoot, ["push", "-u", "origin", "main"]);

       git(repoRoot, ["checkout", "-b", "feature/parent-current"]);
+      git(repoRoot, ["push", "-u", "origin", "feature/parent-current"]);
       git(repoRoot, ["checkout", "-b", "feature/child"]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/conflicts/conflictService.test.ts` around
lines 956 - 1025, The test can false-pass because primary parent lane resolution
may try origin/<branch> which the fixture never creates; update the fixture in
this test (around parentLane and the repo setup before createConflictService) to
create a remote-tracking ref for feature/parent-current (e.g. add an "origin"
remote and push or create refs/remotes/origin/feature/parent-current pointing at
the branch commit) so ref-resolution exercises the intended code paths used by
scanRebaseNeeds() and getRebaseNeed("lane-child") instead of hitting missing-ref
fallback behavior.

});
58 changes: 53 additions & 5 deletions apps/desktop/src/main/services/conflicts/conflictService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { redactSecretsDeep } from "../../utils/redaction";
import { extractFirstJsonObject } from "../ai/utils";
import { safeSegment } from "../shared/packLegacyUtils";
import { fetchQueueTargetTrackingBranches, resolveQueueRebaseOverride } from "../shared/queueRebase";
import type { QueueRebaseOverride } from "../shared/queueRebase";
import { asString, isRecord, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils";

type PredictionStatus = "clean" | "conflict" | "unknown";
Expand Down Expand Up @@ -269,6 +270,40 @@ async function readTouchedFiles(cwd: string, mergeBase: string, headSha: string)
return new Set(parseDiffNameOnly(res.stdout));
}

function resolveLaneRebaseTarget(args: {
lane: LaneSummary;
lanesById: Map<string, LaneSummary>;
queueOverride: QueueRebaseOverride | null;
}): {
comparisonRef: string;
displayBaseBranch: string;
} {
if (args.queueOverride) {
return {
comparisonRef: args.queueOverride.comparisonRef,
displayBaseBranch: args.queueOverride.displayBaseBranch,
};
}

const parent = args.lane.parentLaneId ? args.lanesById.get(args.lane.parentLaneId) ?? null : null;
const parentBranchRef = parent?.branchRef?.trim() ?? "";
if (parentBranchRef) {
// For primary lanes, prefer the remote tracking ref (origin/<branch>) to stay
// consistent with laneService.resolveParentRebaseTarget which rebases against
// the remote tracking ref rather than the local HEAD.
const comparisonRef = parent?.laneType === "primary" ? `origin/${parentBranchRef}` : parentBranchRef;
return {
comparisonRef,
displayBaseBranch: parentBranchRef,
};
}

return {
comparisonRef: args.lane.baseRef,
displayBaseBranch: args.lane.baseRef,
};
}

async function readDiffNumstat(cwd: string, mergeBase: string, headSha: string): Promise<{
files: Set<string>;
insertions: number;
Expand Down Expand Up @@ -4173,6 +4208,7 @@ export function createConflictService({
}

const lanes = await listActiveLanes();
const lanesById = new Map(lanes.map((lane) => [lane.id, lane] as const));
const needs: RebaseNeed[] = [];

// Skip primary lane — it IS the base, rebasing it is nonsensical
Expand All @@ -4185,8 +4221,11 @@ export function createConflictService({
projectRoot,
laneId: lane.id,
});
const comparisonRef = queueOverride?.comparisonRef ?? lane.baseRef;
const displayBaseBranch = queueOverride?.displayBaseBranch ?? lane.baseRef;
const { comparisonRef, displayBaseBranch } = resolveLaneRebaseTarget({
lane,
lanesById,
queueOverride,
});
const baseHead = await readHeadSha(projectRoot, comparisonRef);
const laneHead = await readHeadSha(lane.worktreePath, "HEAD");

Expand Down Expand Up @@ -4244,6 +4283,7 @@ export function createConflictService({
});

const lanes = await listActiveLanes();
const lanesById = new Map(lanes.map((entry) => [entry.id, entry] as const));
const lane = lanes.find((l) => l.id === laneId);
if (!lane || lane.laneType === "primary") return null;

Expand All @@ -4254,8 +4294,11 @@ export function createConflictService({
projectRoot,
laneId: lane.id,
});
const comparisonRef = queueOverride?.comparisonRef ?? lane.baseRef;
const displayBaseBranch = queueOverride?.displayBaseBranch ?? lane.baseRef;
const { comparisonRef, displayBaseBranch } = resolveLaneRebaseTarget({
lane,
lanesById,
queueOverride,
});
const baseHead = await readHeadSha(projectRoot, comparisonRef);
const laneHead = await readHeadSha(lane.worktreePath, "HEAD");

Expand Down Expand Up @@ -4340,6 +4383,7 @@ export function createConflictService({

try {
const lanes = await listActiveLanes();
const lanesById = new Map(lanes.map((entry) => [entry.id, entry] as const));
const lane = lanes.find((l) => l.id === args.laneId);
if (!lane) {
return {
Expand Down Expand Up @@ -4384,7 +4428,11 @@ export function createConflictService({
projectRoot,
laneId: lane.id,
});
const rebaseTarget = queueOverride?.comparisonRef ?? lane.baseRef;
const { comparisonRef: rebaseTarget } = resolveLaneRebaseTarget({
lane,
lanesById,
queueOverride,
});
const rebaseRes = await runGit(
["rebase", rebaseTarget],
{ cwd: lane.worktreePath, timeoutMs: 120_000 }
Expand Down
59 changes: 58 additions & 1 deletion apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ vi.mock("../git/git", () => ({
runGitOrThrow: vi.fn(),
}));

import { getHeadSha, runGit } from "../git/git";
import { getHeadSha, runGit, runGitOrThrow } from "../git/git";

function createLogger() {
return {
Expand Down Expand Up @@ -61,6 +61,7 @@ describe("laneService rebaseStart", () => {
beforeEach(() => {
vi.mocked(getHeadSha).mockReset();
vi.mocked(runGit).mockReset();
vi.mocked(runGitOrThrow).mockReset();
});

it("skips rebasing when the parent head is already an ancestor of the lane head", async () => {
Expand Down Expand Up @@ -118,6 +119,9 @@ describe("laneService rebaseStart", () => {
if (args[0] === "merge-base" && args[1] === "--is-ancestor") {
return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" });
}
if (args[0] === "status" && args[1] === "--porcelain=v1") {
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
}
if (args[0] === "rebase") {
return new Promise((resolve) => {
resolveRebase = resolve;
Expand Down Expand Up @@ -155,4 +159,57 @@ describe("laneService rebaseStart", () => {
const completed = await firstRun;
expect(completed.run.state).toBe("completed");
});

it("rebases against the primary lane remote tracking ref when it is available", async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-remote-"));
const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger());
await seedProjectAndStack(db, { projectId: "proj-primary-remote", repoRoot });

vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any);
vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => {
if (cwd.endsWith("/parent")) return "sha-parent";
return "sha-main";
});
vi.mocked(runGit).mockImplementation(async (args: string[]) => {
if (
args[0] === "rev-parse"
&& args[1] === "--abbrev-ref"
&& args[2] === "--symbolic-full-name"
&& args[3] === "@{upstream}"
) {
return { exitCode: 0, stdout: "origin/main\n", stderr: "" };
}
if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") {
return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" };
}
if (args[0] === "merge-base" && args[1] === "--is-ancestor") {
expect(args[2]).toBe("sha-origin-main");
expect(args[3]).toBe("sha-parent");
return { exitCode: 1, stdout: "", stderr: "" };
}
if (args[0] === "status" && args[1] === "--porcelain=v1") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "rebase") {
expect(args[1]).toBe("sha-origin-main");
return { exitCode: 0, stdout: "", stderr: "" };
}
throw new Error(`Unexpected git call: ${args.join(" ")}`);
});

const service = createLaneService({
db,
projectRoot: repoRoot,
projectId: "proj-primary-remote",
defaultBaseRef: "main",
worktreesDir: path.join(repoRoot, "worktrees"),
});

const result = await service.rebaseStart({ laneId: "lane-parent", scope: "lane_only", actor: "user" });

expect(result.run.state).toBe("completed");
expect(result.run.error).toBeNull();
expect(result.run.lanes[0]?.status).toBe("succeeded");
expect(vi.mocked(runGitOrThrow)).toHaveBeenCalled();
});
});
74 changes: 68 additions & 6 deletions apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import type { AdeDb } from "../state/kvDb";
import { getHeadSha, runGit, runGitOrThrow } from "../git/git";
import { isWithinDir } from "../shared/utils";
import { resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase";
import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase";
import { detectConflictKind } from "../git/gitConflictState";
import type { createOperationService } from "../history/operationService";
import type {
Expand Down Expand Up @@ -226,6 +226,59 @@ async function computeLaneStatus(worktreePath: string, baseRef: string, branchRe
return { dirty, ahead, behind, remoteBehind, rebaseInProgress };
}

async function resolveParentRebaseTarget(args: {
projectRoot: string;
parent: LaneRow;
}): Promise<{ headSha: string; label: string }> {
const { projectRoot, parent } = args;

if (parent.lane_type === "primary") {
await fetchRemoteTrackingBranch({
projectRoot,
targetBranch: parent.branch_ref,
}).catch(() => false);

const candidateRefs: string[] = [];
const upstreamRes = await runGit(
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
{ cwd: parent.worktree_path, timeoutMs: 5_000 },
);
if (upstreamRes.exitCode === 0 && upstreamRes.stdout.trim()) {
candidateRefs.push(upstreamRes.stdout.trim());
}
const originRef = `origin/${parent.branch_ref}`;
if (!candidateRefs.includes(originRef)) {
candidateRefs.push(originRef);
}

for (const candidateRef of candidateRefs) {
const candidateRes = await runGit(
["rev-parse", "--verify", candidateRef],
{ cwd: parent.worktree_path, timeoutMs: 5_000 },
);
if (candidateRes.exitCode === 0 && candidateRes.stdout.trim()) {
return {
headSha: candidateRes.stdout.trim(),
label: candidateRef,
};
}
}
}

const headSha = await getHeadSha(parent.worktree_path);
if (!headSha) {
throw new Error(`Unable to resolve parent HEAD for ${parent.name}`);
}
return {
headSha,
label: parent.name,
};
}

function describeParentRebaseTarget(parent: LaneRow, label: string): string {
return label === parent.name ? parent.name : `${parent.name} (${label})`;
}

function computeStackDepth(args: {
laneId: string;
rowsById: Map<string, LaneRow>;
Expand Down Expand Up @@ -1274,11 +1327,20 @@ export function createLaneService({
break;
}

const parentHead = await getHeadSha(parent.worktree_path);
if (!parentHead) {
failRunAtLane(laneItem, lane.id, index, `Unable to resolve parent HEAD for ${parent.name}`);
let parentTarget: { headSha: string; label: string };
try {
parentTarget = await resolveParentRebaseTarget({ projectRoot, parent });
} catch (error) {
failRunAtLane(
laneItem,
lane.id,
index,
error instanceof Error ? error.message : `Unable to resolve parent HEAD for ${parent.name}`,
);
break;
}
const parentHead = parentTarget.headSha;
const parentTargetLabel = describeParentRebaseTarget(parent, parentTarget.label);

run.currentLaneId = lane.id;
laneItem.preHeadSha = await getHeadSha(lane.worktree_path);
Expand All @@ -1298,7 +1360,7 @@ export function createLaneService({
emitRunLog({
runId,
laneId: lane.id,
message: `${lane.name} is already up to date with ${parent.name}; skipping rebase.`,
message: `${lane.name} is already up to date with ${parentTargetLabel}; skipping rebase.`,
});
emitRunUpdated(run);
continue;
Expand All @@ -1323,7 +1385,7 @@ export function createLaneService({
emitRunLog({
runId,
laneId: lane.id,
message: `Rebasing ${lane.name} onto ${parent.name} (${parentHead.slice(0, 8)})`
message: `Rebasing ${lane.name} onto ${parentTargetLabel} (${parentHead.slice(0, 8)})`
});

const operation = operationService?.start({
Expand Down
Loading
Loading