@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +3 -0
  4. package/assets/manifest.json +19 -0
  5. package/e2e/deploy-api.test.ts +1129 -0
  6. package/e2e/moss-cli.test.ts +478 -0
  7. package/features/auth/device-flow.feature +41 -0
  8. package/features/deploy/validation.feature +50 -0
  9. package/features/steps/auth.steps.ts +285 -0
  10. package/features/steps/deploy.steps.ts +354 -0
  11. package/package.json +51 -0
  12. package/src/__tests__/auth-flow.integration.test.ts +738 -0
  13. package/src/__tests__/auth.test.ts +147 -0
  14. package/src/__tests__/configure-domain.test.ts +263 -0
  15. package/src/__tests__/deploy.integration.test.ts +798 -0
  16. package/src/__tests__/git.test.ts +190 -0
  17. package/src/__tests__/github-api.test.ts +761 -0
  18. package/src/__tests__/github-deploy.test.ts +2411 -0
  19. package/src/__tests__/progress-timeout.test.ts +209 -0
  20. package/src/__tests__/repo-setup-progress.test.ts +367 -0
  21. package/src/__tests__/repo-setup.test.ts +370 -0
  22. package/src/__tests__/token.test.ts +152 -0
  23. package/src/__tests__/utils.test.ts +129 -0
  24. package/src/__tests__/workflow.test.ts +146 -0
  25. package/src/auth.ts +588 -0
  26. package/src/constants.ts +7 -0
  27. package/src/git.ts +60 -0
  28. package/src/github-api.ts +601 -0
  29. package/src/github-deploy.ts +593 -0
  30. package/src/main.ts +646 -0
  31. package/src/repo-setup.ts +685 -0
  32. package/src/token.ts +202 -0
  33. package/src/types.ts +91 -0
  34. package/src/utils.ts +108 -0
  35. package/src/workflow.ts +79 -0
  36. package/test-helpers/mock-github-api.ts +217 -0
  37. package/tsconfig.json +20 -0
  38. 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
+ });