Skip to content

Commit 920b86f

Browse files
committed
refactor: simplify git clone logic.
1 parent 5ecd08a commit 920b86f

File tree

2 files changed

+68
-37
lines changed

2 files changed

+68
-37
lines changed

src/deploy/functions/remoteSource.spec.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ describe("remoteSource", () => {
1313
let cloneStub: sinon.SinonStub;
1414
let fetchStub: sinon.SinonStub;
1515
let checkoutStub: sinon.SinonStub;
16-
let initSparseStub: sinon.SinonStub;
17-
let setSparseStub: sinon.SinonStub;
1816
let mockGitClient: GitClient;
1917

2018
beforeEach(() => {
@@ -24,14 +22,10 @@ describe("remoteSource", () => {
2422
cloneStub = sinon.stub().returns({ status: 0 });
2523
fetchStub = sinon.stub().returns({ status: 0 });
2624
checkoutStub = sinon.stub().returns({ status: 0 });
27-
initSparseStub = sinon.stub().returns({ status: 0 });
28-
setSparseStub = sinon.stub().returns({ status: 0 });
2925
mockGitClient = {
3026
clone: cloneStub,
3127
fetch: fetchStub,
3228
checkout: checkoutStub,
33-
initSparseCheckout: initSparseStub,
34-
setSparsePaths: setSparseStub,
3529
} as unknown as GitClient;
3630
});
3731

@@ -66,24 +60,69 @@ describe("remoteSource", () => {
6660

6761
it("should validate subdirectory exists after clone", async () => {
6862
isGitAvailableStub.returns(true);
69-
setSparseStub.returns({
70-
status: 1,
71-
stderr: "fatal: pathspec 'subdir' did not match any files",
63+
// Simulate that the subdirectory does not exist
64+
existsSyncStub.callsFake((p: fs.PathLike) => {
65+
const s = String(p);
66+
if (/[/\\]subdir$/.test(s)) return false; // dir missing
67+
if (s.endsWith("functions.yaml")) return true; // avoid manifest error masking
68+
return true;
7269
});
73-
7470
await expect(
7571
cloneRemoteSource("https://github.com/org/repo", "main", "subdir", mockGitClient),
7672
).to.be.rejectedWith(FirebaseError, /Directory 'subdir' not found/);
7773
});
7874

7975
it("should validate functions.yaml exists", async () => {
8076
isGitAvailableStub.returns(true);
81-
existsSyncStub.withArgs(sinon.match(/firebase-functions-remote/)).returns(true);
82-
existsSyncStub.withArgs(sinon.match(/functions\.yaml$/)).returns(false);
77+
// Everything exists except the manifest file
78+
existsSyncStub.callsFake((p: fs.PathLike) => !String(p).endsWith("functions.yaml"));
8379

8480
await expect(
8581
cloneRemoteSource("https://github.com/org/repo", "main", undefined, mockGitClient),
8682
).to.be.rejectedWith(FirebaseError, /missing a required deployment manifest/);
8783
});
84+
85+
it("should successfully clone a repository without a subdirectory", async () => {
86+
isGitAvailableStub.returns(true);
87+
// Pass manifest check by returning true for any path ending with functions.yaml
88+
existsSyncStub.callsFake((p: fs.PathLike) => String(p).endsWith("functions.yaml"));
89+
90+
const sourceDir = await cloneRemoteSource(
91+
"https://github.com/org/repo",
92+
"main",
93+
undefined,
94+
mockGitClient,
95+
);
96+
97+
expect(cloneStub.calledOnceWith("https://github.com/org/repo", sinon.match.string)).to.be
98+
.true;
99+
expect(fetchStub.calledOnceWith("main", sinon.match.string)).to.be.true;
100+
expect(checkoutStub.calledOnceWith("FETCH_HEAD", sinon.match.string)).to.be.true;
101+
// No sparse-checkout in MVP path
102+
expect(sourceDir).to.be.a("string");
103+
});
104+
105+
it("should successfully clone a repository with a subdirectory", async () => {
106+
isGitAvailableStub.returns(true);
107+
existsSyncStub.callsFake((p: fs.PathLike) => {
108+
const s = String(p);
109+
if (/[/\\]functions$/.test(s)) return true; // subdir exists
110+
if (s.endsWith("functions.yaml")) return true; // manifest exists
111+
return false;
112+
});
113+
114+
const dir = "functions";
115+
const sourceDir = await cloneRemoteSource(
116+
"https://github.com/org/repo",
117+
"main",
118+
dir,
119+
mockGitClient,
120+
);
121+
122+
expect(fetchStub.calledOnceWith("main", sinon.match.string)).to.be.true;
123+
expect(checkoutStub.calledOnceWith("FETCH_HEAD", sinon.match.string)).to.be.true;
124+
expect(sourceDir).to.be.a("string");
125+
expect(/[/\\]functions$/.test(sourceDir)).to.be.true;
126+
});
88127
});
89128
});

src/deploy/functions/remoteSource.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ export interface GitClient {
1111
clone(repository: string, destination: string): SpawnSyncReturns<string>;
1212
fetch(ref: string, cwd: string): SpawnSyncReturns<string>;
1313
checkout(ref: string, cwd: string): SpawnSyncReturns<string>;
14-
initSparseCheckout(cwd: string): SpawnSyncReturns<string>;
15-
setSparsePaths(paths: string[], cwd: string): SpawnSyncReturns<string>;
1614
}
1715

1816
export class DefaultGitClient implements GitClient {
@@ -34,14 +32,6 @@ export class DefaultGitClient implements GitClient {
3432
checkout(ref: string, cwd: string): SpawnSyncReturns<string> {
3533
return spawnSync("git", ["checkout", ref], { cwd, encoding: "utf8" });
3634
}
37-
38-
initSparseCheckout(cwd: string): SpawnSyncReturns<string> {
39-
return spawnSync("git", ["sparse-checkout", "init", "--cone"], { cwd, encoding: "utf8" });
40-
}
41-
42-
setSparsePaths(paths: string[], cwd: string): SpawnSyncReturns<string> {
43-
return spawnSync("git", ["sparse-checkout", "set", ...paths], { cwd, encoding: "utf8" });
44-
}
4535
}
4636

4737
export async function cloneRemoteSource(
@@ -84,30 +74,29 @@ export async function cloneRemoteSource(
8474

8575
const cloneResult = await runGitWithRetry(() => gitClient.clone(repository, tmpDir.name));
8676
if (cloneResult.error || cloneResult.status !== 0) {
87-
throw new Error(cloneResult.stderr || cloneResult.stdout || "Clone failed");
88-
}
89-
90-
// If a subdirectory is specified, use sparse checkout to limit the working tree.
91-
if (dir) {
92-
const initSparse = gitClient.initSparseCheckout(tmpDir.name);
93-
if (initSparse.error || initSparse.status !== 0) {
94-
throw new Error(initSparse.stderr || initSparse.stdout || "Sparse checkout init failed");
95-
}
96-
const setSparse = gitClient.setSparsePaths([dir], tmpDir.name);
97-
if (setSparse.error || setSparse.status !== 0) {
98-
throw new FirebaseError(`Directory '${dir}' not found in repository ${repository}@${ref}`);
99-
}
77+
throw cloneResult.error ||
78+
new Error(
79+
cloneResult.stderr || cloneResult.stdout || `Clone failed with status ${cloneResult.status}`,
80+
);
10081
}
10182

10283
// Fetch just the requested ref shallowly, then check it out.
10384
const fetchResult = await runGitWithRetry(() => gitClient.fetch(ref, tmpDir.name));
10485
if (fetchResult.error || fetchResult.status !== 0) {
105-
throw new Error(fetchResult.stderr || fetchResult.stdout || "Fetch failed");
86+
throw fetchResult.error ||
87+
new Error(
88+
fetchResult.stderr || fetchResult.stdout || `Fetch failed with status ${fetchResult.status}`,
89+
);
10690
}
10791

10892
const checkoutResult = gitClient.checkout("FETCH_HEAD", tmpDir.name);
10993
if (checkoutResult.error || checkoutResult.status !== 0) {
110-
throw new Error(checkoutResult.stderr || checkoutResult.stdout || "Checkout failed");
94+
throw checkoutResult.error ||
95+
new Error(
96+
checkoutResult.stderr ||
97+
checkoutResult.stdout ||
98+
`Checkout failed with status ${checkoutResult.status}`,
99+
);
111100
}
112101

113102
const sourceDir = dir
@@ -117,6 +106,9 @@ export async function cloneRemoteSource(
117106
`Subdirectory '${dir}' in remote source must not escape the repository root.`,
118107
)
119108
: tmpDir.name;
109+
if (dir && !fs.existsSync(sourceDir)) {
110+
throw new FirebaseError(`Directory '${dir}' not found in repository ${repository}@${ref}`);
111+
}
120112
requireFunctionsYaml(sourceDir);
121113
const origin = `${repository}@${ref}${dir ? `/${dir}` : ""}`;
122114
let sha: string | undefined;

0 commit comments

Comments
 (0)