Skip to content

Commit 9f3d31c

Browse files
authored
Use direct force pushes by committing first to a temp branch (#70)
* Switch to using REST API for branch upserts * Rollback to using graphql * Use a temp branch strategy * add tests * fix lint * fmt * fix graphql usage * add changeset * Apply suggestion from @Andarist
1 parent aa60c37 commit 9f3d31c

4 files changed

Lines changed: 271 additions & 95 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@changesets/ghcommit": minor
3+
---
4+
5+
Improve force-push handling so updating an existing branch no longer temporarily resets the target branch to the base commit, avoiding cases where GitHub closes open pull requests during the update.

src/core.ts

Lines changed: 175 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import {
22
createCommitOnBranchQuery,
3-
createRefMutation,
43
getRepositoryMetadata,
5-
updateRefMutation,
64
} from "./github/graphql/queries.js";
7-
import type {
8-
CreateCommitOnBranchMutationVariables,
9-
GetRepositoryMetadataQuery,
10-
} from "./github/graphql/generated/operations.js";
5+
import type { GetRepositoryMetadataQuery } from "./github/graphql/generated/operations.js";
116
import {
127
CommitFilesFromBase64Args,
138
CommitFilesResult,
@@ -21,6 +16,8 @@ const getBaseRef = (base: GitBase): string => {
2116
} else if ("tag" in base) {
2217
return `refs/tags/${base.tag}`;
2318
} else {
19+
// For explicit commit bases we don't resolve the base oid from a ref,
20+
// but the shared metadata query still expects a valid qualified ref name.
2421
return "HEAD";
2522
}
2623
};
@@ -45,6 +42,47 @@ const getOidFromRef = (
4542
return ref.target.oid;
4643
};
4744

45+
const isAlreadyExistingRefError = (error: unknown) =>
46+
typeof error === "object" &&
47+
error !== null &&
48+
"status" in error &&
49+
"message" in error &&
50+
typeof error.status === "number" &&
51+
typeof error.message === "string" &&
52+
error.status === 422 &&
53+
error.message.includes("Reference already exists");
54+
55+
const createCommit = async ({
56+
octokit,
57+
refId,
58+
baseOid,
59+
message,
60+
fileChanges,
61+
}: Pick<CommitFilesFromBase64Args, "octokit" | "message" | "fileChanges"> & {
62+
refId: string;
63+
baseOid: string;
64+
}) => {
65+
const normalizedMessage: CommitMessage =
66+
typeof message === "string"
67+
? {
68+
headline: message.split("\n")[0]?.trim() ?? "",
69+
body: message.split("\n").slice(1).join("\n").trim(),
70+
}
71+
: message;
72+
73+
// we have to stick to GraphQL here as with REST, each file change would become a separate API call
74+
return createCommitOnBranchQuery(octokit, {
75+
input: {
76+
branch: {
77+
id: refId,
78+
},
79+
expectedHeadOid: baseOid,
80+
message: normalizedMessage,
81+
fileChanges,
82+
},
83+
});
84+
};
85+
4886
export const commitFilesFromBase64 = async ({
4987
octokit,
5088
owner,
@@ -70,115 +108,160 @@ export const commitFilesFromBase64 = async ({
70108
log?.debug(`Repo info: ${JSON.stringify(info, null, 2)}`);
71109

72110
if (!info) {
73-
throw new Error(`Repository ${repositoryNameWithOwner} not found`);
111+
throw new Error(
112+
`Repository ${JSON.stringify(repositoryNameWithOwner)} not found`,
113+
);
114+
}
115+
if (!("commit" in base) && !info.baseRef) {
116+
throw new Error(`Ref ${JSON.stringify(baseRef)} not found`);
74117
}
75118

76-
const repositoryId = info.id;
119+
const resolvedBaseRef = info.baseRef;
120+
77121
/**
78122
* The commit oid to base the new commit on.
79123
*
80-
* Used both to create / update the new branch (if necessary),
81-
* and to ensure no changes have been made as we push the new commit.
124+
* Used both to create the new commit,
125+
* and to determine whether an existing branch can be updated.
82126
*/
83127
const baseOid = getOidFromRef(base, info.baseRef);
128+
const targetOid = info.targetBranch?.target?.oid ?? null;
129+
const sameBranchBase = "branch" in base && base.branch === branch;
84130

85-
let refId: string;
86-
87-
if ("branch" in base && base.branch === branch) {
88-
log?.debug(`Committing to the same branch as base: ${branch} (${baseOid})`);
89-
// Get existing branch refId
131+
let mode: "create" | "update" | "force-update";
90132

91-
if (!info.baseRef) {
92-
throw new Error(`Ref ${baseRef} not found`);
93-
}
94-
refId = info.baseRef.id;
133+
if (sameBranchBase) {
134+
mode = force ? "force-update" : "update";
135+
} else if (targetOid === null) {
136+
// TODO: legit *creation* failure should be retried if `force === true`
137+
mode = "create";
138+
} else if (force) {
139+
mode = "force-update";
140+
} else if (targetOid === baseOid) {
141+
mode = "update";
95142
} else {
96-
// Determine if the branch needs to be created or not
97-
if (info.targetBranch?.target?.oid) {
98-
// Branch already exists, check if it matches the base
99-
if (info.targetBranch.target.oid !== baseOid) {
100-
if (force) {
101-
log?.debug(
102-
`Branch ${branch} exists but does not match base ${baseOid}, forcing update to base`,
103-
);
104-
const refIdUpdate = await updateRefMutation(octokit, {
105-
input: {
106-
refId: info.targetBranch.id,
107-
oid: baseOid,
108-
force: true,
109-
},
110-
});
111-
112-
log?.debug(
113-
`Updated branch with refId ${JSON.stringify(refIdUpdate, null, 2)}`,
114-
);
115-
116-
const refIdStr = refIdUpdate.updateRef?.ref?.id;
117-
118-
if (!refIdStr) {
119-
throw new Error(`Failed to create branch ${branch}`);
120-
}
121-
122-
refId = refIdStr;
123-
} else {
124-
throw new Error(
125-
`Branch ${branch} exists already and does not match base ${baseOid}, force is set to false`,
126-
);
127-
}
128-
} else {
129-
log?.debug(
130-
`Branch ${branch} already exists and matches base ${baseOid}`,
131-
);
132-
refId = info.targetBranch.id;
133-
}
134-
} else {
135-
// Create branch as it does not exist yet
136-
log?.debug(`Creating branch ${branch} from commit ${baseOid}}`);
137-
const refIdCreation = await createRefMutation(octokit, {
138-
input: {
139-
repositoryId,
140-
name: `refs/heads/${branch}`,
141-
oid: baseOid,
142-
},
143+
throw new Error(
144+
`Branch ${branch} exists already and does not match base ${baseOid}, force is set to false`,
145+
);
146+
}
147+
148+
if (mode === "force-update") {
149+
// Use a stable temp branch name so a later run can recover and reuse it
150+
// if an earlier run failed before cleanup completed.
151+
const tempBranch = `changesets-ghcommit-temp/${branch}`;
152+
153+
let tempRefId: string;
154+
155+
try {
156+
const createdTempRef = await octokit.rest.git.createRef({
157+
owner,
158+
repo,
159+
ref: `refs/heads/${tempBranch}`,
160+
sha: baseOid,
143161
});
144162

145-
log?.debug(
146-
`Created branch with refId ${JSON.stringify(refIdCreation, null, 2)}`,
147-
);
163+
const refIdStr = createdTempRef.data.node_id;
148164

149-
const refIdStr = refIdCreation.createRef?.ref?.id;
165+
if (!refIdStr) {
166+
throw new Error(`Failed to create temporary branch ${tempBranch}`);
167+
}
168+
169+
tempRefId = refIdStr;
170+
} catch (error) {
171+
if (!isAlreadyExistingRefError(error)) {
172+
throw error;
173+
}
174+
175+
const updatedTempRef = await octokit.rest.git.updateRef({
176+
owner,
177+
repo,
178+
ref: `heads/${tempBranch}`,
179+
sha: baseOid,
180+
force: true,
181+
});
182+
183+
const refIdStr = updatedTempRef.data.node_id;
150184

151185
if (!refIdStr) {
152-
throw new Error(`Failed to create branch ${branch}`);
186+
throw new Error(`Failed to update temporary branch ${tempBranch}`);
153187
}
154188

155-
refId = refIdStr;
189+
tempRefId = refIdStr;
190+
}
191+
192+
log?.debug(`Creating commit on branch ${tempBranch}`);
193+
const tempCommit = await createCommit({
194+
octokit,
195+
refId: tempRefId,
196+
baseOid,
197+
message,
198+
fileChanges,
199+
});
200+
201+
const tempHeadOid = tempCommit.createCommitOnBranch?.commit?.oid;
202+
203+
if (!tempHeadOid) {
204+
throw new Error(
205+
`Failed to determine head commit of temporary branch ${tempBranch}`,
206+
);
156207
}
208+
209+
const updatedTargetRef = await octokit.rest.git.updateRef({
210+
owner,
211+
repo,
212+
ref: `heads/${branch}`,
213+
sha: tempHeadOid,
214+
force: true,
215+
});
216+
217+
const updatedTargetRefId = updatedTargetRef.data.node_id;
218+
219+
if (!updatedTargetRefId) {
220+
throw new Error(`Failed to update branch ${branch}`);
221+
}
222+
223+
await octokit.rest.git.deleteRef({
224+
owner,
225+
repo,
226+
ref: `heads/${tempBranch}`,
227+
});
228+
229+
return {
230+
refId: updatedTargetRefId,
231+
};
157232
}
158233

159-
const finalMessage: CommitMessage =
160-
typeof message === "string"
161-
? {
162-
headline: message.split("\n")[0]?.trim() ?? "",
163-
body: message.split("\n").slice(1).join("\n").trim(),
164-
}
165-
: message;
234+
let refId: string;
235+
236+
if (mode === "create") {
237+
const createdRef = await octokit.rest.git.createRef({
238+
owner,
239+
repo,
240+
ref: `refs/heads/${branch}`,
241+
sha: baseOid,
242+
});
243+
244+
const refIdStr = createdRef.data.node_id;
245+
246+
if (!refIdStr) {
247+
throw new Error(`Failed to create branch ${branch}`);
248+
}
249+
250+
refId = refIdStr;
251+
} else {
252+
refId = sameBranchBase ? resolvedBaseRef!.id : info.targetBranch!.id;
253+
}
166254

167255
log?.debug(`Creating commit on branch ${branch}`);
168-
const createCommitMutation: CreateCommitOnBranchMutationVariables = {
169-
input: {
170-
branch: {
171-
id: refId,
172-
},
173-
expectedHeadOid: baseOid,
174-
message: finalMessage,
175-
fileChanges,
176-
},
177-
};
178-
log?.debug(JSON.stringify(createCommitMutation, null, 2));
256+
const newCommit = await createCommit({
257+
octokit,
258+
refId,
259+
baseOid,
260+
message,
261+
fileChanges,
262+
});
179263

180-
const result = await createCommitOnBranchQuery(octokit, createCommitMutation);
181264
return {
182-
refId: result.createCommitOnBranch?.ref?.id ?? null,
265+
refId: newCommit.createCommitOnBranch?.ref?.id ?? null,
183266
};
184267
};

src/github/graphql/queries.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export type GitHubClient = {
2-
graphql: <T>(query: string, variables: any) => Promise<T>;
3-
};
1+
export type GitHubClient = ReturnType<
2+
typeof import("@actions/github").getOctokit
3+
>;
44

55
import type {
66
CreateCommitOnBranchMutation,
@@ -78,6 +78,9 @@ const DELETE_REF = /* GraphQL */ `
7878
const CREATE_COMMIT_ON_BRANCH = /* GraphQL */ `
7979
mutation createCommitOnBranch($input: CreateCommitOnBranchInput!) {
8080
createCommitOnBranch(input: $input) {
81+
commit {
82+
oid
83+
}
8184
ref {
8285
id
8386
}

0 commit comments

Comments
 (0)