@symbiosis-lab/moss-plugin-github 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +50 -0
|
@@ -0,0 +1,2411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GitHub Deployment Module
|
|
3
|
+
*
|
|
4
|
+
* Tests verifyRepoExists and deployViaGitPush (single-repo with tree extraction).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
// Mock fetch globally
|
|
10
|
+
const mockFetch = vi.fn();
|
|
11
|
+
global.fetch = mockFetch;
|
|
12
|
+
|
|
13
|
+
// Mock utils
|
|
14
|
+
vi.mock("../utils", () => ({
|
|
15
|
+
showToast: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock @symbiosis-lab/moss-api for executeBinary and listSiteFilesWithSizes
|
|
19
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
20
|
+
executeBinary: vi.fn(),
|
|
21
|
+
listSiteFilesWithSizes: vi.fn().mockResolvedValue([]),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { executeBinary, listSiteFilesWithSizes } from "@symbiosis-lab/moss-api";
|
|
25
|
+
import type { ExecuteResult } from "@symbiosis-lab/moss-api";
|
|
26
|
+
import { showToast } from "../utils";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
deployViaGitPush,
|
|
30
|
+
verifyRepoExists,
|
|
31
|
+
getOriginOwnerRepo,
|
|
32
|
+
looksLikeCorruptGit,
|
|
33
|
+
resolveCurrentGenDir,
|
|
34
|
+
type DeployViaGitPushOptions,
|
|
35
|
+
type DeployResult,
|
|
36
|
+
} from "../github-deploy";
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Test Constants
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
const TOKEN = "ghp_test-token-123";
|
|
43
|
+
const OWNER = "testuser";
|
|
44
|
+
const REPO = "my-site";
|
|
45
|
+
|
|
46
|
+
/** Simulated absolute readlink output for .moss/build/current */
|
|
47
|
+
const GEN_ABS = "/Users/test/.../project/.moss/build/generations/gen-abc123def456";
|
|
48
|
+
/** Relative generation directory path (what resolveCurrentGenDir returns) */
|
|
49
|
+
const GEN_DIR = ".moss/build/generations/gen-abc123def456";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Helper to create a mock Response for fetch
|
|
53
|
+
*/
|
|
54
|
+
function mockResponse(body: unknown, status = 200, ok = true): Partial<Response> {
|
|
55
|
+
return {
|
|
56
|
+
ok,
|
|
57
|
+
status,
|
|
58
|
+
json: () => Promise.resolve(body),
|
|
59
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mockErrorResponse(status: number, message: string): Partial<Response> {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
status,
|
|
67
|
+
json: () => Promise.resolve({ message }),
|
|
68
|
+
text: () => Promise.resolve(JSON.stringify({ message })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Tests
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
describe("github-deploy", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
mockFetch.mockReset();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ==========================================================================
|
|
82
|
+
// verifyRepoExists
|
|
83
|
+
// ==========================================================================
|
|
84
|
+
describe("verifyRepoExists", () => {
|
|
85
|
+
it("succeeds silently when repo exists (200)", async () => {
|
|
86
|
+
mockFetch.mockResolvedValueOnce(mockResponse({ id: 123, name: "my-site" }));
|
|
87
|
+
|
|
88
|
+
await expect(verifyRepoExists(OWNER, REPO, TOKEN)).resolves.toBeUndefined();
|
|
89
|
+
|
|
90
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
91
|
+
"https://api.github.com/repos/testuser/my-site",
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
headers: expect.objectContaining({
|
|
94
|
+
Authorization: "Bearer ghp_test-token-123",
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("throws repo-not-found error when 404 and owner exists", async () => {
|
|
101
|
+
// First call: GET /repos/{owner}/{repo} → 404
|
|
102
|
+
mockFetch.mockResolvedValueOnce(mockErrorResponse(404, "Not Found"));
|
|
103
|
+
// Second call: GET /users/{owner} → 200 (owner exists)
|
|
104
|
+
mockFetch.mockResolvedValueOnce(mockResponse({ login: OWNER }));
|
|
105
|
+
|
|
106
|
+
await expect(verifyRepoExists(OWNER, REPO, TOKEN)).rejects.toThrow(
|
|
107
|
+
`Repository "${OWNER}/${REPO}" not found`
|
|
108
|
+
);
|
|
109
|
+
// Should have made the disambiguation call
|
|
110
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
111
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
112
|
+
2,
|
|
113
|
+
`https://api.github.com/users/${OWNER}`,
|
|
114
|
+
expect.objectContaining({
|
|
115
|
+
headers: expect.not.objectContaining({ Authorization: expect.anything() }),
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("throws owner-not-found error when 404 and owner does not exist", async () => {
|
|
121
|
+
// First call: GET /repos/{owner}/{repo} → 404
|
|
122
|
+
mockFetch.mockResolvedValueOnce(mockErrorResponse(404, "Not Found"));
|
|
123
|
+
// Second call: GET /users/{owner} → 404 (owner doesn't exist)
|
|
124
|
+
mockFetch.mockResolvedValueOnce(mockErrorResponse(404, "Not Found"));
|
|
125
|
+
|
|
126
|
+
await expect(verifyRepoExists(OWNER, REPO, TOKEN)).rejects.toThrow(
|
|
127
|
+
`GitHub user or organization "${OWNER}" not found`
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("throws invalid token error on 401", async () => {
|
|
132
|
+
mockFetch.mockResolvedValueOnce(mockErrorResponse(401, "Unauthorized"));
|
|
133
|
+
|
|
134
|
+
await expect(verifyRepoExists(OWNER, REPO, TOKEN)).rejects.toThrow(
|
|
135
|
+
"GitHub token is invalid or expired"
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws access denied error on 403", async () => {
|
|
140
|
+
mockFetch.mockResolvedValueOnce(mockErrorResponse(403, "Forbidden"));
|
|
141
|
+
|
|
142
|
+
await expect(verifyRepoExists(OWNER, REPO, TOKEN)).rejects.toThrow(
|
|
143
|
+
`Access denied to "${OWNER}/${REPO}"`
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ==========================================================================
|
|
150
|
+
// getOriginOwnerRepo
|
|
151
|
+
// ==========================================================================
|
|
152
|
+
describe("getOriginOwnerRepo", () => {
|
|
153
|
+
const mockExecuteBinary = vi.mocked(executeBinary);
|
|
154
|
+
|
|
155
|
+
function gitResult(success: boolean, stdout = "", stderr = ""): { success: boolean; exitCode: number; stdout: string; stderr: string } {
|
|
156
|
+
return { success, exitCode: success ? 0 : 1, stdout, stderr };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
mockExecuteBinary.mockReset();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns owner/repo from HTTPS origin", async () => {
|
|
164
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
165
|
+
gitResult(true, "https://github.com/testuser/my-site.git\n")
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const result = await getOriginOwnerRepo();
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual({ owner: "testuser", repo: "my-site" });
|
|
171
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
binaryPath: "git",
|
|
174
|
+
args: ["remote", "get-url", "origin"],
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns owner/repo from SSH origin", async () => {
|
|
180
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
181
|
+
gitResult(true, "git@github.com:testuser/my-site.git\n")
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const result = await getOriginOwnerRepo();
|
|
185
|
+
expect(result).toEqual({ owner: "testuser", repo: "my-site" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("handles dotted repo names (username.github.io)", async () => {
|
|
189
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
190
|
+
gitResult(true, "https://github.com/guoliu/guoliu.github.io.git\n")
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await getOriginOwnerRepo();
|
|
194
|
+
expect(result).toEqual({ owner: "guoliu", repo: "guoliu.github.io" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns null when no .git directory (command fails)", async () => {
|
|
198
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
199
|
+
gitResult(false, "", "fatal: not a git repository")
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const result = await getOriginOwnerRepo();
|
|
203
|
+
expect(result).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns null when origin is not a GitHub URL", async () => {
|
|
207
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
208
|
+
gitResult(true, "https://gitlab.com/user/repo.git\n")
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const result = await getOriginOwnerRepo();
|
|
212
|
+
expect(result).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns null when origin remote does not exist", async () => {
|
|
216
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
217
|
+
gitResult(false, "", "fatal: No such remote 'origin'")
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const result = await getOriginOwnerRepo();
|
|
221
|
+
expect(result).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ==========================================================================
|
|
226
|
+
// resolveCurrentGenDir
|
|
227
|
+
// ==========================================================================
|
|
228
|
+
describe("resolveCurrentGenDir", () => {
|
|
229
|
+
const mockExecuteBinary = vi.mocked(executeBinary);
|
|
230
|
+
|
|
231
|
+
function execResult(success: boolean, stdout = "", stderr = ""): import("@symbiosis-lab/moss-api").ExecuteResult {
|
|
232
|
+
return { success, exitCode: success ? 0 : 1, stdout, stderr };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
mockExecuteBinary.mockReset();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("resolves symlink target to relative generation dir path", async () => {
|
|
240
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
241
|
+
execResult(true, "/Users/test/project/.moss/build/generations/gen-abc123def456\n")
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const result = await resolveCurrentGenDir();
|
|
245
|
+
expect(result).toBe(".moss/build/generations/gen-abc123def456");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("calls readlink with .moss/build/current argument", async () => {
|
|
249
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
250
|
+
execResult(true, "/some/path/.moss/build/generations/gen-xyz789\n")
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
await resolveCurrentGenDir();
|
|
254
|
+
|
|
255
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
256
|
+
expect.objectContaining({
|
|
257
|
+
binaryPath: "readlink",
|
|
258
|
+
args: [".moss/build/current"],
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("throws when .moss/build/current symlink is missing (readlink fails)", async () => {
|
|
264
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
265
|
+
execResult(false, "", "readlink: .moss/build/current: No such file or directory")
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await expect(resolveCurrentGenDir()).rejects.toThrow(
|
|
269
|
+
"Cannot locate current generation"
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("throws when readlink returns empty stdout", async () => {
|
|
274
|
+
mockExecuteBinary.mockResolvedValueOnce(
|
|
275
|
+
execResult(true, "")
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await expect(resolveCurrentGenDir()).rejects.toThrow(
|
|
279
|
+
"Cannot locate current generation"
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ==========================================================================
|
|
285
|
+
// deployViaGitPush (single-repo with tree extraction)
|
|
286
|
+
// ==========================================================================
|
|
287
|
+
describe("deployViaGitPush", () => {
|
|
288
|
+
const mockExecuteBinary = vi.mocked(executeBinary);
|
|
289
|
+
const mockListSiteFilesWithSizes = vi.mocked(listSiteFilesWithSizes);
|
|
290
|
+
const mockShowToast = vi.mocked(showToast);
|
|
291
|
+
|
|
292
|
+
/** Helper to create an ExecuteResult */
|
|
293
|
+
function gitResult(success: boolean, stdout = "", stderr = ""): ExecuteResult {
|
|
294
|
+
return { success, exitCode: success ? 0 : 1, stdout, stderr };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Token-free marker URL used for origin identity checks */
|
|
298
|
+
const REPO_MARKER = `https://github.com/${OWNER}/${REPO}.git`;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Set up mock sequence for a full successful deploy (existing repo, matching origin).
|
|
302
|
+
* Returns the mock for further customization.
|
|
303
|
+
*
|
|
304
|
+
* New sequence (incremental deploy, generations model, #816):
|
|
305
|
+
* rev-parse --git-dir, remote get-url origin, fetch --depth=1,
|
|
306
|
+
* .gitignore, rm -f index.lock, rm -f shallow.lock,
|
|
307
|
+
* readlink .moss/build/current (resolve generation dir),
|
|
308
|
+
* git add .moss/build/generations/<id>/,
|
|
309
|
+
* rm -f .git/index.lock (iCloud race),
|
|
310
|
+
* write-tree --prefix=.moss/build/generations/<id>/, .nojekyll injection,
|
|
311
|
+
* rev-parse gh-pages tip, commit-tree, push gh-pages,
|
|
312
|
+
* then deferred: find(large files), add --all, diff, commit,
|
|
313
|
+
* rev-parse --short HEAD, push main
|
|
314
|
+
*
|
|
315
|
+
* listSiteFilesWithSizes is mocked separately (returns [] by default).
|
|
316
|
+
*/
|
|
317
|
+
function setupFullDeployMocks(commitSha = "abc1234") {
|
|
318
|
+
mockExecuteBinary
|
|
319
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir (repo exists)
|
|
320
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
321
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
322
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore (sh -c)
|
|
323
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
324
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
325
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
326
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
327
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
328
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111bbb222\n")) // git write-tree --prefix=.moss/build/generations/<id>/
|
|
329
|
+
// .nojekyll injection (always happens):
|
|
330
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyll000\n")) // hash-object -w --stdin (.nojekyll)
|
|
331
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob siteblob\tindex.html\n")) // ls-tree
|
|
332
|
+
.mockResolvedValueOnce(gitResult(true, "modifiedTree\n")) // mktree (with .nojekyll)
|
|
333
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
334
|
+
.mockResolvedValueOnce(gitResult(true, "ccc333ddd444\n")) // git commit-tree (orphan, no parent)
|
|
335
|
+
.mockResolvedValueOnce(gitResult(true)) // git push --force gh-pages only
|
|
336
|
+
// Deferred source backup:
|
|
337
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none found)
|
|
338
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
339
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff --cached --quiet (changes exist)
|
|
340
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy site\n")) // git commit
|
|
341
|
+
.mockResolvedValueOnce(gitResult(true, commitSha + "\n")) // git rev-parse --short HEAD
|
|
342
|
+
.mockResolvedValueOnce(gitResult(true)); // git push main
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
beforeEach(() => {
|
|
346
|
+
mockExecuteBinary.mockReset();
|
|
347
|
+
mockListSiteFilesWithSizes.mockReset();
|
|
348
|
+
mockListSiteFilesWithSizes.mockResolvedValue([]);
|
|
349
|
+
mockShowToast.mockReset();
|
|
350
|
+
mockShowToast.mockResolvedValue(undefined);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("initializes git repo on first deploy", async () => {
|
|
354
|
+
mockExecuteBinary
|
|
355
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse fails (no .git)
|
|
356
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
357
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
358
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
359
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
360
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
361
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
362
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
363
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
364
|
+
// Site-only staging:
|
|
365
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
366
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
367
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
368
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
369
|
+
// .nojekyll injection:
|
|
370
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
371
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
372
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
373
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse gh-pages (no prev)
|
|
374
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree (orphan)
|
|
375
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages only
|
|
376
|
+
// Deferred source backup:
|
|
377
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none)
|
|
378
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
379
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff --cached --quiet (changes exist)
|
|
380
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy site\n")) // git commit
|
|
381
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
382
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
383
|
+
|
|
384
|
+
const onProgress = vi.fn();
|
|
385
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
386
|
+
|
|
387
|
+
// Verify git init was called
|
|
388
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
389
|
+
expect.objectContaining({ binaryPath: "git", args: ["init"] })
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Verify git config
|
|
393
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
394
|
+
expect.objectContaining({ binaryPath: "git", args: ["config", "user.email", "moss@symbiosis-lab.com"] })
|
|
395
|
+
);
|
|
396
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
397
|
+
expect.objectContaining({ binaryPath: "git", args: ["config", "user.name", "moss"] })
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Verify remote add origin with token-free marker URL
|
|
401
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
402
|
+
expect.objectContaining({
|
|
403
|
+
binaryPath: "git",
|
|
404
|
+
args: ["remote", "add", "origin", REPO_MARKER],
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("reuses existing git repo when origin matches", async () => {
|
|
410
|
+
setupFullDeployMocks();
|
|
411
|
+
|
|
412
|
+
const onProgress = vi.fn();
|
|
413
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
414
|
+
|
|
415
|
+
// Verify remote get-url origin was checked
|
|
416
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
417
|
+
expect.objectContaining({
|
|
418
|
+
binaryPath: "git",
|
|
419
|
+
args: ["remote", "get-url", "origin"],
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// Verify git init was NOT called
|
|
424
|
+
const initCalls = mockExecuteBinary.mock.calls.filter(
|
|
425
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "init"
|
|
426
|
+
);
|
|
427
|
+
expect(initCalls).toHaveLength(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("strips stale moss-managed .moss/* lines from root .gitignore (migration)", async () => {
|
|
431
|
+
setupFullDeployMocks();
|
|
432
|
+
|
|
433
|
+
const onProgress = vi.fn();
|
|
434
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
435
|
+
|
|
436
|
+
// Gitignore ownership for .moss/ lives in .moss/.gitignore (written by Rust).
|
|
437
|
+
// Here we only strip stale .moss/* rules from the root .gitignore via sed.
|
|
438
|
+
const shCall = mockExecuteBinary.mock.calls.find(
|
|
439
|
+
(call) => call[0].binaryPath === "sh" && (call[0].args[1] as string).includes("sed")
|
|
440
|
+
);
|
|
441
|
+
expect(shCall).toBeDefined();
|
|
442
|
+
const cmd = shCall![0].args[1] as string;
|
|
443
|
+
expect(cmd).toContain("[ -f .gitignore ]");
|
|
444
|
+
expect(cmd).toContain("sed -i");
|
|
445
|
+
expect(cmd).toContain("/^\\.moss/d");
|
|
446
|
+
expect(cmd).toContain("/^!\\.moss/d");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("pushes gh-pages first, then main separately", async () => {
|
|
450
|
+
setupFullDeployMocks();
|
|
451
|
+
|
|
452
|
+
const onProgress = vi.fn();
|
|
453
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
454
|
+
|
|
455
|
+
const pushUrl = `https://x-access-token:${TOKEN}@github.com/${OWNER}/${REPO}.git`;
|
|
456
|
+
|
|
457
|
+
// Verify TWO separate push calls
|
|
458
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
459
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
460
|
+
);
|
|
461
|
+
expect(pushCalls).toHaveLength(2);
|
|
462
|
+
|
|
463
|
+
// First push: gh-pages only (with --force --progress)
|
|
464
|
+
expect(pushCalls[0][0].args).toEqual([
|
|
465
|
+
"push", "--force", "--progress", pushUrl,
|
|
466
|
+
"ccc333ddd444:refs/heads/gh-pages",
|
|
467
|
+
]);
|
|
468
|
+
|
|
469
|
+
// Second push: main only (no --force, no --progress)
|
|
470
|
+
expect(pushCalls[1][0].args).toEqual([
|
|
471
|
+
"push", pushUrl, "HEAD:refs/heads/main",
|
|
472
|
+
]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("extracts current generation tree via write-tree and creates orphan commit for gh-pages", async () => {
|
|
476
|
+
setupFullDeployMocks();
|
|
477
|
+
|
|
478
|
+
const onProgress = vi.fn();
|
|
479
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
480
|
+
|
|
481
|
+
// Verify tree extraction via write-tree --prefix on the resolved generation dir
|
|
482
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
483
|
+
expect.objectContaining({
|
|
484
|
+
binaryPath: "git",
|
|
485
|
+
args: ["write-tree", `--prefix=${GEN_DIR}/`],
|
|
486
|
+
})
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// Verify orphan commit creation with modified tree (includes .nojekyll)
|
|
490
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
491
|
+
expect.objectContaining({
|
|
492
|
+
binaryPath: "git",
|
|
493
|
+
args: ["commit-tree", "modifiedTree", "-m", "Deploy site\n\nGenerated by moss"],
|
|
494
|
+
})
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("still pushes gh-pages for fresh repo with empty site tree", async () => {
|
|
499
|
+
// In the new flow, write-tree always succeeds (even empty tree).
|
|
500
|
+
// gh-pages is always pushed. The deferred source backup may have no changes.
|
|
501
|
+
mockExecuteBinary
|
|
502
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse --git-dir fails (no .git)
|
|
503
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
504
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
505
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
506
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
507
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
508
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
509
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
510
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
511
|
+
// Site-only staging:
|
|
512
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
513
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
514
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
515
|
+
.mockResolvedValueOnce(gitResult(true, "4b825dc\n")) // write-tree --prefix (empty tree)
|
|
516
|
+
// .nojekyll injection:
|
|
517
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
518
|
+
.mockResolvedValueOnce(gitResult(true, "")) // ls-tree (empty tree — no entries)
|
|
519
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree (just .nojekyll)
|
|
520
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse gh-pages (no prev)
|
|
521
|
+
.mockResolvedValueOnce(gitResult(true, "orphan123\n")) // commit-tree
|
|
522
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
523
|
+
// Deferred source backup:
|
|
524
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none)
|
|
525
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
526
|
+
.mockResolvedValueOnce(gitResult(true)); // git diff --cached --quiet (no changes)
|
|
527
|
+
|
|
528
|
+
const onProgress = vi.fn();
|
|
529
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
530
|
+
|
|
531
|
+
expect((result as DeployResult).commitSha).toBe("");
|
|
532
|
+
expect((result as DeployResult).orphanSha).toBe("orphan123");
|
|
533
|
+
// gh-pages push should still happen
|
|
534
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
535
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
536
|
+
);
|
|
537
|
+
expect(pushCalls).toHaveLength(1); // gh-pages only, no main push (no source changes)
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("returns commit SHA via rev-parse after commit", async () => {
|
|
541
|
+
setupFullDeployMocks("abc1234");
|
|
542
|
+
|
|
543
|
+
const onProgress = vi.fn();
|
|
544
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
545
|
+
|
|
546
|
+
expect((result as DeployResult).commitSha).toBe("abc1234");
|
|
547
|
+
|
|
548
|
+
// Verify rev-parse --short HEAD was called (not regex on commit output)
|
|
549
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
550
|
+
expect.objectContaining({
|
|
551
|
+
binaryPath: "git",
|
|
552
|
+
args: ["rev-parse", "--short", "HEAD"],
|
|
553
|
+
})
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("returns empty commitSha when rev-parse fails after commit", async () => {
|
|
558
|
+
mockExecuteBinary
|
|
559
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir (repo exists)
|
|
560
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
561
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
562
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore (sh -c)
|
|
563
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
564
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
565
|
+
// Site-only staging:
|
|
566
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
567
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
568
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
569
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111bbb222\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
570
|
+
// .nojekyll injection:
|
|
571
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
572
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
573
|
+
.mockResolvedValueOnce(gitResult(true, "newTree\n")) // mktree
|
|
574
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
575
|
+
.mockResolvedValueOnce(gitResult(true, "ccc333ddd444\n")) // git commit-tree
|
|
576
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
577
|
+
// Deferred source backup:
|
|
578
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none)
|
|
579
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
580
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff --cached --quiet (changes exist)
|
|
581
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy site\n")) // git commit
|
|
582
|
+
.mockResolvedValueOnce(gitResult(false)); // rev-parse --short HEAD FAILS
|
|
583
|
+
|
|
584
|
+
const onProgress = vi.fn();
|
|
585
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
586
|
+
|
|
587
|
+
expect((result as DeployResult).commitSha).toBe("");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("sanitizes token from error messages on push failure", async () => {
|
|
591
|
+
mockExecuteBinary
|
|
592
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse succeeds
|
|
593
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
594
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
595
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
596
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
597
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
598
|
+
// Site-only staging:
|
|
599
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
600
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
601
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
602
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
603
|
+
// .nojekyll injection:
|
|
604
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
605
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
606
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
607
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
608
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
609
|
+
.mockResolvedValueOnce(gitResult(false, "", `fatal: unable to access 'https://x-access-token:${TOKEN}@github.com/testuser/my-site.git/'`)); // push gh-pages fails
|
|
610
|
+
|
|
611
|
+
const onProgress = vi.fn();
|
|
612
|
+
|
|
613
|
+
await expect(
|
|
614
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
615
|
+
).rejects.toThrow(expect.objectContaining({
|
|
616
|
+
message: expect.not.stringContaining(TOKEN),
|
|
617
|
+
}));
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("reports weighted progress at phase boundaries", async () => {
|
|
621
|
+
setupFullDeployMocks();
|
|
622
|
+
|
|
623
|
+
const onProgress = vi.fn();
|
|
624
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
625
|
+
|
|
626
|
+
expect(onProgress).toHaveBeenCalledWith(0, "Preparing deploy...");
|
|
627
|
+
expect(onProgress).toHaveBeenCalledWith(5, "Staging site files...");
|
|
628
|
+
expect(onProgress).toHaveBeenCalledWith(10, "Preparing gh-pages...");
|
|
629
|
+
expect(onProgress).toHaveBeenCalledWith(25, "Pushing to GitHub...");
|
|
630
|
+
expect(onProgress).toHaveBeenCalledWith(100, "Deployed!");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("uses project root as working directory for all git commands", async () => {
|
|
634
|
+
setupFullDeployMocks();
|
|
635
|
+
|
|
636
|
+
const onProgress = vi.fn();
|
|
637
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
638
|
+
|
|
639
|
+
// All calls should use workingDir "." (project root)
|
|
640
|
+
for (const call of mockExecuteBinary.mock.calls) {
|
|
641
|
+
expect(call[0].workingDir).toBe(".");
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("throws on write-tree failure", async () => {
|
|
646
|
+
mockExecuteBinary
|
|
647
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse succeeds
|
|
648
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
649
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
650
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
651
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
652
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
653
|
+
// Site-only staging:
|
|
654
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
655
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
656
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
657
|
+
.mockResolvedValueOnce(gitResult(false, "", "fatal: not a valid object name")); // write-tree --prefix fails
|
|
658
|
+
|
|
659
|
+
const onProgress = vi.fn();
|
|
660
|
+
|
|
661
|
+
await expect(
|
|
662
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
663
|
+
).rejects.toThrow("Failed to write site tree");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("throws on commit-tree failure", async () => {
|
|
667
|
+
mockExecuteBinary
|
|
668
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse succeeds
|
|
669
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
670
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
671
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
672
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
673
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
674
|
+
// Site-only staging:
|
|
675
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
676
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
677
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
678
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
679
|
+
// .nojekyll injection:
|
|
680
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
681
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
682
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
683
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
684
|
+
.mockResolvedValueOnce(gitResult(false, "", "fatal: not a tree object")); // commit-tree fails
|
|
685
|
+
|
|
686
|
+
const onProgress = vi.fn();
|
|
687
|
+
|
|
688
|
+
await expect(
|
|
689
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
690
|
+
).rejects.toThrow("Failed to create gh-pages commit");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ========================================================================
|
|
694
|
+
// Idempotency: repo change detection (14a)
|
|
695
|
+
// ========================================================================
|
|
696
|
+
it("reinitializes git when target repo changes", async () => {
|
|
697
|
+
const oldMarker = "https://github.com/oldowner/old-repo.git";
|
|
698
|
+
mockExecuteBinary
|
|
699
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir (repo exists)
|
|
700
|
+
.mockResolvedValueOnce(gitResult(true, oldMarker + "\n")) // remote get-url origin (DIFFERENT repo)
|
|
701
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -rf .git
|
|
702
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
703
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
704
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
705
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
706
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
707
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
708
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
709
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
710
|
+
// Site-only staging:
|
|
711
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
712
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
713
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
714
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
715
|
+
// .nojekyll injection:
|
|
716
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
717
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
718
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
719
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
720
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
721
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
722
|
+
// Deferred source backup:
|
|
723
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none)
|
|
724
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
725
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff --cached --quiet (changes exist)
|
|
726
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy site\n")) // git commit
|
|
727
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
728
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
729
|
+
|
|
730
|
+
const onProgress = vi.fn();
|
|
731
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
732
|
+
|
|
733
|
+
expect((result as DeployResult).commitSha).toBe("abc1234");
|
|
734
|
+
|
|
735
|
+
// Verify rm -rf .git was called
|
|
736
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
737
|
+
expect.objectContaining({
|
|
738
|
+
binaryPath: "rm",
|
|
739
|
+
args: ["-rf", ".git"],
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
// Verify reinit happened
|
|
744
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
745
|
+
expect.objectContaining({ binaryPath: "git", args: ["init"] })
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
// Verify remote add origin with new marker URL
|
|
749
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
750
|
+
expect.objectContaining({
|
|
751
|
+
binaryPath: "git",
|
|
752
|
+
args: ["remote", "add", "origin", REPO_MARKER],
|
|
753
|
+
})
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("reinitializes git when origin is missing", async () => {
|
|
758
|
+
mockExecuteBinary
|
|
759
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir (repo exists)
|
|
760
|
+
.mockResolvedValueOnce(gitResult(false)) // remote get-url origin FAILS (no remote)
|
|
761
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -rf .git
|
|
762
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
763
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
764
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
765
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
766
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
767
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
768
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
769
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
770
|
+
// Site-only staging:
|
|
771
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
772
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
773
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
774
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
775
|
+
// .nojekyll injection:
|
|
776
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
777
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
778
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
779
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
780
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
781
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
782
|
+
// Deferred source backup:
|
|
783
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none)
|
|
784
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
785
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff --cached --quiet (changes exist)
|
|
786
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy site\n")) // git commit
|
|
787
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
788
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
789
|
+
|
|
790
|
+
const onProgress = vi.fn();
|
|
791
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
792
|
+
|
|
793
|
+
// Verify rm -rf .git was called
|
|
794
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
795
|
+
expect.objectContaining({
|
|
796
|
+
binaryPath: "rm",
|
|
797
|
+
args: ["-rf", ".git"],
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
// Verify reinit happened
|
|
802
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
803
|
+
expect.objectContaining({ binaryPath: "git", args: ["init"] })
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// ========================================================================
|
|
808
|
+
// Idempotency: stale index.lock removal (14b)
|
|
809
|
+
// ========================================================================
|
|
810
|
+
it("removes stale index.lock before git add", async () => {
|
|
811
|
+
setupFullDeployMocks();
|
|
812
|
+
|
|
813
|
+
const onProgress = vi.fn();
|
|
814
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
815
|
+
|
|
816
|
+
// Verify rm -f .git/index.lock was called
|
|
817
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
818
|
+
expect.objectContaining({
|
|
819
|
+
binaryPath: "rm",
|
|
820
|
+
args: ["-f", ".git/index.lock"],
|
|
821
|
+
})
|
|
822
|
+
);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ========================================================================
|
|
826
|
+
// Idempotency: push even when no staged changes (14c — resume after crash)
|
|
827
|
+
// ========================================================================
|
|
828
|
+
it("pushes gh-pages even when no source changes exist (resume after crash)", async () => {
|
|
829
|
+
const pushUrl = `https://x-access-token:${TOKEN}@github.com/${OWNER}/${REPO}.git`;
|
|
830
|
+
mockExecuteBinary
|
|
831
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir (repo exists)
|
|
832
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
833
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
834
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
835
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
836
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
837
|
+
// Site-only staging:
|
|
838
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
839
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
840
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
841
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
842
|
+
// .nojekyll injection:
|
|
843
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
844
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
845
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
846
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
847
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
848
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
849
|
+
// Deferred source backup:
|
|
850
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none)
|
|
851
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
852
|
+
.mockResolvedValueOnce(gitResult(true)) // git diff --cached --quiet SUCCESS (no changes)
|
|
853
|
+
.mockResolvedValueOnce(gitResult(true, "0\n")); // rev-list --count (no unpushed commits)
|
|
854
|
+
|
|
855
|
+
const onProgress = vi.fn();
|
|
856
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
857
|
+
|
|
858
|
+
// Returns empty commitSha (no source changes) but orphanSha populated
|
|
859
|
+
expect((result as DeployResult).commitSha).toBe("");
|
|
860
|
+
expect((result as DeployResult).orphanSha).toBe("bbb222");
|
|
861
|
+
|
|
862
|
+
// Verify commit was NOT called (no source changes to commit)
|
|
863
|
+
const commitCalls = mockExecuteBinary.mock.calls.filter(
|
|
864
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "commit"
|
|
865
|
+
);
|
|
866
|
+
expect(commitCalls).toHaveLength(0);
|
|
867
|
+
|
|
868
|
+
// Verify gh-pages push happened (first push)
|
|
869
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
870
|
+
expect.objectContaining({
|
|
871
|
+
binaryPath: "git",
|
|
872
|
+
args: ["push", "--force", "--progress", pushUrl, "bbb222:refs/heads/gh-pages"],
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
// Only ONE push call (gh-pages only, no main push since no source changes)
|
|
877
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
878
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
879
|
+
);
|
|
880
|
+
expect(pushCalls).toHaveLength(1);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// ========================================================================
|
|
884
|
+
// 100MB site file limit — ABORT if any site file exceeds 100MB
|
|
885
|
+
// ========================================================================
|
|
886
|
+
describe("100MB site file limit", () => {
|
|
887
|
+
it("throws when a site file exceeds 100MB", async () => {
|
|
888
|
+
const HUNDRED_MB = 100 * 1024 * 1024;
|
|
889
|
+
mockListSiteFilesWithSizes.mockResolvedValue([
|
|
890
|
+
{ path: "index.html", size: 1024 },
|
|
891
|
+
{ path: "assets/huge-video.mp4", size: HUNDRED_MB + 1 },
|
|
892
|
+
]);
|
|
893
|
+
|
|
894
|
+
const onProgress = vi.fn();
|
|
895
|
+
|
|
896
|
+
await expect(
|
|
897
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
898
|
+
).rejects.toThrow("assets/huge-video.mp4");
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it("includes file size in the error message", async () => {
|
|
902
|
+
const HUNDRED_MB = 100 * 1024 * 1024;
|
|
903
|
+
mockListSiteFilesWithSizes.mockResolvedValue([
|
|
904
|
+
{ path: "assets/huge-video.mp4", size: HUNDRED_MB + 500 },
|
|
905
|
+
]);
|
|
906
|
+
|
|
907
|
+
const onProgress = vi.fn();
|
|
908
|
+
|
|
909
|
+
await expect(
|
|
910
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
911
|
+
).rejects.toThrow("100");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("lists multiple oversized files in error", async () => {
|
|
915
|
+
const HUNDRED_MB = 100 * 1024 * 1024;
|
|
916
|
+
mockListSiteFilesWithSizes.mockResolvedValue([
|
|
917
|
+
{ path: "video1.mp4", size: HUNDRED_MB + 1 },
|
|
918
|
+
{ path: "video2.mp4", size: HUNDRED_MB * 2 },
|
|
919
|
+
{ path: "small.html", size: 500 },
|
|
920
|
+
]);
|
|
921
|
+
|
|
922
|
+
const onProgress = vi.fn();
|
|
923
|
+
|
|
924
|
+
try {
|
|
925
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
926
|
+
expect.fail("Expected to throw");
|
|
927
|
+
} catch (err: unknown) {
|
|
928
|
+
const error = err as Error;
|
|
929
|
+
expect(error.message).toContain("video1.mp4");
|
|
930
|
+
expect(error.message).toContain("video2.mp4");
|
|
931
|
+
expect(error.message).not.toContain("small.html");
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it("does not abort when all site files are under 100MB", async () => {
|
|
936
|
+
mockListSiteFilesWithSizes.mockResolvedValue([
|
|
937
|
+
{ path: "index.html", size: 1024 },
|
|
938
|
+
{ path: "style.css", size: 2048 },
|
|
939
|
+
]);
|
|
940
|
+
|
|
941
|
+
// Set up full deploy mocks (file size check passes, then normal flow)
|
|
942
|
+
setupFullDeployMocks();
|
|
943
|
+
|
|
944
|
+
const onProgress = vi.fn();
|
|
945
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
946
|
+
|
|
947
|
+
expect((result as DeployResult).commitSha).toBe("abc1234");
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("aborts before any git operations when site files exceed limit", async () => {
|
|
951
|
+
const HUNDRED_MB = 100 * 1024 * 1024;
|
|
952
|
+
mockListSiteFilesWithSizes.mockResolvedValue([
|
|
953
|
+
{ path: "huge.bin", size: HUNDRED_MB + 1 },
|
|
954
|
+
]);
|
|
955
|
+
|
|
956
|
+
const onProgress = vi.fn();
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
960
|
+
} catch {
|
|
961
|
+
// Expected
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// No git commands should have been called
|
|
965
|
+
expect(mockExecuteBinary).not.toHaveBeenCalled();
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// ========================================================================
|
|
970
|
+
// 100MB source file limit — SKIP >100MB files with warning
|
|
971
|
+
// ========================================================================
|
|
972
|
+
describe("100MB source file limit", () => {
|
|
973
|
+
it("appends large source files to .gitignore", async () => {
|
|
974
|
+
// Set up mocks with new sequence: site-only staging → gh-pages push → deferred source backup
|
|
975
|
+
mockExecuteBinary
|
|
976
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
977
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
978
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
979
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore (sh -c)
|
|
980
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
981
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
982
|
+
// Site-only staging:
|
|
983
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
984
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
985
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
986
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
987
|
+
// .nojekyll injection:
|
|
988
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
989
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
990
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
991
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
992
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
993
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
994
|
+
// Deferred source backup — find returns large files:
|
|
995
|
+
.mockResolvedValueOnce(gitResult(true, "big-model.bin\ndata/huge.csv\n")) // find large files
|
|
996
|
+
.mockResolvedValueOnce(gitResult(true)) // append .gitignore (sh -c)
|
|
997
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
998
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
999
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy\n")) // commit
|
|
1000
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1001
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1002
|
+
|
|
1003
|
+
const onProgress = vi.fn();
|
|
1004
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1005
|
+
|
|
1006
|
+
// Verify a second sh -c call was made to append to .gitignore
|
|
1007
|
+
const shCalls = mockExecuteBinary.mock.calls.filter(
|
|
1008
|
+
(call) => call[0].binaryPath === "sh"
|
|
1009
|
+
);
|
|
1010
|
+
expect(shCalls.length).toBeGreaterThanOrEqual(2);
|
|
1011
|
+
// The append call should contain the large file names
|
|
1012
|
+
const appendCall = shCalls[1];
|
|
1013
|
+
expect(appendCall[0].args[1]).toContain("big-model.bin");
|
|
1014
|
+
expect(appendCall[0].args[1]).toContain("data/huge.csv");
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("shows warning toast for skipped source files", async () => {
|
|
1018
|
+
mockExecuteBinary
|
|
1019
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1020
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
1021
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1022
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore (sh -c)
|
|
1023
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1024
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1025
|
+
// Site-only staging:
|
|
1026
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1027
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1028
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1029
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1030
|
+
// .nojekyll injection:
|
|
1031
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1032
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1033
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1034
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1035
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
1036
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1037
|
+
// Deferred source backup — find returns large files:
|
|
1038
|
+
.mockResolvedValueOnce(gitResult(true, "large-file.bin\n")) // find large files
|
|
1039
|
+
.mockResolvedValueOnce(gitResult(true)) // append .gitignore (sh -c)
|
|
1040
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1041
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1042
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy\n")) // commit
|
|
1043
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1044
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1045
|
+
|
|
1046
|
+
const onProgress = vi.fn();
|
|
1047
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1048
|
+
|
|
1049
|
+
// showToast should have been called with a warning
|
|
1050
|
+
expect(mockShowToast).toHaveBeenCalledWith(
|
|
1051
|
+
expect.objectContaining({
|
|
1052
|
+
variant: "warning",
|
|
1053
|
+
message: expect.stringContaining("large-file.bin"),
|
|
1054
|
+
})
|
|
1055
|
+
);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("does not modify .gitignore when no large source files found", async () => {
|
|
1059
|
+
setupFullDeployMocks();
|
|
1060
|
+
|
|
1061
|
+
const onProgress = vi.fn();
|
|
1062
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1063
|
+
|
|
1064
|
+
// Should only have one sh -c call (base .gitignore write)
|
|
1065
|
+
const shCalls = mockExecuteBinary.mock.calls.filter(
|
|
1066
|
+
(call) => call[0].binaryPath === "sh"
|
|
1067
|
+
);
|
|
1068
|
+
expect(shCalls).toHaveLength(1);
|
|
1069
|
+
|
|
1070
|
+
// showToast should NOT have been called for large files
|
|
1071
|
+
expect(mockShowToast).not.toHaveBeenCalled();
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("uses find command to detect large source files", async () => {
|
|
1075
|
+
setupFullDeployMocks();
|
|
1076
|
+
|
|
1077
|
+
const onProgress = vi.fn();
|
|
1078
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1079
|
+
|
|
1080
|
+
// Verify find command was called with correct args
|
|
1081
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1082
|
+
expect.objectContaining({
|
|
1083
|
+
binaryPath: "find",
|
|
1084
|
+
args: expect.arrayContaining(["-size", "+100M"]),
|
|
1085
|
+
})
|
|
1086
|
+
);
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// ========================================================================
|
|
1091
|
+
// Streaming git operations — verbose/progress flags + onStderr
|
|
1092
|
+
// ========================================================================
|
|
1093
|
+
describe("streaming git operations", () => {
|
|
1094
|
+
it("calls git add --all without -v flag for source backup", async () => {
|
|
1095
|
+
setupFullDeployMocks();
|
|
1096
|
+
|
|
1097
|
+
const onProgress = vi.fn();
|
|
1098
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1099
|
+
|
|
1100
|
+
// The deferred source backup uses git add --all (no -v)
|
|
1101
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1102
|
+
expect.objectContaining({
|
|
1103
|
+
binaryPath: "git",
|
|
1104
|
+
args: ["add", "--all"],
|
|
1105
|
+
})
|
|
1106
|
+
);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("calls git push with --force --progress for gh-pages only", async () => {
|
|
1110
|
+
setupFullDeployMocks();
|
|
1111
|
+
|
|
1112
|
+
const onProgress = vi.fn();
|
|
1113
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1114
|
+
|
|
1115
|
+
const pushUrl = `https://x-access-token:${TOKEN}@github.com/${OWNER}/${REPO}.git`;
|
|
1116
|
+
// First push: gh-pages only
|
|
1117
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1118
|
+
expect.objectContaining({
|
|
1119
|
+
binaryPath: "git",
|
|
1120
|
+
args: ["push", "--force", "--progress", pushUrl, "ccc333ddd444:refs/heads/gh-pages"],
|
|
1121
|
+
})
|
|
1122
|
+
);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("passes onStderr callback to gh-pages push", async () => {
|
|
1126
|
+
setupFullDeployMocks();
|
|
1127
|
+
|
|
1128
|
+
const onProgress = vi.fn();
|
|
1129
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1130
|
+
|
|
1131
|
+
// Find the gh-pages push call
|
|
1132
|
+
const pushCall = mockExecuteBinary.mock.calls.find(
|
|
1133
|
+
(call) => call[0].binaryPath === "git" &&
|
|
1134
|
+
call[0].args[0] === "push" &&
|
|
1135
|
+
call[0].args.includes("ccc333ddd444:refs/heads/gh-pages")
|
|
1136
|
+
);
|
|
1137
|
+
expect(pushCall).toBeDefined();
|
|
1138
|
+
expect(pushCall![0].onStderr).toBeTypeOf("function");
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("maps push stderr progress to 25-95% range", async () => {
|
|
1142
|
+
setupFullDeployMocks();
|
|
1143
|
+
|
|
1144
|
+
const onProgress = vi.fn();
|
|
1145
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1146
|
+
|
|
1147
|
+
// Find the gh-pages push call and invoke its onStderr
|
|
1148
|
+
const pushCall = mockExecuteBinary.mock.calls.find(
|
|
1149
|
+
(call) => call[0].binaryPath === "git" &&
|
|
1150
|
+
call[0].args[0] === "push" &&
|
|
1151
|
+
call[0].args.includes("ccc333ddd444:refs/heads/gh-pages")
|
|
1152
|
+
);
|
|
1153
|
+
const pushOnStderr = pushCall![0].onStderr;
|
|
1154
|
+
|
|
1155
|
+
// Simulate git push progress output at 50%
|
|
1156
|
+
pushOnStderr("Writing objects: 50% (5/10), 1.00 MiB | 500.00 KiB/s");
|
|
1157
|
+
|
|
1158
|
+
// Should map 50% to range 25-95%, which is 25 + 35 = 60%
|
|
1159
|
+
expect(onProgress).toHaveBeenCalledWith(60, expect.stringContaining("Writing objects"));
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// ========================================================================
|
|
1164
|
+
// CNAME injection for custom domains
|
|
1165
|
+
// ========================================================================
|
|
1166
|
+
describe("CNAME injection for custom domains", () => {
|
|
1167
|
+
it("injects CNAME file into gh-pages tree when domain is provided", async () => {
|
|
1168
|
+
const SITE_TREE = "aaa111bbb222";
|
|
1169
|
+
const NOJEKYLL_BLOB = "nnn000jjj111";
|
|
1170
|
+
const CNAME_BLOB = "fff000ccc111";
|
|
1171
|
+
const NEW_TREE = "eee222ddd333";
|
|
1172
|
+
const ORPHAN_SHA = "ggg444hhh555";
|
|
1173
|
+
|
|
1174
|
+
mockExecuteBinary
|
|
1175
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1176
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1177
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1178
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1179
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1180
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1181
|
+
// Site-only staging:
|
|
1182
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1183
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1184
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1185
|
+
.mockResolvedValueOnce(gitResult(true, SITE_TREE + "\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1186
|
+
// .nojekyll + CNAME injection steps:
|
|
1187
|
+
.mockResolvedValueOnce(gitResult(true, NOJEKYLL_BLOB + "\n")) // hash-object -w --stdin (.nojekyll)
|
|
1188
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc123\tindex.html\n")) // git ls-tree
|
|
1189
|
+
.mockResolvedValueOnce(gitResult(true, CNAME_BLOB + "\n")) // hash-object -w --stdin (CNAME)
|
|
1190
|
+
.mockResolvedValueOnce(gitResult(true, NEW_TREE + "\n")) // git mktree (tree with .nojekyll + CNAME)
|
|
1191
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1192
|
+
.mockResolvedValueOnce(gitResult(true, ORPHAN_SHA + "\n")) // git commit-tree (uses NEW tree)
|
|
1193
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1194
|
+
// Deferred source backup:
|
|
1195
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1196
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1197
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1198
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy\n")) // commit
|
|
1199
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1200
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1201
|
+
|
|
1202
|
+
const onProgress = vi.fn();
|
|
1203
|
+
await deployViaGitPush({
|
|
1204
|
+
owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git",
|
|
1205
|
+
domain: "example.com",
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
// Verify CNAME blob was created with domain content
|
|
1209
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1210
|
+
expect.objectContaining({
|
|
1211
|
+
binaryPath: "git",
|
|
1212
|
+
args: ["hash-object", "-w", "--stdin"],
|
|
1213
|
+
stdin: "example.com\n",
|
|
1214
|
+
})
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
// Verify ls-tree was called on the original site tree
|
|
1218
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1219
|
+
expect.objectContaining({
|
|
1220
|
+
binaryPath: "git",
|
|
1221
|
+
args: ["ls-tree", SITE_TREE],
|
|
1222
|
+
})
|
|
1223
|
+
);
|
|
1224
|
+
|
|
1225
|
+
// Verify mktree was called with entries including both .nojekyll and CNAME
|
|
1226
|
+
const mktreeCall = mockExecuteBinary.mock.calls.find(
|
|
1227
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "mktree"
|
|
1228
|
+
);
|
|
1229
|
+
expect(mktreeCall).toBeDefined();
|
|
1230
|
+
expect(mktreeCall![0].stdin).toContain("CNAME");
|
|
1231
|
+
expect(mktreeCall![0].stdin).toContain(CNAME_BLOB);
|
|
1232
|
+
expect(mktreeCall![0].stdin).toContain(".nojekyll");
|
|
1233
|
+
expect(mktreeCall![0].stdin).toContain(NOJEKYLL_BLOB);
|
|
1234
|
+
|
|
1235
|
+
// Verify commit-tree used the NEW tree (with CNAME + .nojekyll), not the original
|
|
1236
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1237
|
+
expect.objectContaining({
|
|
1238
|
+
binaryPath: "git",
|
|
1239
|
+
args: ["commit-tree", NEW_TREE, "-m", "Deploy site\n\nGenerated by moss"],
|
|
1240
|
+
})
|
|
1241
|
+
);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it("falls back to original tree when hash-object fails for CNAME", async () => {
|
|
1245
|
+
const SITE_TREE = "aaa111bbb222";
|
|
1246
|
+
const NOJEKYLL_BLOB = "nnn000jjj111";
|
|
1247
|
+
|
|
1248
|
+
mockExecuteBinary
|
|
1249
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1250
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1251
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1252
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1253
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1254
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1255
|
+
// Site-only staging:
|
|
1256
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1257
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1258
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1259
|
+
.mockResolvedValueOnce(gitResult(true, SITE_TREE + "\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1260
|
+
// .nojekyll injection:
|
|
1261
|
+
.mockResolvedValueOnce(gitResult(true, NOJEKYLL_BLOB + "\n")) // hash-object -w --stdin (empty .nojekyll)
|
|
1262
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc123\tindex.html\n")) // ls-tree
|
|
1263
|
+
// CNAME injection: hash-object FAILS
|
|
1264
|
+
.mockResolvedValueOnce(gitResult(false)) // hash-object for CNAME fails
|
|
1265
|
+
// mktree still happens with just .nojekyll
|
|
1266
|
+
.mockResolvedValueOnce(gitResult(true, "newTree123\n")) // mktree (with .nojekyll only)
|
|
1267
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1268
|
+
.mockResolvedValueOnce(gitResult(true, "ccc333ddd444\n")) // commit-tree
|
|
1269
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1270
|
+
// Deferred source backup:
|
|
1271
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1272
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1273
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1274
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy\n")) // commit
|
|
1275
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1276
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1277
|
+
|
|
1278
|
+
const onProgress = vi.fn();
|
|
1279
|
+
await deployViaGitPush({
|
|
1280
|
+
owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git",
|
|
1281
|
+
domain: "example.com",
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// mktree should have been called with .nojekyll but not CNAME
|
|
1285
|
+
const mktreeCall = mockExecuteBinary.mock.calls.find(
|
|
1286
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "mktree"
|
|
1287
|
+
);
|
|
1288
|
+
expect(mktreeCall).toBeDefined();
|
|
1289
|
+
expect(mktreeCall![0].stdin).toContain(".nojekyll");
|
|
1290
|
+
expect(mktreeCall![0].stdin).not.toContain("CNAME");
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// ========================================================================
|
|
1295
|
+
// .nojekyll injection (Bug 1)
|
|
1296
|
+
// ========================================================================
|
|
1297
|
+
describe(".nojekyll injection", () => {
|
|
1298
|
+
it("always injects .nojekyll into gh-pages tree even without domain", async () => {
|
|
1299
|
+
const SITE_TREE = "aaa111bbb222";
|
|
1300
|
+
const NOJEKYLL_BLOB = "nnn000jjj111";
|
|
1301
|
+
const NEW_TREE = "eee222ddd333";
|
|
1302
|
+
const ORPHAN_SHA = "ggg444hhh555";
|
|
1303
|
+
|
|
1304
|
+
mockExecuteBinary
|
|
1305
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1306
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1307
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1308
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1309
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1310
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1311
|
+
// Site-only staging:
|
|
1312
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1313
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1314
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1315
|
+
.mockResolvedValueOnce(gitResult(true, SITE_TREE + "\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1316
|
+
// .nojekyll injection:
|
|
1317
|
+
.mockResolvedValueOnce(gitResult(true, NOJEKYLL_BLOB + "\n")) // hash-object -w --stdin (empty .nojekyll)
|
|
1318
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc123\tindex.html\n")) // ls-tree
|
|
1319
|
+
.mockResolvedValueOnce(gitResult(true, NEW_TREE + "\n")) // mktree (with .nojekyll)
|
|
1320
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1321
|
+
.mockResolvedValueOnce(gitResult(true, ORPHAN_SHA + "\n")) // commit-tree
|
|
1322
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1323
|
+
// Deferred source backup:
|
|
1324
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1325
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1326
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1327
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy\n")) // commit
|
|
1328
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1329
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1330
|
+
|
|
1331
|
+
const onProgress = vi.fn();
|
|
1332
|
+
await deployViaGitPush({
|
|
1333
|
+
owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git",
|
|
1334
|
+
// no domain — .nojekyll should still be injected
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// Verify .nojekyll blob was created with empty stdin
|
|
1338
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1339
|
+
expect.objectContaining({
|
|
1340
|
+
binaryPath: "git",
|
|
1341
|
+
args: ["hash-object", "-w", "--stdin"],
|
|
1342
|
+
stdin: "",
|
|
1343
|
+
})
|
|
1344
|
+
);
|
|
1345
|
+
|
|
1346
|
+
// Verify ls-tree was called on the original site tree
|
|
1347
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1348
|
+
expect.objectContaining({
|
|
1349
|
+
binaryPath: "git",
|
|
1350
|
+
args: ["ls-tree", SITE_TREE],
|
|
1351
|
+
})
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
// Verify mktree was called with .nojekyll entry
|
|
1355
|
+
const mktreeCall = mockExecuteBinary.mock.calls.find(
|
|
1356
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "mktree"
|
|
1357
|
+
);
|
|
1358
|
+
expect(mktreeCall).toBeDefined();
|
|
1359
|
+
expect(mktreeCall![0].stdin).toContain(".nojekyll");
|
|
1360
|
+
expect(mktreeCall![0].stdin).toContain(NOJEKYLL_BLOB);
|
|
1361
|
+
|
|
1362
|
+
// Verify commit-tree used the NEW tree (with .nojekyll), not the original
|
|
1363
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1364
|
+
expect.objectContaining({
|
|
1365
|
+
binaryPath: "git",
|
|
1366
|
+
args: ["commit-tree", NEW_TREE, "-m", "Deploy site\n\nGenerated by moss"],
|
|
1367
|
+
})
|
|
1368
|
+
);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it("injects both .nojekyll and CNAME when domain is provided", async () => {
|
|
1372
|
+
const SITE_TREE = "aaa111bbb222";
|
|
1373
|
+
const NOJEKYLL_BLOB = "nnn000jjj111";
|
|
1374
|
+
const CNAME_BLOB = "fff000ccc111";
|
|
1375
|
+
const NEW_TREE = "eee222ddd333";
|
|
1376
|
+
const ORPHAN_SHA = "ggg444hhh555";
|
|
1377
|
+
|
|
1378
|
+
mockExecuteBinary
|
|
1379
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1380
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1381
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1382
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1383
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1384
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1385
|
+
// Site-only staging:
|
|
1386
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1387
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1388
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1389
|
+
.mockResolvedValueOnce(gitResult(true, SITE_TREE + "\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1390
|
+
// .nojekyll injection:
|
|
1391
|
+
.mockResolvedValueOnce(gitResult(true, NOJEKYLL_BLOB + "\n")) // hash-object -w --stdin (.nojekyll)
|
|
1392
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc123\tindex.html\n")) // ls-tree
|
|
1393
|
+
// CNAME injection:
|
|
1394
|
+
.mockResolvedValueOnce(gitResult(true, CNAME_BLOB + "\n")) // hash-object -w --stdin (CNAME)
|
|
1395
|
+
// mktree with both entries:
|
|
1396
|
+
.mockResolvedValueOnce(gitResult(true, NEW_TREE + "\n")) // mktree
|
|
1397
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1398
|
+
.mockResolvedValueOnce(gitResult(true, ORPHAN_SHA + "\n")) // commit-tree
|
|
1399
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1400
|
+
// Deferred source backup:
|
|
1401
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1402
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1403
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1404
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc1234] Deploy\n")) // commit
|
|
1405
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1406
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1407
|
+
|
|
1408
|
+
const onProgress = vi.fn();
|
|
1409
|
+
await deployViaGitPush({
|
|
1410
|
+
owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git",
|
|
1411
|
+
domain: "example.com",
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// Verify mktree contains both .nojekyll and CNAME
|
|
1415
|
+
const mktreeCall = mockExecuteBinary.mock.calls.find(
|
|
1416
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "mktree"
|
|
1417
|
+
);
|
|
1418
|
+
expect(mktreeCall).toBeDefined();
|
|
1419
|
+
expect(mktreeCall![0].stdin).toContain(".nojekyll");
|
|
1420
|
+
expect(mktreeCall![0].stdin).toContain(NOJEKYLL_BLOB);
|
|
1421
|
+
expect(mktreeCall![0].stdin).toContain("CNAME");
|
|
1422
|
+
expect(mktreeCall![0].stdin).toContain(CNAME_BLOB);
|
|
1423
|
+
|
|
1424
|
+
// Single mktree call — combined pass
|
|
1425
|
+
const mktreeCalls = mockExecuteBinary.mock.calls.filter(
|
|
1426
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "mktree"
|
|
1427
|
+
);
|
|
1428
|
+
expect(mktreeCalls).toHaveLength(1);
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
// ========================================================================
|
|
1433
|
+
// DeployResult return type (Bug 2)
|
|
1434
|
+
// ========================================================================
|
|
1435
|
+
describe("DeployResult return type", () => {
|
|
1436
|
+
it("returns DeployResult with commitSha and orphanSha", async () => {
|
|
1437
|
+
setupFullDeployMocks("abc1234");
|
|
1438
|
+
|
|
1439
|
+
const onProgress = vi.fn();
|
|
1440
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1441
|
+
|
|
1442
|
+
// Should return DeployResult object instead of string
|
|
1443
|
+
expect(result).toHaveProperty("commitSha");
|
|
1444
|
+
expect(result).toHaveProperty("orphanSha");
|
|
1445
|
+
expect((result as DeployResult).commitSha).toBe("abc1234");
|
|
1446
|
+
expect((result as DeployResult).orphanSha).toBe("ccc333ddd444");
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it("returns empty commitSha when no source changes to deploy", async () => {
|
|
1450
|
+
mockExecuteBinary
|
|
1451
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1452
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1453
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1454
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1455
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1456
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1457
|
+
// Site-only staging:
|
|
1458
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1459
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1460
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1461
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1462
|
+
// .nojekyll injection:
|
|
1463
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1464
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1465
|
+
.mockResolvedValueOnce(gitResult(true, "newTree\n")) // mktree
|
|
1466
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1467
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // commit-tree
|
|
1468
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1469
|
+
// Deferred source backup:
|
|
1470
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1471
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1472
|
+
.mockResolvedValueOnce(gitResult(true)) // git diff --cached --quiet SUCCESS (no changes)
|
|
1473
|
+
.mockResolvedValueOnce(gitResult(true, "0\n")); // rev-list --count (no unpushed commits)
|
|
1474
|
+
|
|
1475
|
+
const onProgress = vi.fn();
|
|
1476
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1477
|
+
|
|
1478
|
+
expect((result as DeployResult).commitSha).toBe("");
|
|
1479
|
+
expect((result as DeployResult).orphanSha).toBe("bbb222");
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
it("returns orphanSha even for fresh repo (write-tree always succeeds)", async () => {
|
|
1483
|
+
// In the new flow, write-tree produces a valid tree even for fresh repos.
|
|
1484
|
+
// gh-pages is always pushed. Only commitSha may be empty (no source changes).
|
|
1485
|
+
mockExecuteBinary
|
|
1486
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse --git-dir (no .git)
|
|
1487
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
1488
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
1489
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
1490
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
1491
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
1492
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1493
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1494
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1495
|
+
// Site-only staging:
|
|
1496
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1497
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1498
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1499
|
+
.mockResolvedValueOnce(gitResult(true, "4b825dc\n")) // write-tree --prefix (empty tree)
|
|
1500
|
+
// .nojekyll injection:
|
|
1501
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1502
|
+
.mockResolvedValueOnce(gitResult(true, "")) // ls-tree (empty)
|
|
1503
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1504
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse gh-pages (no prev)
|
|
1505
|
+
.mockResolvedValueOnce(gitResult(true, "orphan123\n")) // commit-tree
|
|
1506
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1507
|
+
// Deferred source backup:
|
|
1508
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1509
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1510
|
+
.mockResolvedValueOnce(gitResult(true)) // git diff --cached --quiet (no changes)
|
|
1511
|
+
.mockResolvedValueOnce(gitResult(true, "0\n")); // rev-list --count (no unpushed commits)
|
|
1512
|
+
|
|
1513
|
+
const onProgress = vi.fn();
|
|
1514
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1515
|
+
|
|
1516
|
+
expect((result as DeployResult).commitSha).toBe("");
|
|
1517
|
+
expect((result as DeployResult).orphanSha).toBe("orphan123");
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// ========================================================================
|
|
1522
|
+
// Regression: generation dir path (prevents .moss/build/site/ or .moss/site/ bug)
|
|
1523
|
+
// ========================================================================
|
|
1524
|
+
describe("path regression: current generation dir", () => {
|
|
1525
|
+
it("uses .moss/build/generations/<id>/ for git add (not .moss/build/site/ or .moss/site/)", async () => {
|
|
1526
|
+
setupFullDeployMocks();
|
|
1527
|
+
|
|
1528
|
+
const onProgress = vi.fn();
|
|
1529
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1530
|
+
|
|
1531
|
+
// Verify git add uses the resolved generation dir, not the legacy .moss/build/site/
|
|
1532
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1533
|
+
expect.objectContaining({
|
|
1534
|
+
binaryPath: "git",
|
|
1535
|
+
args: ["add", `${GEN_DIR}/`],
|
|
1536
|
+
})
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
// Verify NO call uses the old .moss/site/ or .moss/build/site/ path
|
|
1540
|
+
const oldPathCalls = mockExecuteBinary.mock.calls.filter(
|
|
1541
|
+
(call) => call[0].binaryPath === "git" &&
|
|
1542
|
+
call[0].args.some(
|
|
1543
|
+
(arg: string) =>
|
|
1544
|
+
arg === ".moss/site/" ||
|
|
1545
|
+
arg === "--prefix=.moss/site/" ||
|
|
1546
|
+
arg === ".moss/build/site/" ||
|
|
1547
|
+
arg === "--prefix=.moss/build/site/"
|
|
1548
|
+
)
|
|
1549
|
+
);
|
|
1550
|
+
expect(oldPathCalls).toHaveLength(0);
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it("uses .moss/build/generations/<id>/ for write-tree --prefix (not .moss/build/site/ or .moss/site/)", async () => {
|
|
1554
|
+
setupFullDeployMocks();
|
|
1555
|
+
|
|
1556
|
+
const onProgress = vi.fn();
|
|
1557
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1558
|
+
|
|
1559
|
+
// Verify write-tree uses the resolved generation dir prefix
|
|
1560
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1561
|
+
expect.objectContaining({
|
|
1562
|
+
binaryPath: "git",
|
|
1563
|
+
args: ["write-tree", `--prefix=${GEN_DIR}/`],
|
|
1564
|
+
})
|
|
1565
|
+
);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it("migration sed strips both .moss and !.moss rules from root .gitignore", async () => {
|
|
1569
|
+
setupFullDeployMocks();
|
|
1570
|
+
|
|
1571
|
+
const onProgress = vi.fn();
|
|
1572
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1573
|
+
|
|
1574
|
+
// Gitignore ownership for .moss/ moved to .moss/.gitignore (Rust-managed).
|
|
1575
|
+
// The plugin only strips stale .moss/* and !.moss/* lines from the root
|
|
1576
|
+
// gitignore for users upgrading from older moss versions.
|
|
1577
|
+
const shCall = mockExecuteBinary.mock.calls.find(
|
|
1578
|
+
(call) => call[0].binaryPath === "sh" && (call[0].args[1] as string).includes("sed")
|
|
1579
|
+
);
|
|
1580
|
+
expect(shCall).toBeDefined();
|
|
1581
|
+
const cmd = shCall![0].args[1] as string;
|
|
1582
|
+
|
|
1583
|
+
// Deletes lines starting with .moss (ignore rules)
|
|
1584
|
+
expect(cmd).toContain("/^\\.moss/d");
|
|
1585
|
+
// Deletes lines starting with !.moss (un-ignore rules)
|
|
1586
|
+
expect(cmd).toContain("/^!\\.moss/d");
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
// ========================================================================
|
|
1591
|
+
// Tree comparison: treeChanged flag in DeployResult
|
|
1592
|
+
// ========================================================================
|
|
1593
|
+
describe("treeChanged flag", () => {
|
|
1594
|
+
it("returns treeChanged=true when tree SHA differs from previous gh-pages", async () => {
|
|
1595
|
+
mockExecuteBinary
|
|
1596
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1597
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1598
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch
|
|
1599
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1600
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1601
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1602
|
+
// Site-only staging:
|
|
1603
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1604
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1605
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1606
|
+
.mockResolvedValueOnce(gitResult(true, "newSiteTree\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1607
|
+
// .nojekyll injection:
|
|
1608
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1609
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1610
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1611
|
+
// Previous gh-pages exists with DIFFERENT tree:
|
|
1612
|
+
.mockResolvedValueOnce(gitResult(true, "prevGhPagesTip\n")) // rev-parse refs/remotes/origin/gh-pages
|
|
1613
|
+
.mockResolvedValueOnce(gitResult(true, "oldTreeSha\n")) // rev-parse prevGhPagesTip^{tree} (different from modTree)
|
|
1614
|
+
.mockResolvedValueOnce(gitResult(true, "newOrphanSha\n")) // commit-tree -p prevGhPagesTip
|
|
1615
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1616
|
+
// Deferred source backup:
|
|
1617
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files
|
|
1618
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1619
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1620
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc] Deploy\n")) // commit
|
|
1621
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1622
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1623
|
+
|
|
1624
|
+
const onProgress = vi.fn();
|
|
1625
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1626
|
+
|
|
1627
|
+
expect(result.treeChanged).toBe(true);
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it("returns treeChanged=false when tree SHA matches previous gh-pages", async () => {
|
|
1631
|
+
// The key: mktree returns "modTree" and the previous gh-pages tree is also "modTree"
|
|
1632
|
+
mockExecuteBinary
|
|
1633
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1634
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1635
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch
|
|
1636
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1637
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1638
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1639
|
+
// Site-only staging:
|
|
1640
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1641
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1642
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1643
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1644
|
+
// .nojekyll injection:
|
|
1645
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1646
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1647
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree (treeSha = "modTree")
|
|
1648
|
+
// Previous gh-pages exists with SAME tree:
|
|
1649
|
+
.mockResolvedValueOnce(gitResult(true, "prevGhPagesTip\n")) // rev-parse refs/remotes/origin/gh-pages
|
|
1650
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // rev-parse prevGhPagesTip^{tree} (SAME as modTree)
|
|
1651
|
+
.mockResolvedValueOnce(gitResult(true, "newOrphanSha\n")) // commit-tree -p prevGhPagesTip
|
|
1652
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1653
|
+
// Deferred source backup:
|
|
1654
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files
|
|
1655
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1656
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1657
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc] Deploy\n")) // commit
|
|
1658
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
1659
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
1660
|
+
|
|
1661
|
+
const onProgress = vi.fn();
|
|
1662
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1663
|
+
|
|
1664
|
+
expect(result.treeChanged).toBe(false);
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it("returns treeChanged=true when no previous gh-pages exists (first deploy)", async () => {
|
|
1668
|
+
setupFullDeployMocks();
|
|
1669
|
+
|
|
1670
|
+
const onProgress = vi.fn();
|
|
1671
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1672
|
+
|
|
1673
|
+
// First deploy: no previous gh-pages ref, so tree is always "changed"
|
|
1674
|
+
expect(result.treeChanged).toBe(true);
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
// ========================================================================
|
|
1679
|
+
// Stale lock cleanup: both index.lock and shallow.lock
|
|
1680
|
+
// ========================================================================
|
|
1681
|
+
describe("stale lock cleanup", () => {
|
|
1682
|
+
it("removes both index.lock and shallow.lock before staging", async () => {
|
|
1683
|
+
setupFullDeployMocks();
|
|
1684
|
+
|
|
1685
|
+
const onProgress = vi.fn();
|
|
1686
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1687
|
+
|
|
1688
|
+
// Verify rm -f .git/index.lock was called
|
|
1689
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1690
|
+
expect.objectContaining({
|
|
1691
|
+
binaryPath: "rm",
|
|
1692
|
+
args: ["-f", ".git/index.lock"],
|
|
1693
|
+
})
|
|
1694
|
+
);
|
|
1695
|
+
|
|
1696
|
+
// Verify rm -f .git/shallow.lock was called
|
|
1697
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1698
|
+
expect.objectContaining({
|
|
1699
|
+
binaryPath: "rm",
|
|
1700
|
+
args: ["-f", ".git/shallow.lock"],
|
|
1701
|
+
})
|
|
1702
|
+
);
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
it("cleans lock files before git add", async () => {
|
|
1706
|
+
setupFullDeployMocks();
|
|
1707
|
+
|
|
1708
|
+
const onProgress = vi.fn();
|
|
1709
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1710
|
+
|
|
1711
|
+
// Find the indices of the lock removal and first git add
|
|
1712
|
+
const indexLockIdx = mockExecuteBinary.mock.calls.findIndex(
|
|
1713
|
+
(call) => call[0].binaryPath === "rm" && call[0].args.includes(".git/index.lock")
|
|
1714
|
+
);
|
|
1715
|
+
const shallowLockIdx = mockExecuteBinary.mock.calls.findIndex(
|
|
1716
|
+
(call) => call[0].binaryPath === "rm" && call[0].args.includes(".git/shallow.lock")
|
|
1717
|
+
);
|
|
1718
|
+
const gitAddIdx = mockExecuteBinary.mock.calls.findIndex(
|
|
1719
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "add"
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1722
|
+
// Both lock removals must come before git add
|
|
1723
|
+
expect(indexLockIdx).toBeLessThan(gitAddIdx);
|
|
1724
|
+
expect(shallowLockIdx).toBeLessThan(gitAddIdx);
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
// ========================================================================
|
|
1729
|
+
// Source backup: push existing unpushed commits when working tree is clean
|
|
1730
|
+
// ========================================================================
|
|
1731
|
+
describe("source backup push for unpushed commits", () => {
|
|
1732
|
+
it("pushes main when diff --cached --quiet succeeds but unpushed commits exist", async () => {
|
|
1733
|
+
const pushUrl = `https://x-access-token:${TOKEN}@github.com/${OWNER}/${REPO}.git`;
|
|
1734
|
+
mockExecuteBinary
|
|
1735
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1736
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1737
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1738
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1739
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1740
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1741
|
+
// Site-only staging:
|
|
1742
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1743
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1744
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1745
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1746
|
+
// .nojekyll injection:
|
|
1747
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1748
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1749
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1750
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1751
|
+
.mockResolvedValueOnce(gitResult(true, "orphanSha\n")) // commit-tree
|
|
1752
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1753
|
+
// Deferred source backup:
|
|
1754
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files (none)
|
|
1755
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1756
|
+
.mockResolvedValueOnce(gitResult(true)) // git diff --cached --quiet SUCCESS (clean working tree)
|
|
1757
|
+
// But local has unpushed commits:
|
|
1758
|
+
.mockResolvedValueOnce(gitResult(true, "3\n")) // rev-list --count origin/main..HEAD → 3 unpushed
|
|
1759
|
+
.mockResolvedValueOnce(gitResult(true)); // git push main (pushes the 3 pending commits)
|
|
1760
|
+
|
|
1761
|
+
const onProgress = vi.fn();
|
|
1762
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1763
|
+
|
|
1764
|
+
expect(result.commitSha).toBe(""); // No new commit (working tree was clean)
|
|
1765
|
+
expect(result.orphanSha).toBe("orphanSha");
|
|
1766
|
+
|
|
1767
|
+
// Verify rev-list was called to check for unpushed commits
|
|
1768
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
1769
|
+
expect.objectContaining({
|
|
1770
|
+
binaryPath: "git",
|
|
1771
|
+
args: ["rev-list", "--count", "origin/main..HEAD"],
|
|
1772
|
+
})
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
// Verify push to main still happened (for the unpushed commits)
|
|
1776
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
1777
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
1778
|
+
);
|
|
1779
|
+
expect(pushCalls).toHaveLength(2); // gh-pages + main
|
|
1780
|
+
expect(pushCalls[1][0].args).toEqual(["push", pushUrl, "HEAD:refs/heads/main"]);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it("does not push main when working tree is clean and no unpushed commits", async () => {
|
|
1784
|
+
mockExecuteBinary
|
|
1785
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1786
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1787
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1788
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1789
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1790
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1791
|
+
// Site-only staging:
|
|
1792
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1793
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1794
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1795
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1796
|
+
// .nojekyll injection:
|
|
1797
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1798
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1799
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1800
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1801
|
+
.mockResolvedValueOnce(gitResult(true, "orphanSha\n")) // commit-tree
|
|
1802
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
1803
|
+
// Deferred source backup:
|
|
1804
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files (none)
|
|
1805
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1806
|
+
.mockResolvedValueOnce(gitResult(true)) // git diff --cached --quiet SUCCESS (clean)
|
|
1807
|
+
.mockResolvedValueOnce(gitResult(true, "0\n")); // rev-list --count → 0 (no unpushed)
|
|
1808
|
+
|
|
1809
|
+
const onProgress = vi.fn();
|
|
1810
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1811
|
+
|
|
1812
|
+
expect(result.commitSha).toBe("");
|
|
1813
|
+
|
|
1814
|
+
// Only ONE push call (gh-pages only, NO main push)
|
|
1815
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
1816
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
1817
|
+
);
|
|
1818
|
+
expect(pushCalls).toHaveLength(1);
|
|
1819
|
+
});
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// ========================================================================
|
|
1823
|
+
// Corrupt git recovery (auto-reinitialize on corrupt .git)
|
|
1824
|
+
// ========================================================================
|
|
1825
|
+
describe("corrupt git recovery", () => {
|
|
1826
|
+
it("retries deploy after wiping corrupt .git when push fails with 'Could not read' error", async () => {
|
|
1827
|
+
const corruptPushError = "error: Could not read 6077fdfa2120f56c44a1504a3d05deac53a83781\n" +
|
|
1828
|
+
"fatal: Failed to traverse parents of commit 6e3a40c7dae586b51d844ecdcec03ec8da841a32\n" +
|
|
1829
|
+
"fatal: the remote end hung up unexpectedly";
|
|
1830
|
+
|
|
1831
|
+
// First attempt: full deploy sequence, push fails with corrupt error
|
|
1832
|
+
mockExecuteBinary
|
|
1833
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1834
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1835
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1836
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1837
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1838
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1839
|
+
// Site-only staging:
|
|
1840
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1841
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1842
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1843
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1844
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1845
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1846
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1847
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1848
|
+
.mockResolvedValueOnce(gitResult(true, "orphan1\n")) // commit-tree
|
|
1849
|
+
.mockResolvedValueOnce(gitResult(false, "", corruptPushError)) // push gh-pages FAILS (corrupt)
|
|
1850
|
+
// Recovery: rm -rf .git
|
|
1851
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -rf .git
|
|
1852
|
+
// Retry: full deploy sequence again (needsInit = true since .git was removed)
|
|
1853
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse --git-dir (no .git)
|
|
1854
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
1855
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
1856
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
1857
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
1858
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
1859
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1860
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1861
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1862
|
+
// Site-only staging (retry):
|
|
1863
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1864
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1865
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1866
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1867
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1868
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1869
|
+
.mockResolvedValueOnce(gitResult(true, "modTree2\n")) // mktree
|
|
1870
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1871
|
+
.mockResolvedValueOnce(gitResult(true, "orphan2\n")) // commit-tree
|
|
1872
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages SUCCEEDS
|
|
1873
|
+
// Deferred source backup (retry):
|
|
1874
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
1875
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
1876
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
1877
|
+
.mockResolvedValueOnce(gitResult(true, "[main def] Deploy\n")) // commit
|
|
1878
|
+
.mockResolvedValueOnce(gitResult(true, "def5678\n")) // rev-parse --short HEAD
|
|
1879
|
+
.mockResolvedValueOnce(gitResult(true)); // push main SUCCEEDS
|
|
1880
|
+
|
|
1881
|
+
const onProgress = vi.fn();
|
|
1882
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
1883
|
+
|
|
1884
|
+
// Should succeed on retry
|
|
1885
|
+
expect((result as DeployResult).commitSha).toBe("def5678");
|
|
1886
|
+
|
|
1887
|
+
// Verify .git was wiped during recovery
|
|
1888
|
+
const rmCalls = mockExecuteBinary.mock.calls.filter(
|
|
1889
|
+
(call) => call[0].binaryPath === "rm" && call[0].args.includes("-rf") && call[0].args.includes(".git")
|
|
1890
|
+
);
|
|
1891
|
+
expect(rmCalls.length).toBeGreaterThanOrEqual(1);
|
|
1892
|
+
|
|
1893
|
+
// Verify recovery progress was reported
|
|
1894
|
+
expect(onProgress).toHaveBeenCalledWith(0, expect.stringContaining("Recovering"));
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
it("does NOT retry on non-corruption push failures", async () => {
|
|
1898
|
+
const authError = "fatal: Authentication failed for 'https://github.com/user/repo.git'";
|
|
1899
|
+
|
|
1900
|
+
// Full deploy sequence, push fails with auth error
|
|
1901
|
+
mockExecuteBinary
|
|
1902
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1903
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1904
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1905
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1906
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1907
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1908
|
+
// Site-only staging:
|
|
1909
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1910
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1911
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1912
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1913
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1914
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1915
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1916
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1917
|
+
.mockResolvedValueOnce(gitResult(true, "orphan1\n")) // commit-tree
|
|
1918
|
+
.mockResolvedValueOnce(gitResult(false, "", authError)); // push gh-pages FAILS (auth error)
|
|
1919
|
+
|
|
1920
|
+
const onProgress = vi.fn();
|
|
1921
|
+
|
|
1922
|
+
await expect(
|
|
1923
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
1924
|
+
).rejects.toThrow("git push failed");
|
|
1925
|
+
|
|
1926
|
+
// Should NOT have tried to rm -rf .git for recovery
|
|
1927
|
+
const rmCalls = mockExecuteBinary.mock.calls.filter(
|
|
1928
|
+
(call) => call[0].binaryPath === "rm" && call[0].args.includes("-rf") && call[0].args.includes(".git")
|
|
1929
|
+
);
|
|
1930
|
+
expect(rmCalls).toHaveLength(0);
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
it("throws if retry also fails", async () => {
|
|
1934
|
+
const corruptError = "error: Could not read abc123\nfatal: Failed to traverse parents of commit def456";
|
|
1935
|
+
|
|
1936
|
+
// First attempt fails with corruption
|
|
1937
|
+
mockExecuteBinary
|
|
1938
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
1939
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
1940
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
1941
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1942
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1943
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1944
|
+
// Site-only staging:
|
|
1945
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1946
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1947
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1948
|
+
.mockResolvedValueOnce(gitResult(true, "aaa111\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1949
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1950
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1951
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
1952
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1953
|
+
.mockResolvedValueOnce(gitResult(true, "orphan1\n")) // commit-tree
|
|
1954
|
+
.mockResolvedValueOnce(gitResult(false, "", corruptError)) // push gh-pages FAILS (corrupt)
|
|
1955
|
+
// Recovery: rm -rf .git
|
|
1956
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -rf .git
|
|
1957
|
+
// Retry: also fails
|
|
1958
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse --git-dir (no .git)
|
|
1959
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
1960
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
1961
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
1962
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
1963
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch (fails, first deploy)
|
|
1964
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
1965
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
1966
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
1967
|
+
// Site-only staging (retry):
|
|
1968
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
1969
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
1970
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
1971
|
+
.mockResolvedValueOnce(gitResult(true, "bbb222\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
1972
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
1973
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
1974
|
+
.mockResolvedValueOnce(gitResult(true, "modTree2\n")) // mktree
|
|
1975
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
1976
|
+
.mockResolvedValueOnce(gitResult(true, "orphan2\n")) // commit-tree
|
|
1977
|
+
.mockResolvedValueOnce(gitResult(false, "", "fatal: some other error")); // push gh-pages FAILS again
|
|
1978
|
+
|
|
1979
|
+
const onProgress = vi.fn();
|
|
1980
|
+
|
|
1981
|
+
await expect(
|
|
1982
|
+
deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" })
|
|
1983
|
+
).rejects.toThrow("git push failed");
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
// ==========================================================================
|
|
1989
|
+
// Incremental deploy: git fetch + parent gh-pages
|
|
1990
|
+
// ==========================================================================
|
|
1991
|
+
describe("incremental deploy", () => {
|
|
1992
|
+
const mockExecuteBinary = vi.mocked(executeBinary);
|
|
1993
|
+
const mockListSiteFilesWithSizes = vi.mocked(listSiteFilesWithSizes);
|
|
1994
|
+
const mockShowToast = vi.mocked(showToast);
|
|
1995
|
+
|
|
1996
|
+
function gitResult(success: boolean, stdout = "", stderr = ""): ExecuteResult {
|
|
1997
|
+
return { success, exitCode: success ? 0 : 1, stdout, stderr };
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
const REPO_MARKER = `https://github.com/${OWNER}/${REPO}.git`;
|
|
2001
|
+
|
|
2002
|
+
beforeEach(() => {
|
|
2003
|
+
mockExecuteBinary.mockReset();
|
|
2004
|
+
mockListSiteFilesWithSizes.mockReset();
|
|
2005
|
+
mockListSiteFilesWithSizes.mockResolvedValue([]);
|
|
2006
|
+
mockShowToast.mockReset();
|
|
2007
|
+
mockShowToast.mockResolvedValue(undefined);
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
it("fetches remote refs before staging to enable delta compression", async () => {
|
|
2011
|
+
// Full deploy sequence with git fetch added after init
|
|
2012
|
+
mockExecuteBinary
|
|
2013
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
2014
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
2015
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
2016
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
2017
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
2018
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
2019
|
+
// Site-only staging:
|
|
2020
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
2021
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
2022
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
2023
|
+
.mockResolvedValueOnce(gitResult(true, "treeSha\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
2024
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
2025
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob siteblob\tindex.html\n")) // ls-tree
|
|
2026
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
2027
|
+
.mockResolvedValueOnce(gitResult(true, "ghPagesTip\n")) // rev-parse refs/remotes/origin/gh-pages
|
|
2028
|
+
.mockResolvedValueOnce(gitResult(true, "prevTreeSha\n")) // rev-parse ghPagesTip^{tree} (different tree)
|
|
2029
|
+
.mockResolvedValueOnce(gitResult(true, "orphanSha\n")) // commit-tree with -p parent
|
|
2030
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
2031
|
+
// Deferred source backup:
|
|
2032
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
2033
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
2034
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
2035
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc] Deploy\n")) // git commit
|
|
2036
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
2037
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
2038
|
+
|
|
2039
|
+
const onProgress = vi.fn();
|
|
2040
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2041
|
+
|
|
2042
|
+
// Verify git fetch was called
|
|
2043
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
2044
|
+
expect.objectContaining({
|
|
2045
|
+
binaryPath: "git",
|
|
2046
|
+
args: ["fetch", "--depth=1", "origin"],
|
|
2047
|
+
})
|
|
2048
|
+
);
|
|
2049
|
+
|
|
2050
|
+
// Verify fetch happens BEFORE git add
|
|
2051
|
+
const calls = mockExecuteBinary.mock.calls.map(c => c[0].args);
|
|
2052
|
+
const fetchIdx = calls.findIndex(args => args[0] === "fetch");
|
|
2053
|
+
const addIdx = calls.findIndex(args => args[0] === "add");
|
|
2054
|
+
expect(fetchIdx).toBeLessThan(addIdx);
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
it("continues deploy when fetch fails (first deploy, no remote)", async () => {
|
|
2058
|
+
mockExecuteBinary
|
|
2059
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse --git-dir (no .git)
|
|
2060
|
+
.mockResolvedValueOnce(gitResult(true)) // git init
|
|
2061
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.email
|
|
2062
|
+
.mockResolvedValueOnce(gitResult(true)) // git config user.name
|
|
2063
|
+
.mockResolvedValueOnce(gitResult(true)) // git remote add origin
|
|
2064
|
+
.mockResolvedValueOnce(gitResult(false)) // git fetch fails (no remote yet)
|
|
2065
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
2066
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
2067
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
2068
|
+
// Site-only staging:
|
|
2069
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
2070
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
2071
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
2072
|
+
.mockResolvedValueOnce(gitResult(true, "treeSha\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
2073
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
2074
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
2075
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
2076
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no remote)
|
|
2077
|
+
.mockResolvedValueOnce(gitResult(true, "orphanSha\n")) // commit-tree (no parent)
|
|
2078
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
2079
|
+
// Deferred source backup:
|
|
2080
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files
|
|
2081
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
2082
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff (changes)
|
|
2083
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc] Deploy\n")) // commit
|
|
2084
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
2085
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
2086
|
+
|
|
2087
|
+
const onProgress = vi.fn();
|
|
2088
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2089
|
+
|
|
2090
|
+
// Deploy should succeed despite fetch failure
|
|
2091
|
+
expect(result.commitSha).toBe("abc1234");
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
it("parents gh-pages commit to previous tip when remote ref exists", async () => {
|
|
2095
|
+
mockExecuteBinary
|
|
2096
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
2097
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
2098
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch
|
|
2099
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
2100
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
2101
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
2102
|
+
// Site-only staging:
|
|
2103
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
2104
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
2105
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
2106
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
2107
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
2108
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob siteblob\tindex.html\n")) // ls-tree
|
|
2109
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
2110
|
+
.mockResolvedValueOnce(gitResult(true, "prevGhPagesTip\n")) // rev-parse refs/remotes/origin/gh-pages
|
|
2111
|
+
.mockResolvedValueOnce(gitResult(true, "prevTreeSha\n")) // rev-parse prevGhPagesTip^{tree} (different tree)
|
|
2112
|
+
.mockResolvedValueOnce(gitResult(true, "newOrphanSha\n")) // commit-tree -p prevGhPagesTip
|
|
2113
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
2114
|
+
// Deferred source backup:
|
|
2115
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files
|
|
2116
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
2117
|
+
.mockResolvedValueOnce(gitResult(false)) // diff (changes)
|
|
2118
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc] Deploy\n")) // commit
|
|
2119
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
2120
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
2121
|
+
|
|
2122
|
+
const onProgress = vi.fn();
|
|
2123
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2124
|
+
|
|
2125
|
+
// Verify commit-tree uses -p with previous gh-pages tip
|
|
2126
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
2127
|
+
expect.objectContaining({
|
|
2128
|
+
binaryPath: "git",
|
|
2129
|
+
args: ["commit-tree", "modTree", "-p", "prevGhPagesTip", "-m", "Deploy site\n\nGenerated by moss"],
|
|
2130
|
+
})
|
|
2131
|
+
);
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it("creates orphan commit when no previous gh-pages exists", async () => {
|
|
2135
|
+
mockExecuteBinary
|
|
2136
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
2137
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
2138
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch
|
|
2139
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
2140
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
2141
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
2142
|
+
// Site-only staging:
|
|
2143
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
2144
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
2145
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
2146
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
2147
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyllblob\n")) // hash-object .nojekyll
|
|
2148
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob siteblob\tindex.html\n")) // ls-tree
|
|
2149
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
2150
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages FAILS
|
|
2151
|
+
.mockResolvedValueOnce(gitResult(true, "orphanSha\n")) // commit-tree (no -p)
|
|
2152
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages
|
|
2153
|
+
// Deferred source backup:
|
|
2154
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files
|
|
2155
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
2156
|
+
.mockResolvedValueOnce(gitResult(false)) // diff (changes)
|
|
2157
|
+
.mockResolvedValueOnce(gitResult(true, "[main abc] Deploy\n")) // commit
|
|
2158
|
+
.mockResolvedValueOnce(gitResult(true, "abc1234\n")) // rev-parse --short HEAD
|
|
2159
|
+
.mockResolvedValueOnce(gitResult(true)); // push main
|
|
2160
|
+
|
|
2161
|
+
const onProgress = vi.fn();
|
|
2162
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2163
|
+
|
|
2164
|
+
// Verify commit-tree WITHOUT -p (orphan commit)
|
|
2165
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
2166
|
+
expect.objectContaining({
|
|
2167
|
+
binaryPath: "git",
|
|
2168
|
+
args: ["commit-tree", "modTree", "-m", "Deploy site\n\nGenerated by moss"],
|
|
2169
|
+
})
|
|
2170
|
+
);
|
|
2171
|
+
});
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
// ==========================================================================
|
|
2175
|
+
// Incremental deploy: site-only staging + deferred source backup
|
|
2176
|
+
// ==========================================================================
|
|
2177
|
+
describe("incremental deploy (site-only staging)", () => {
|
|
2178
|
+
const mockExecuteBinary = vi.mocked(executeBinary);
|
|
2179
|
+
const mockListSiteFilesWithSizes = vi.mocked(listSiteFilesWithSizes);
|
|
2180
|
+
const mockShowToast = vi.mocked(showToast);
|
|
2181
|
+
|
|
2182
|
+
function gitResult(success: boolean, stdout = "", stderr = ""): ExecuteResult {
|
|
2183
|
+
return { success, exitCode: success ? 0 : 1, stdout, stderr };
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const REPO_MARKER = `https://github.com/${OWNER}/${REPO}.git`;
|
|
2187
|
+
|
|
2188
|
+
beforeEach(() => {
|
|
2189
|
+
mockExecuteBinary.mockReset();
|
|
2190
|
+
mockListSiteFilesWithSizes.mockReset();
|
|
2191
|
+
mockListSiteFilesWithSizes.mockResolvedValue([]);
|
|
2192
|
+
mockShowToast.mockReset();
|
|
2193
|
+
mockShowToast.mockResolvedValue(undefined);
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Set up mock sequence for incremental deploy (site-only staging).
|
|
2198
|
+
*
|
|
2199
|
+
* New sequence: rev-parse --git-dir, remote get-url origin, fetch --depth=1,
|
|
2200
|
+
* .gitignore, rm -f index.lock, rm -f shallow.lock,
|
|
2201
|
+
* readlink .moss/build/current, git add .moss/build/generations/<id>/,
|
|
2202
|
+
* git write-tree --prefix=.moss/build/generations/<id>/, .nojekyll injection,
|
|
2203
|
+
* rev-parse gh-pages tip, commit-tree, push gh-pages ONLY,
|
|
2204
|
+
* then deferred: find(large files), git add --all, diff, commit, push main
|
|
2205
|
+
*/
|
|
2206
|
+
function setupIncrementalDeployMocks(commitSha = "abc1234") {
|
|
2207
|
+
mockExecuteBinary
|
|
2208
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir (repo exists)
|
|
2209
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin (matches)
|
|
2210
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
2211
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore (sh -c)
|
|
2212
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
2213
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
2214
|
+
// Site-only staging:
|
|
2215
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
2216
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
2217
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
2218
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree123\n")) // git write-tree --prefix=.moss/build/generations/<id>/
|
|
2219
|
+
// .nojekyll injection:
|
|
2220
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyll000\n")) // hash-object -w --stdin (.nojekyll)
|
|
2221
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob siteblob\tindex.html\n")) // ls-tree
|
|
2222
|
+
.mockResolvedValueOnce(gitResult(true, "modifiedTree\n")) // mktree (with .nojekyll)
|
|
2223
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse refs/remotes/origin/gh-pages (no prev)
|
|
2224
|
+
.mockResolvedValueOnce(gitResult(true, "orphan999\n")) // git commit-tree (orphan, no parent)
|
|
2225
|
+
.mockResolvedValueOnce(gitResult(true)) // git push gh-pages ONLY
|
|
2226
|
+
// Deferred source backup:
|
|
2227
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large source files (none found)
|
|
2228
|
+
.mockResolvedValueOnce(gitResult(true)) // git add --all
|
|
2229
|
+
.mockResolvedValueOnce(gitResult(false)) // git diff --cached --quiet (changes exist)
|
|
2230
|
+
.mockResolvedValueOnce(gitResult(true, `[main ${commitSha}] Deploy site\n`)) // git commit
|
|
2231
|
+
.mockResolvedValueOnce(gitResult(true, commitSha + "\n")) // rev-parse --short HEAD
|
|
2232
|
+
.mockResolvedValueOnce(gitResult(true)); // git push main
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
it("stages only current generation dir before gh-pages push (not --all)", async () => {
|
|
2236
|
+
setupIncrementalDeployMocks();
|
|
2237
|
+
|
|
2238
|
+
const onProgress = vi.fn();
|
|
2239
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2240
|
+
|
|
2241
|
+
// Find all git add calls
|
|
2242
|
+
const addCalls = mockExecuteBinary.mock.calls.filter(
|
|
2243
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "add"
|
|
2244
|
+
);
|
|
2245
|
+
|
|
2246
|
+
// First add call should be the resolved generation dir only (NOT --all, NOT .moss/build/site/)
|
|
2247
|
+
expect(addCalls.length).toBeGreaterThanOrEqual(1);
|
|
2248
|
+
expect(addCalls[0][0].args).toEqual(["add", `${GEN_DIR}/`]);
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
it("uses write-tree --prefix to get site tree SHA directly from index", async () => {
|
|
2252
|
+
setupIncrementalDeployMocks();
|
|
2253
|
+
|
|
2254
|
+
const onProgress = vi.fn();
|
|
2255
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2256
|
+
|
|
2257
|
+
expect(mockExecuteBinary).toHaveBeenCalledWith(
|
|
2258
|
+
expect.objectContaining({
|
|
2259
|
+
binaryPath: "git",
|
|
2260
|
+
args: ["write-tree", `--prefix=${GEN_DIR}/`],
|
|
2261
|
+
})
|
|
2262
|
+
);
|
|
2263
|
+
|
|
2264
|
+
// Should NOT use rev-parse HEAD:.moss/site (old approach)
|
|
2265
|
+
const revParseSiteCalls = mockExecuteBinary.mock.calls.filter(
|
|
2266
|
+
(call) => call[0].binaryPath === "git" &&
|
|
2267
|
+
call[0].args[0] === "rev-parse" &&
|
|
2268
|
+
call[0].args.includes("HEAD:.moss/site")
|
|
2269
|
+
);
|
|
2270
|
+
expect(revParseSiteCalls).toHaveLength(0);
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
it("pushes gh-pages before staging source files", async () => {
|
|
2274
|
+
setupIncrementalDeployMocks();
|
|
2275
|
+
|
|
2276
|
+
const onProgress = vi.fn();
|
|
2277
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2278
|
+
|
|
2279
|
+
const pushUrl = `https://x-access-token:${TOKEN}@github.com/${OWNER}/${REPO}.git`;
|
|
2280
|
+
|
|
2281
|
+
// Find the first push call — it should be gh-pages only (no HEAD:refs/heads/main)
|
|
2282
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
2283
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
2284
|
+
);
|
|
2285
|
+
expect(pushCalls.length).toBeGreaterThanOrEqual(1);
|
|
2286
|
+
const firstPush = pushCalls[0][0].args;
|
|
2287
|
+
expect(firstPush).toContain("orphan999:refs/heads/gh-pages");
|
|
2288
|
+
expect(firstPush).not.toContain("HEAD:refs/heads/main");
|
|
2289
|
+
|
|
2290
|
+
// Find the git add --all call — it should come AFTER the first push
|
|
2291
|
+
const addAllIndex = mockExecuteBinary.mock.calls.findIndex(
|
|
2292
|
+
(call) => call[0].binaryPath === "git" &&
|
|
2293
|
+
call[0].args[0] === "add" &&
|
|
2294
|
+
call[0].args.includes("--all")
|
|
2295
|
+
);
|
|
2296
|
+
const firstPushIndex = mockExecuteBinary.mock.calls.findIndex(
|
|
2297
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
2298
|
+
);
|
|
2299
|
+
expect(addAllIndex).toBeGreaterThan(firstPushIndex);
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
it("defers source backup to main branch after gh-pages deploy", async () => {
|
|
2303
|
+
setupIncrementalDeployMocks();
|
|
2304
|
+
|
|
2305
|
+
const onProgress = vi.fn();
|
|
2306
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2307
|
+
|
|
2308
|
+
const pushUrl = `https://x-access-token:${TOKEN}@github.com/${OWNER}/${REPO}.git`;
|
|
2309
|
+
|
|
2310
|
+
// Should have two push calls: gh-pages first, then main
|
|
2311
|
+
const pushCalls = mockExecuteBinary.mock.calls.filter(
|
|
2312
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
2313
|
+
);
|
|
2314
|
+
expect(pushCalls).toHaveLength(2);
|
|
2315
|
+
|
|
2316
|
+
// Second push should be main only
|
|
2317
|
+
const secondPush = pushCalls[1][0].args;
|
|
2318
|
+
expect(secondPush).toContain("HEAD:refs/heads/main");
|
|
2319
|
+
expect(secondPush).not.toContain("gh-pages");
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
it("succeeds even when source backup fails", async () => {
|
|
2323
|
+
mockExecuteBinary
|
|
2324
|
+
.mockResolvedValueOnce(gitResult(true)) // rev-parse --git-dir
|
|
2325
|
+
.mockResolvedValueOnce(gitResult(true, REPO_MARKER + "\n")) // remote get-url origin
|
|
2326
|
+
.mockResolvedValueOnce(gitResult(true)) // git fetch --depth=1 origin
|
|
2327
|
+
.mockResolvedValueOnce(gitResult(true)) // write .gitignore
|
|
2328
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock
|
|
2329
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/shallow.lock
|
|
2330
|
+
// Site-only staging:
|
|
2331
|
+
.mockResolvedValueOnce(gitResult(true, GEN_ABS + "\n")) // readlink .moss/build/current → abs gen path
|
|
2332
|
+
.mockResolvedValueOnce(gitResult(true)) // git add .moss/build/generations/<id>/
|
|
2333
|
+
.mockResolvedValueOnce(gitResult(true)) // rm -f .git/index.lock (iCloud race)
|
|
2334
|
+
.mockResolvedValueOnce(gitResult(true, "siteTree123\n")) // write-tree --prefix=.moss/build/generations/<id>/
|
|
2335
|
+
// .nojekyll injection:
|
|
2336
|
+
.mockResolvedValueOnce(gitResult(true, "nojekyll000\n")) // hash-object
|
|
2337
|
+
.mockResolvedValueOnce(gitResult(true, "100644 blob abc\tindex.html\n")) // ls-tree
|
|
2338
|
+
.mockResolvedValueOnce(gitResult(true, "modTree\n")) // mktree
|
|
2339
|
+
.mockResolvedValueOnce(gitResult(false)) // rev-parse gh-pages (no prev)
|
|
2340
|
+
.mockResolvedValueOnce(gitResult(true, "orphan999\n")) // commit-tree
|
|
2341
|
+
.mockResolvedValueOnce(gitResult(true)) // push gh-pages (succeeds)
|
|
2342
|
+
// Deferred source backup FAILS:
|
|
2343
|
+
.mockResolvedValueOnce(gitResult(true, "")) // find large files
|
|
2344
|
+
.mockResolvedValueOnce(gitResult(false, "", "fatal: error")); // git add --all fails
|
|
2345
|
+
|
|
2346
|
+
const onProgress = vi.fn();
|
|
2347
|
+
// Should NOT throw even though source backup failed
|
|
2348
|
+
const result = await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2349
|
+
expect(result.orphanSha).toBe("orphan999");
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
it("reports Deployed! before starting source backup", async () => {
|
|
2353
|
+
setupIncrementalDeployMocks();
|
|
2354
|
+
|
|
2355
|
+
const onProgress = vi.fn();
|
|
2356
|
+
await deployViaGitPush({ owner: OWNER, repo: REPO, token: TOKEN, onProgress, gitPath: "git" });
|
|
2357
|
+
|
|
2358
|
+
// "Deployed!" should be called
|
|
2359
|
+
expect(onProgress).toHaveBeenCalledWith(100, "Deployed!");
|
|
2360
|
+
|
|
2361
|
+
// Find the index of the "Deployed!" call and the "git add --all" call
|
|
2362
|
+
const deployedCallIndex = onProgress.mock.calls.findIndex(
|
|
2363
|
+
(call) => call[0] === 100 && call[1] === "Deployed!"
|
|
2364
|
+
);
|
|
2365
|
+
// The source backup git add --all should come after Deployed!
|
|
2366
|
+
const addAllIndex = mockExecuteBinary.mock.calls.findIndex(
|
|
2367
|
+
(call) => call[0].binaryPath === "git" &&
|
|
2368
|
+
call[0].args[0] === "add" &&
|
|
2369
|
+
call[0].args.includes("--all")
|
|
2370
|
+
);
|
|
2371
|
+
const ghPagesPushIndex = mockExecuteBinary.mock.calls.findIndex(
|
|
2372
|
+
(call) => call[0].binaryPath === "git" && call[0].args[0] === "push"
|
|
2373
|
+
);
|
|
2374
|
+
// The git add --all should come AFTER the gh-pages push
|
|
2375
|
+
expect(addAllIndex).toBeGreaterThan(ghPagesPushIndex);
|
|
2376
|
+
});
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
// ==========================================================================
|
|
2380
|
+
// looksLikeCorruptGit
|
|
2381
|
+
// ==========================================================================
|
|
2382
|
+
describe("looksLikeCorruptGit", () => {
|
|
2383
|
+
it("detects 'Could not read' errors", () => {
|
|
2384
|
+
expect(looksLikeCorruptGit("error: Could not read 6077fdfa2120f56c44a1504a3d05deac53a83781")).toBe(true);
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
it("detects 'Failed to traverse parents' errors", () => {
|
|
2388
|
+
expect(looksLikeCorruptGit("fatal: Failed to traverse parents of commit 6e3a40c")).toBe(true);
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
it("detects 'bad object' errors", () => {
|
|
2392
|
+
expect(looksLikeCorruptGit("fatal: bad object HEAD")).toBe(true);
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
it("detects 'corrupt' in error messages", () => {
|
|
2396
|
+
expect(looksLikeCorruptGit("error: corrupt loose object '6077fdfa'")).toBe(true);
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
it("returns false for authentication errors", () => {
|
|
2400
|
+
expect(looksLikeCorruptGit("fatal: Authentication failed")).toBe(false);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
it("returns false for rejection errors", () => {
|
|
2404
|
+
expect(looksLikeCorruptGit("error: failed to push some refs to 'https://github.com/...'")).toBe(false);
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
it("returns false for empty string", () => {
|
|
2408
|
+
expect(looksLikeCorruptGit("")).toBe(false);
|
|
2409
|
+
});
|
|
2410
|
+
});
|
|
2411
|
+
});
|