Skip to content

Commit 67853bf

Browse files
authored
Improve commitFilesFromBase64 update handling (#116)
1 parent 169b8fa commit 67853bf

4 files changed

Lines changed: 157 additions & 188 deletions

File tree

.changeset/little-colts-bet.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@changesets/ghcommit": minor
3+
---
4+
5+
Improve create, update, and force update handling in `commitFilesFromBase64`:
6+
7+
- Correctly detect if the `base` is the same as `branch` by comparing their SHAs instead of names.
8+
- Perform a normal update if the branch exists and the `base` and branch HEAD SHAs match, instead of always a force update if `force` is true.
9+
- Always clean up created or existing temporary branches during force updates, even if it fails.
10+
- Always return a non-nullable `refId` from `commitFilesFromBase64` (and consequently `commitChangesFromRepo`). If the commit fails, it'll throw an error instead, similar to existing parts of the implementation.

src/core.ts

Lines changed: 145 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -9,53 +9,10 @@ import type {
99
} from "./interface.ts";
1010
import { normalizeCommitMessage, resolveGitRef } from "./utils.ts";
1111

12-
function getBaseRefSha(
13-
baseRef: NonNullable<GetRepositoryMetadataQuery["repository"]>["baseRef"],
14-
) {
15-
if (!baseRef?.target) return null;
16-
17-
if ("target" in baseRef.target) {
18-
return baseRef.target.target.oid;
19-
}
20-
21-
return baseRef.target.oid;
22-
}
23-
24-
function isAlreadyExistingRefError(error: unknown) {
25-
return (
26-
typeof error === "object" &&
27-
error !== null &&
28-
"status" in error &&
29-
"message" in error &&
30-
typeof error.status === "number" &&
31-
typeof error.message === "string" &&
32-
error.status === 422 &&
33-
error.message.includes("Reference already exists")
34-
);
35-
}
36-
37-
async function createCommit({
38-
octokit,
39-
refId,
40-
baseSha,
41-
message,
42-
fileChanges,
43-
}: Pick<CommitFilesFromBase64Args, "octokit" | "message" | "fileChanges"> & {
44-
refId: string;
45-
baseSha: string;
46-
}) {
47-
// we have to stick to GraphQL here as with REST, each file change would become a separate API call
48-
return createCommitOnBranchQuery(octokit, {
49-
input: {
50-
branch: {
51-
id: refId,
52-
},
53-
expectedHeadOid: baseSha,
54-
message: normalizeCommitMessage(message),
55-
fileChanges,
56-
},
57-
});
58-
}
12+
type CreateCommit = (
13+
refId: string,
14+
branch: string,
15+
) => Promise<{ commitSha: string }>;
5916

6017
export async function commitFilesFromBase64({
6118
octokit,
@@ -68,164 +25,198 @@ export async function commitFilesFromBase64({
6825
fileChanges,
6926
}: CommitFilesFromBase64Args): Promise<CommitFilesResult> {
7027
const baseRef = resolveGitRef(base);
71-
const targetRef = `refs/heads/${branch}`;
7228

7329
const info = await getRepositoryMetadata(octokit, {
7430
owner,
7531
repo,
7632
baseRef,
77-
targetRef,
33+
targetRef: `refs/heads/${branch}`,
7834
});
79-
8035
if (!info) {
8136
throw new Error(`Repository "${owner}/${repo}" not found`);
8237
}
8338

84-
/**
85-
* The commit sha to base the new commit on.
86-
*
87-
* Used both to create the new commit,
88-
* and to determine whether an existing branch can be updated.
89-
*/
39+
// Commits will base off this sha as the parent. Also used to check if this
40+
// is the same as the target branch's HEAD so we can safely commit to it.
9041
const baseSha = "commit" in base ? base.commit : getBaseRefSha(info.baseRef);
9142
if (!baseSha) {
9243
throw new Error(`Could not determine sha for base ref "${baseRef}"`);
9344
}
9445
const targetSha = info.targetBranch?.target?.oid ?? null;
95-
const sameBranchBase = "branch" in base && base.branch === branch;
96-
97-
let mode: "create" | "update" | "force-update";
98-
99-
if (sameBranchBase) {
100-
mode = force ? "force-update" : "update";
101-
} else if (targetSha === null) {
102-
// TODO: legit *creation* failure should be retried if `force === true`
103-
mode = "create";
104-
} else if (force) {
105-
mode = "force-update";
106-
} else if (targetSha === baseSha) {
107-
mode = "update";
108-
} else {
109-
throw new Error(
110-
`Branch ${branch} exists already and does not match base ${baseSha}, force is set to false`,
111-
);
46+
47+
const createCommit: CreateCommit = async (refId, branch) => {
48+
// Use GraphQL because REST would require each non-text file change to be a
49+
// separate `createBlob` call. While in most cases users would commit only
50+
// text files, it's hard to guarantee that and it's simpler to keep the API
51+
// as only accepting base64 content.
52+
const result = await createCommitOnBranchQuery(octokit, {
53+
input: {
54+
branch: { id: refId },
55+
expectedHeadOid: baseSha,
56+
message: normalizeCommitMessage(message),
57+
fileChanges,
58+
},
59+
});
60+
// `ref.id` is the same as `refId`. We only use `ref.id` to verify the commit succeeded
61+
if (result.createCommitOnBranch?.ref?.id == null) {
62+
throw new Error(`Failed to create commit on branch "${branch}"`);
63+
}
64+
if (result.createCommitOnBranch?.commit?.oid == null) {
65+
throw new Error(
66+
`Failed to determine commit sha for commit on branch "${branch}"`,
67+
);
68+
}
69+
return { commitSha: result.createCommitOnBranch.commit.oid };
70+
};
71+
72+
// [CREATE] If the branch does not exist, create and commit to it
73+
if (targetSha == null) {
74+
const createdRef = await octokit.rest.git.createRef({
75+
owner,
76+
repo,
77+
ref: `refs/heads/${branch}`,
78+
sha: baseSha,
79+
});
80+
const createdRefId = createdRef.data.node_id;
81+
if (!createdRefId) {
82+
throw new Error(`Failed to create branch "${branch}"`);
83+
}
84+
85+
await createCommit(createdRefId, branch);
86+
87+
return { refId: createdRefId };
11288
}
89+
// [UPDATE] If the branch exists and its HEAD matches the base, we can safely
90+
// directly commit to it
91+
else if (targetSha === baseSha) {
92+
// Safety: `targetSha` already ensures that `targetBranch` exists
93+
const targetRefId = info.targetBranch!.id;
11394

114-
if (mode === "force-update") {
115-
// Use a stable temp branch name so a later run can recover and reuse it
116-
// if an earlier run failed before cleanup completed.
117-
const tempBranch = `changesets-ghcommit-temp/${branch}`;
95+
await createCommit(targetRefId, branch);
11896

119-
let tempRefId: string;
97+
return { refId: targetRefId };
98+
}
99+
// [FORCE UPDATE] If the branch exists but its HEAD does not match the base,
100+
// we can only update it if `force` is true, which works like a force push.
101+
//
102+
// FORCE UPDATE creates a temporary branch, commits to it and returns the
103+
// sha, and then force updates the existing branch with the new sha. We cannot
104+
// reset the branch and then commit because if the branch has an existing PR,
105+
// GitHub will auto-close as it sees there's no changes with the base.
106+
else if (force) {
107+
const tempBranch = `changesets-ghcommit-temp/${branch}`;
120108

121109
try {
122-
const createdTempRef = await octokit.rest.git.createRef({
110+
const { tempRefId } = await createOrForceUpdateTemporaryBranch({
111+
octokit,
123112
owner,
124113
repo,
125-
ref: `refs/heads/${tempBranch}`,
126-
sha: baseSha,
114+
tempBranch,
115+
baseSha,
127116
});
128117

129-
const refIdStr = createdTempRef.data.node_id;
130-
131-
if (!refIdStr) {
132-
throw new Error(`Failed to create temporary branch ${tempBranch}`);
133-
}
134-
135-
tempRefId = refIdStr;
136-
} catch (error) {
137-
if (!isAlreadyExistingRefError(error)) {
138-
throw error;
139-
}
118+
const { commitSha } = await createCommit(tempRefId, tempBranch);
140119

141-
const updatedTempRef = await octokit.rest.git.updateRef({
120+
const updatedRef = await octokit.rest.git.updateRef({
142121
owner,
143122
repo,
144-
ref: `heads/${tempBranch}`,
145-
sha: baseSha,
123+
ref: `heads/${branch}`,
124+
sha: commitSha,
146125
force: true,
147126
});
148-
149-
const refIdStr = updatedTempRef.data.node_id;
150-
151-
if (!refIdStr) {
152-
throw new Error(`Failed to update temporary branch ${tempBranch}`);
127+
const updatedRefId = updatedRef.data.node_id;
128+
if (!updatedRefId) {
129+
throw new Error(`Failed to force update branch "${branch}"`);
153130
}
154131

155-
tempRefId = refIdStr;
132+
return { refId: updatedRefId };
133+
} finally {
134+
// Clean up the temporary branch
135+
await octokit.rest.git.deleteRef({
136+
owner,
137+
repo,
138+
ref: `heads/${tempBranch}`,
139+
});
156140
}
141+
}
142+
// [ERROR] If the branch exists but its HEAD does not match the base, and
143+
// `force` isn't true, we cannot commit to it. Throw an error.
144+
else {
145+
throw new Error(
146+
`Branch "${branch}" exists but its HEAD does not match the base ${baseSha} and \`force\` is set to false`,
147+
);
148+
}
149+
}
157150

158-
const tempCommit = await createCommit({
159-
octokit,
160-
refId: tempRefId,
161-
baseSha,
162-
message,
163-
fileChanges,
164-
});
151+
function getBaseRefSha(
152+
baseRef: NonNullable<GetRepositoryMetadataQuery["repository"]>["baseRef"],
153+
) {
154+
if (!baseRef?.target) return null;
165155

166-
const tempHeadSha = tempCommit.createCommitOnBranch?.commit?.oid;
156+
if ("target" in baseRef.target) {
157+
return baseRef.target.target.oid;
158+
}
167159

168-
if (!tempHeadSha) {
169-
throw new Error(
170-
`Failed to determine head commit of temporary branch ${tempBranch}`,
171-
);
172-
}
160+
return baseRef.target.oid;
161+
}
173162

174-
const updatedTargetRef = await octokit.rest.git.updateRef({
163+
async function createOrForceUpdateTemporaryBranch({
164+
octokit,
165+
owner,
166+
repo,
167+
tempBranch,
168+
baseSha,
169+
}: Pick<CommitFilesFromBase64Args, "octokit" | "owner" | "repo"> & {
170+
tempBranch: string;
171+
baseSha: string;
172+
}) {
173+
try {
174+
const createdTempRef = await octokit.rest.git.createRef({
175175
owner,
176176
repo,
177-
ref: `heads/${branch}`,
178-
sha: tempHeadSha,
179-
force: true,
177+
ref: `refs/heads/${tempBranch}`,
178+
sha: baseSha,
180179
});
181180

182-
const updatedTargetRefId = updatedTargetRef.data.node_id;
181+
const createdTempRefId = createdTempRef.data.node_id;
182+
if (!createdTempRefId) {
183+
throw new Error(`Failed to create temporary branch "${tempBranch}"`);
184+
}
183185

184-
if (!updatedTargetRefId) {
185-
throw new Error(`Failed to update branch ${branch}`);
186+
return { tempRefId: createdTempRefId };
187+
} catch (error) {
188+
if (!isAlreadyExistingRefError(error)) {
189+
throw error;
186190
}
187191

188-
await octokit.rest.git.deleteRef({
192+
const updatedTempRef = await octokit.rest.git.updateRef({
189193
owner,
190194
repo,
191195
ref: `heads/${tempBranch}`,
192-
});
193-
194-
return {
195-
refId: updatedTargetRefId,
196-
};
197-
}
198-
199-
let refId: string;
200-
201-
if (mode === "create") {
202-
const createdRef = await octokit.rest.git.createRef({
203-
owner,
204-
repo,
205-
ref: `refs/heads/${branch}`,
206196
sha: baseSha,
197+
force: true,
207198
});
208199

209-
const refIdStr = createdRef.data.node_id;
210-
211-
if (!refIdStr) {
212-
throw new Error(`Failed to create branch ${branch}`);
200+
const updatedTempRefId = updatedTempRef.data.node_id;
201+
if (!updatedTempRefId) {
202+
throw new Error(
203+
`Failed to force update temporary branch "${tempBranch}"`,
204+
);
213205
}
214206

215-
refId = refIdStr;
216-
} else {
217-
refId = sameBranchBase ? info.baseRef!.id : info.targetBranch!.id;
207+
return { tempRefId: updatedTempRefId };
218208
}
209+
}
219210

220-
const newCommit = await createCommit({
221-
octokit,
222-
refId,
223-
baseSha,
224-
message,
225-
fileChanges,
226-
});
227-
228-
return {
229-
refId: newCommit.createCommitOnBranch?.ref?.id ?? null,
230-
};
211+
function isAlreadyExistingRefError(error: unknown) {
212+
return (
213+
typeof error === "object" &&
214+
error !== null &&
215+
"status" in error &&
216+
"message" in error &&
217+
typeof error.status === "number" &&
218+
typeof error.message === "string" &&
219+
error.status === 422 &&
220+
error.message.includes("Reference already exists")
221+
);
231222
}

src/interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
import type { GitHubClient } from "./github/graphql/queries.ts";
66

77
export type CommitFilesResult = {
8-
refId: string | null;
8+
refId: string;
99
};
1010

1111
export type GitRef =

0 commit comments

Comments
 (0)