Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/pr-stack.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,13 @@
"number": 79,
"branch": "fork/vscode"
}
],
"conflictResolutions": [
{
"branch": "fork/changes",
"commit": "10de598e790218c772ada3d908bc7b1eb1e54f0e",
"path": "apps/mobile/src/lib/threadActivity.ts",
"strategy": "theirs"
}
]
}
35 changes: 35 additions & 0 deletions scripts/rebase-pr-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,41 @@ describe("rebase-pr-stack", () => {
assert.deepStrictEqual(remoteTips(fixture), before);
});

it("applies an exact manifest conflict resolution and completes the atomic update", async () => {
const fixture = createFixture({ conflict: true });
const conflictingCommit = remoteTip(fixture.origin, "feature/pr-4");
const manifest: StackManifest = {
...fixture.manifest,
conflictResolutions: [
{
branch: "feature/pr-4",
commit: conflictingCommit,
path: "shared.txt",
strategy: "theirs",
},
],
};
write(
NodePath.join(fixture.work, ".github", "pr-stack.json"),
`${JSON.stringify(manifest, undefined, 2)}\n`,
);

await syncStack({
sourceRoot: fixture.work,
push: true,
validatePullRequests: false,
});

assert.equal(runGit(fixture.origin, ["show", "feature/pr-4:shared.txt"]), "from pr 4");
assert.ok(
isAncestor(
fixture.origin,
remoteTip(fixture.upstream, "main"),
remoteTip(fixture.origin, "feature/pr-4"),
),
);
});

it("aborts every ref update when a force-with-lease becomes stale", async () => {
const fixture = createFixture();
const before = remoteTips(fixture);
Expand Down
90 changes: 86 additions & 4 deletions scripts/rebase-pr-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,21 @@ export interface StackPullRequest {
readonly branch: string;
}

export interface StackConflictResolution {
readonly branch: string;
readonly commit: string;
readonly path: string;
readonly strategy: "ours" | "theirs";
}

export interface StackManifest {
readonly upstreamRemote: string;
readonly upstreamBranch: string;
readonly forkChangesBranch: string;
readonly integrationBranch: string;
readonly pullRequests: ReadonlyArray<StackPullRequest>;
readonly integrationOverlays: ReadonlyArray<StackPullRequest>;
readonly conflictResolutions?: ReadonlyArray<StackConflictResolution>;
}

export interface PullRequestSnapshot {
Expand Down Expand Up @@ -273,6 +281,7 @@ export function parseManifest(source: string): StackManifest {
integrationBranch,
pullRequests,
integrationOverlays = [],
conflictResolutions = [],
} = value;
if (
typeof upstreamRemote !== "string" ||
Expand All @@ -284,7 +293,8 @@ export function parseManifest(source: string): StackManifest {
typeof integrationBranch !== "string" ||
integrationBranch.length === 0 ||
!Array.isArray(pullRequests) ||
!Array.isArray(integrationOverlays)
!Array.isArray(integrationOverlays) ||
!Array.isArray(conflictResolutions)
) {
throw new StackError("The PR stack manifest has missing or invalid fields.");
}
Expand Down Expand Up @@ -313,6 +323,28 @@ export function parseManifest(source: string): StackManifest {
}
return { number: Number(entry.number), branch: entry.branch };
});
const parsedConflictResolutions = conflictResolutions.map((entry, index) => {
assertObject(entry, `conflictResolutions[${index}]`);
if (
typeof entry.branch !== "string" ||
entry.branch.length === 0 ||
typeof entry.commit !== "string" ||
!/^[0-9a-f]{40}$/i.test(entry.commit) ||
typeof entry.path !== "string" ||
entry.path.length === 0 ||
NodePath.isAbsolute(entry.path) ||
entry.path.split("/").includes("..") ||
(entry.strategy !== "ours" && entry.strategy !== "theirs")
) {
throw new StackError(`conflictResolutions[${index}] is invalid.`);
}
return {
branch: entry.branch,
commit: entry.commit.toLowerCase(),
path: entry.path,
strategy: entry.strategy,
} satisfies StackConflictResolution;
});

const managed = [...parsedPullRequests, ...parsedIntegrationOverlays];
const numbers = new Set(managed.map(({ number }) => number));
Expand All @@ -336,6 +368,9 @@ export function parseManifest(source: string): StackManifest {
integrationBranch,
pullRequests: parsedPullRequests,
integrationOverlays: parsedIntegrationOverlays,
...(parsedConflictResolutions.length > 0
? { conflictResolutions: parsedConflictResolutions }
: {}),
};
}

Expand Down Expand Up @@ -816,6 +851,45 @@ function conflictError(
);
}

function applyConfiguredConflictResolutions(
stateDir: string,
state: PersistedState,
operation: RebaseOperation,
): boolean {
const conflictingPaths = git(state.repoDir, ["diff", "--name-only", "--diff-filter=U"], {
stateDir,
})
.split("\n")
.filter(Boolean);
const commit = git(state.repoDir, ["rev-parse", "--verify", "REBASE_HEAD"], {
stateDir,
}).toLowerCase();
const configured = state.manifest.conflictResolutions ?? [];
const resolutions = conflictingPaths.map((path) =>
configured.find(
(entry) =>
entry.branch === operation.branch &&
entry.commit.toLowerCase() === commit &&
entry.path === path,
),
);
if (resolutions.some((entry) => entry === undefined)) {
return false;
}

for (const resolution of resolutions) {
if (!resolution) continue;
git(state.repoDir, ["checkout", `--${resolution.strategy}`, "--", resolution.path], {
stateDir,
});
git(state.repoDir, ["add", "--", resolution.path], { stateDir });
console.log(
`Applied configured ${resolution.strategy} resolution for ${operation.branch} ${commit.slice(0, 12)} ${resolution.path}`,
);
}
return true;
}

function finishOperation(
stateDir: string,
state: PersistedState,
Expand Down Expand Up @@ -844,7 +918,7 @@ function startOperation(
return finishOperation(stateDir, updated, operation);
}
git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.oldTip], { stateDir });
const result = run(
let result = run(
"git",
[
"-c",
Expand All @@ -862,10 +936,18 @@ function startOperation(
stateDir,
},
);
if (result.status !== 0) {
if (rebaseInProgress(updated.repoDir)) {
while (result.status !== 0 && rebaseInProgress(updated.repoDir)) {
if (!applyConfiguredConflictResolutions(stateDir, updated, operation)) {
throw conflictError(stateDir, updated, operation);
}
result = run("git", ["-c", "commit.gpgsign=false", "rebase", "--continue"], {
cwd: updated.repoDir,
allowFailure: true,
env: { GIT_EDITOR: "true" },
stateDir,
});
}
if (result.status !== 0) {
throw new GitCommandError(
["rebase", "--onto", operation.newBase, operation.oldBase, operation.oldTip],
updated.repoDir,
Expand Down
Loading