Skip to content

Commit 231d400

Browse files
h3rmanjs0
andauthored
Gracefully handle ignored symlinks (#34)
* tests: add a symlink to changes * tests: Add more tests for symlinks * fix: check isIgnored first * fix: Add more useful error for non-ignored symlinks * chore: add changeset --------- Co-authored-by: Sam Lanning <sam@samlanning.com>
1 parent 99331af commit 231d400

File tree

3 files changed

+211
-59
lines changed

3 files changed

+211
-59
lines changed

.changeset/spicy-zoos-wink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@changesets/ghcommit": patch
3+
---
4+
5+
More gracefully handle symlinks, and ignore them when included in .gitignore

src/git.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ import {
77
CommitFilesResult,
88
} from "./interface";
99

10+
/**
11+
* @see https://isomorphic-git.org/docs/en/walk#walkerentry-mode
12+
*/
13+
const FILE_MODES = {
14+
directory: 0o40000,
15+
file: 0o100644,
16+
executableFile: 0o100755,
17+
symlink: 0o120000,
18+
} as const;
19+
1020
export const commitChangesFromRepo = async ({
1121
base,
1222
repoDirectory = process.cwd(),
@@ -40,12 +50,6 @@ export const commitChangesFromRepo = async ({
4050
dir: repoDirectory,
4151
trees,
4252
map: async (filepath, [commit, workdir]) => {
43-
const prevOid = await commit?.oid();
44-
const currentOid = await workdir?.oid();
45-
// Don't include files that haven't changed, and exist in both trees
46-
if (prevOid === currentOid && !commit === !workdir) {
47-
return null;
48-
}
4953
// Don't include ignored files
5054
if (
5155
await git.isIgnored({
@@ -56,6 +60,20 @@ export const commitChangesFromRepo = async ({
5660
) {
5761
return null;
5862
}
63+
if (
64+
(await commit?.mode()) === FILE_MODES.symlink ||
65+
(await workdir?.mode()) === FILE_MODES.symlink
66+
) {
67+
throw new Error(
68+
`Unexpected symlink at ${filepath}, GitHub API only supports files and directories. You may need to add this file to .gitignore`,
69+
);
70+
}
71+
const prevOid = await commit?.oid();
72+
const currentOid = await workdir?.oid();
73+
// Don't include files that haven't changed, and exist in both trees
74+
if (prevOid === currentOid && !commit === !workdir) {
75+
return null;
76+
}
5977
// Iterate through anything that may be a directory in either the
6078
// current commit or the working directory
6179
if (

src/test/integration/git.test.ts

Lines changed: 182 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,14 @@ const expectParentHasOid = async ({
7878
expect(commit.parents.nodes).toEqual([{ oid }]);
7979
};
8080

81-
const makeFileChanges = async (repoDirectory: string) => {
81+
const makeFileChanges = async (
82+
repoDirectory: string,
83+
changegroup:
84+
| "standard"
85+
| "with-ignored-symlink"
86+
| "with-included-valid-symlink"
87+
| "with-included-invalid-symlink",
88+
) => {
8289
// Update an existing file
8390
await fs.promises.writeFile(
8491
path.join(repoDirectory, "LICENSE"),
@@ -113,6 +120,34 @@ const makeFileChanges = async (repoDirectory: string) => {
113120
path.join(repoDirectory, "coverage", "foo", "bar"),
114121
"This file should be ignored",
115122
);
123+
if (changegroup === "with-ignored-symlink") {
124+
// node_modules is ignored in this repo
125+
await fs.promises.mkdir(path.join(repoDirectory, "node_modules"), {
126+
recursive: true,
127+
});
128+
await fs.promises.symlink(
129+
path.join(repoDirectory, "non-existent"),
130+
path.join(repoDirectory, "node_modules", "nested"),
131+
);
132+
}
133+
if (changegroup === "with-included-valid-symlink") {
134+
await fs.promises.mkdir(path.join(repoDirectory, "some-dir"), {
135+
recursive: true,
136+
});
137+
await fs.promises.symlink(
138+
path.join(repoDirectory, "README.md"),
139+
path.join(repoDirectory, "some-dir", "nested"),
140+
);
141+
}
142+
if (changegroup === "with-included-invalid-symlink") {
143+
await fs.promises.mkdir(path.join(repoDirectory, "some-dir"), {
144+
recursive: true,
145+
});
146+
await fs.promises.symlink(
147+
path.join(repoDirectory, "non-existent"),
148+
path.join(repoDirectory, "some-dir", "nested"),
149+
);
150+
}
116151
};
117152

118153
const makeFileChangeAssertions = async (branch: string) => {
@@ -154,62 +189,156 @@ describe("git", () => {
154189
describe("commitChangesFromRepo", () => {
155190
const testDir = path.join(ROOT_TEMP_DIRECTORY, "commitChangesFromRepo");
156191

157-
it("should correctly commit all changes", async () => {
158-
const branch = `${TEST_BRANCH_PREFIX}-multiple-changes`;
159-
branches.push(branch);
160-
161-
await fs.promises.mkdir(testDir, { recursive: true });
162-
const repoDirectory = path.join(testDir, "repo-1");
163-
164-
// Clone the git repo locally using the git cli and child-process
165-
await new Promise<void>((resolve, reject) => {
166-
const p = execFile(
167-
"git",
168-
["clone", process.cwd(), "repo-1"],
169-
{ cwd: testDir },
170-
(error) => {
171-
if (error) {
172-
reject(error);
173-
} else {
174-
resolve();
175-
}
192+
for (const group of ["standard", "with-ignored-symlink"] as const) {
193+
it(`should correctly commit all changes for group: ${group}`, async () => {
194+
const branch = `${TEST_BRANCH_PREFIX}-multiple-changes-${group}`;
195+
branches.push(branch);
196+
197+
await fs.promises.mkdir(testDir, { recursive: true });
198+
const repoDirectory = path.join(testDir, `repo-1-${group}`);
199+
200+
// Clone the git repo locally using the git cli and child-process
201+
await new Promise<void>((resolve, reject) => {
202+
const p = execFile(
203+
"git",
204+
["clone", process.cwd(), `repo-1-${group}`],
205+
{ cwd: testDir },
206+
(error) => {
207+
if (error) {
208+
reject(error);
209+
} else {
210+
resolve();
211+
}
212+
},
213+
);
214+
p.stdout?.pipe(process.stdout);
215+
p.stderr?.pipe(process.stderr);
216+
});
217+
218+
await makeFileChanges(repoDirectory, group);
219+
220+
// Push the changes
221+
await commitChangesFromRepo({
222+
octokit,
223+
...REPO,
224+
branch,
225+
message: {
226+
headline: "Test commit",
227+
body: "This is a test commit",
176228
},
229+
repoDirectory,
230+
log,
231+
});
232+
233+
await waitForGitHubToBeReady();
234+
235+
await makeFileChangeAssertions(branch);
236+
237+
// Expect the OID to be the HEAD commit
238+
const oid =
239+
(
240+
await git.log({
241+
fs,
242+
dir: repoDirectory,
243+
ref: "HEAD",
244+
depth: 1,
245+
})
246+
)[0]?.oid ?? "NO_OID";
247+
248+
await expectParentHasOid({ branch, oid });
249+
});
250+
}
251+
252+
describe(`should throw appropriate error when symlink is present`, () => {
253+
it(`and file does not exist`, async () => {
254+
const branch = `${TEST_BRANCH_PREFIX}-invalid-symlink-error`;
255+
branches.push(branch);
256+
257+
await fs.promises.mkdir(testDir, { recursive: true });
258+
const repoDirectory = path.join(testDir, `repo-invalid-symlink`);
259+
260+
// Clone the git repo locally using the git cli and child-process
261+
await new Promise<void>((resolve, reject) => {
262+
const p = execFile(
263+
"git",
264+
["clone", process.cwd(), `repo-invalid-symlink`],
265+
{ cwd: testDir },
266+
(error) => {
267+
if (error) {
268+
reject(error);
269+
} else {
270+
resolve();
271+
}
272+
},
273+
);
274+
p.stdout?.pipe(process.stdout);
275+
p.stderr?.pipe(process.stderr);
276+
});
277+
278+
await makeFileChanges(repoDirectory, "with-included-invalid-symlink");
279+
280+
// Push the changes
281+
await expect(() =>
282+
commitChangesFromRepo({
283+
octokit,
284+
...REPO,
285+
branch,
286+
message: {
287+
headline: "Test commit",
288+
body: "This is a test commit",
289+
},
290+
repoDirectory,
291+
log,
292+
}),
293+
).rejects.toThrow(
294+
"Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore",
177295
);
178-
p.stdout?.pipe(process.stdout);
179-
p.stderr?.pipe(process.stderr);
180296
});
181297

182-
await makeFileChanges(repoDirectory);
183-
184-
// Push the changes
185-
await commitChangesFromRepo({
186-
octokit,
187-
...REPO,
188-
branch,
189-
message: {
190-
headline: "Test commit",
191-
body: "This is a test commit",
192-
},
193-
repoDirectory,
194-
log,
298+
it(`and file exists`, async () => {
299+
const branch = `${TEST_BRANCH_PREFIX}-valid-symlink-error`;
300+
branches.push(branch);
301+
302+
await fs.promises.mkdir(testDir, { recursive: true });
303+
const repoDirectory = path.join(testDir, `repo-valid-symlink`);
304+
305+
// Clone the git repo locally using the git cli and child-process
306+
await new Promise<void>((resolve, reject) => {
307+
const p = execFile(
308+
"git",
309+
["clone", process.cwd(), `repo-valid-symlink`],
310+
{ cwd: testDir },
311+
(error) => {
312+
if (error) {
313+
reject(error);
314+
} else {
315+
resolve();
316+
}
317+
},
318+
);
319+
p.stdout?.pipe(process.stdout);
320+
p.stderr?.pipe(process.stderr);
321+
});
322+
323+
await makeFileChanges(repoDirectory, "with-included-valid-symlink");
324+
325+
// Push the changes
326+
await expect(() =>
327+
commitChangesFromRepo({
328+
octokit,
329+
...REPO,
330+
branch,
331+
message: {
332+
headline: "Test commit",
333+
body: "This is a test commit",
334+
},
335+
repoDirectory,
336+
log,
337+
}),
338+
).rejects.toThrow(
339+
"Unexpected symlink at some-dir/nested, GitHub API only supports files and directories. You may need to add this file to .gitignore",
340+
);
195341
});
196-
197-
await waitForGitHubToBeReady();
198-
199-
await makeFileChangeAssertions(branch);
200-
201-
// Expect the OID to be the HEAD commit
202-
const oid =
203-
(
204-
await git.log({
205-
fs,
206-
dir: repoDirectory,
207-
ref: "HEAD",
208-
depth: 1,
209-
})
210-
)[0]?.oid ?? "NO_OID";
211-
212-
await expectParentHasOid({ branch, oid });
213342
});
214343

215344
it("should correctly be able to base changes off specific commit", async () => {
@@ -237,7 +366,7 @@ describe("git", () => {
237366
p.stderr?.pipe(process.stderr);
238367
});
239368

240-
makeFileChanges(repoDirectory);
369+
makeFileChanges(repoDirectory, "standard");
241370

242371
// Determine the previous commit hash
243372
const gitLog = await git.log({

0 commit comments

Comments
 (0)