@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,593 @@
1
+ /**
2
+ * GitHub Deployment Module
3
+ *
4
+ * Deploys site content to GitHub Pages via git push and backs up
5
+ * source files to the main branch.
6
+ *
7
+ * @module github-deploy
8
+ */
9
+
10
+ import { GITHUB_API_BASE, GITHUB_API_HEADERS } from "./github-api";
11
+ import { executeBinary, listSiteFilesWithSizes, type ExecuteResult } from "@symbiosis-lab/moss-api";
12
+ import { showToast } from "./utils";
13
+
14
+ // ============================================================================
15
+ // Types
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Progress callback: (percent 0-100, message).
20
+ * Percent reflects weighted phase position, not linear file count.
21
+ */
22
+ export type OnProgress = (percent: number, message: string) => void;
23
+
24
+ /**
25
+ * Result of a deployViaGitPush call.
26
+ */
27
+ export interface DeployResult {
28
+ /** Short SHA of the main branch commit (empty string if no changes) */
29
+ commitSha: string;
30
+ /** Full SHA of the orphan commit pushed to gh-pages */
31
+ orphanSha: string;
32
+ /** Whether the gh-pages tree actually changed from the previous deployment */
33
+ treeChanged: boolean;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Internal Helpers
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Parse an error response body for a human-readable message.
42
+ */
43
+ async function parseErrorMessage(response: Response): Promise<string> {
44
+ try {
45
+ const body = await response.json();
46
+ return body.message || `GitHub API error: ${response.status}`;
47
+ } catch {
48
+ return `GitHub API error: ${response.status}`;
49
+ }
50
+ }
51
+
52
+
53
+ // ============================================================================
54
+ // API Functions
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Verify that a repository exists on GitHub.
59
+ *
60
+ * Call this early in the deploy flow to fail fast with a clear error message
61
+ * instead of getting a cryptic "Not Found" from blob/tree/commit endpoints.
62
+ *
63
+ * @param owner - Repository owner
64
+ * @param repo - Repository name
65
+ * @param token - GitHub access token
66
+ * @throws {RepoNotFoundError} if the repository is definitively not found (404)
67
+ * @throws {Error} if the token is invalid (401), access is denied (403), or another API error occurs
68
+ */
69
+ export async function verifyRepoExists(
70
+ owner: string,
71
+ repo: string,
72
+ token: string
73
+ ): Promise<void> {
74
+ const headers = {
75
+ ...GITHUB_API_HEADERS,
76
+ Authorization: `Bearer ${token}`,
77
+ };
78
+
79
+ const response = await fetch(
80
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}`,
81
+ { headers }
82
+ );
83
+
84
+ if (response.status === 404) {
85
+ // Disambiguate: check if the owner exists (unauthenticated, works for public profiles)
86
+ const ownerResp = await fetch(`${GITHUB_API_BASE}/users/${owner}`, {
87
+ headers: { ...GITHUB_API_HEADERS },
88
+ });
89
+
90
+ if (ownerResp.status === 404) {
91
+ throw new Error(
92
+ `GitHub user or organization "${owner}" not found. ` +
93
+ `Check for typos in the repository owner name.`
94
+ );
95
+ }
96
+
97
+ throw new Error(
98
+ `Repository "${owner}/${repo}" not found on GitHub. ` +
99
+ `The repository may not exist, or your token may not have access to it.`
100
+ );
101
+ }
102
+
103
+ if (response.status === 401) {
104
+ throw new Error(
105
+ `GitHub token is invalid or expired. Please re-authenticate.`
106
+ );
107
+ }
108
+
109
+ if (response.status === 403) {
110
+ throw new Error(
111
+ `Access denied to "${owner}/${repo}". ` +
112
+ `Your token may lack the required "repo" scope.`
113
+ );
114
+ }
115
+
116
+ if (!response.ok) {
117
+ const msg = await parseErrorMessage(response);
118
+ throw new Error(msg);
119
+ }
120
+ }
121
+
122
+ // ============================================================================
123
+ // Git Origin Helpers
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Read the deploy target from the project's .git origin remote.
128
+ * Returns null if no .git, no origin, or origin is not a GitHub URL.
129
+ */
130
+ export async function getOriginOwnerRepo(gitPath: string = "git"): Promise<{ owner: string; repo: string } | null> {
131
+ const result = await executeBinary({
132
+ binaryPath: gitPath,
133
+ args: ["remote", "get-url", "origin"],
134
+ workingDir: ".",
135
+ timeoutMs: 5_000,
136
+ env: { GIT_TERMINAL_PROMPT: "0" },
137
+ });
138
+
139
+ if (!result.success) return null;
140
+
141
+ const url = result.stdout.trim();
142
+
143
+ // Parse HTTPS: https://github.com/{owner}/{repo}.git
144
+ const httpsMatch = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
145
+ if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
146
+
147
+ // Parse SSH: git@github.com:{owner}/{repo}.git
148
+ const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
149
+ if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
150
+
151
+ return null;
152
+ }
153
+
154
+ // ============================================================================
155
+ // Constants
156
+ // ============================================================================
157
+
158
+ /** GitHub's per-file size limit: 100 MB */
159
+ const MAX_FILE_SIZE = 100 * 1024 * 1024;
160
+
161
+ /**
162
+ * Patterns in git error output that indicate corrupt local objects.
163
+ * These errors cannot be fixed by retrying — the .git directory must
164
+ * be wiped and reinitialized. Common cause: iCloud sync corrupting
165
+ * .git/objects/ files.
166
+ */
167
+ const CORRUPT_GIT_PATTERNS = [
168
+ "Could not read",
169
+ "Failed to traverse parents",
170
+ "bad object",
171
+ "corrupt",
172
+ ];
173
+
174
+ /**
175
+ * Check if a git error message indicates corrupt local git state.
176
+ */
177
+ export function looksLikeCorruptGit(errorMsg: string): boolean {
178
+ return CORRUPT_GIT_PATTERNS.some(p => errorMsg.includes(p));
179
+ }
180
+
181
+ // ============================================================================
182
+ // Git Push Deploy
183
+ // ============================================================================
184
+
185
+ /**
186
+ * Options for deploying via git push.
187
+ */
188
+ export interface DeployViaGitPushOptions {
189
+ owner: string;
190
+ repo: string;
191
+ token: string;
192
+ onProgress: OnProgress;
193
+ gitPath: string;
194
+ /** Custom domain to include as CNAME file in gh-pages branch */
195
+ domain?: string;
196
+ }
197
+
198
+ /**
199
+ * Replace all occurrences of the token in text with "***".
200
+ * Prevents leaking credentials in error messages.
201
+ */
202
+ function sanitize(text: string, token: string): string {
203
+ return text.replaceAll(token, "***");
204
+ }
205
+
206
+ /**
207
+ * Format a byte count as a human-readable string (e.g., "105.3 MB").
208
+ */
209
+ function formatSize(bytes: number): string {
210
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
211
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
212
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
213
+ return `${bytes} B`;
214
+ }
215
+
216
+ /**
217
+ * Parse git push progress from stderr and map to a progress range.
218
+ *
219
+ * Git push outputs lines like:
220
+ * "Writing objects: 79% (234/295), 1.84 MiB | 1.20 MiB/s"
221
+ *
222
+ * @param line - stderr line from git push
223
+ * @param rangeStart - Start of the progress range (e.g., 20)
224
+ * @param rangeEnd - End of the progress range (e.g., 40)
225
+ * @param onProgress - Callback to report progress
226
+ * @param token - Token to sanitize from output
227
+ */
228
+ function parsePushProgress(
229
+ line: string,
230
+ rangeStart: number,
231
+ rangeEnd: number,
232
+ onProgress: OnProgress,
233
+ token: string,
234
+ ): void {
235
+ const match = line.match(/Writing objects:\s+(\d+)%/);
236
+ if (match) {
237
+ const gitPercent = parseInt(match[1], 10);
238
+ const mapped = Math.round(rangeStart + (gitPercent / 100) * (rangeEnd - rangeStart));
239
+ onProgress(mapped, sanitize(line.trim(), token));
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Resolve the current generation directory path (relative to project root).
245
+ *
246
+ * The build pipeline writes output to `.moss/build/generations/<hash>/` and
247
+ * updates `.moss/build/current` (a symlink) to point to that directory.
248
+ * `.moss/build/site/` is empty in the generations model — never stage from there.
249
+ *
250
+ * Returns a path like `.moss/build/generations/abc123def456` — the real directory
251
+ * git can stage and extract a tree from (git does not follow symlinks for
252
+ * `write-tree --prefix`).
253
+ *
254
+ * @throws {Error} if the current symlink is absent or unresolvable
255
+ */
256
+ export async function resolveCurrentGenDir(): Promise<string> {
257
+ // `readlink` on macOS does NOT support -f, but the symlink target is already
258
+ // absolute (set_current_ptr uses an absolute path), so plain `readlink` suffices.
259
+ const result = await executeBinary({
260
+ binaryPath: "readlink",
261
+ args: [".moss/build/current"],
262
+ workingDir: ".",
263
+ timeoutMs: 5_000,
264
+ env: {},
265
+ });
266
+ if (!result.success || !result.stdout.trim()) {
267
+ throw new Error(
268
+ "Cannot locate current generation: .moss/build/current symlink is missing or unresolvable. " +
269
+ "Run a build first before deploying."
270
+ );
271
+ }
272
+ // The symlink target is absolute: /abs/path/to/.moss/build/generations/<id>
273
+ // Extract just the generation ID and reconstruct the relative path.
274
+ const absTarget = result.stdout.trim();
275
+ const genId = absTarget.split("/").filter(Boolean).pop();
276
+ if (!genId) {
277
+ throw new Error(`Unexpected generation symlink target: ${absTarget}`);
278
+ }
279
+ return `.moss/build/generations/${genId}`;
280
+ }
281
+
282
+ /**
283
+ * Deploy site to GitHub Pages via a single git repo.
284
+ *
285
+ * Uses one git repo at the project root. Stages site files from the current
286
+ * generation directory (.moss/build/current → .moss/build/generations/<id>/),
287
+ * extracts the generation tree as an orphan commit and pushes it to gh-pages,
288
+ * then backs up source files to main. This gives GitHub Pages the site content
289
+ * at the branch root while keeping source on main.
290
+ *
291
+ * Enforces GitHub's 100MB per-file limit:
292
+ * - Site files (current generation dir): ABORT if any exceed 100MB
293
+ * - Source files (project root): SKIP >100MB files with a warning toast
294
+ *
295
+ * @param options - Deploy options
296
+ * @returns DeployResult with commitSha (short SHA on main) and orphanSha (full SHA on gh-pages)
297
+ */
298
+ export async function deployViaGitPush(options: DeployViaGitPushOptions): Promise<DeployResult> {
299
+ const { owner, repo, token, onProgress } = options;
300
+ const repoMarker = `https://github.com/${owner}/${repo}.git`;
301
+ const pushUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
302
+
303
+ async function git(
304
+ args: string[],
305
+ onStderr?: (line: string) => void,
306
+ ): Promise<ExecuteResult> {
307
+ return executeBinary({
308
+ binaryPath: options.gitPath,
309
+ args,
310
+ workingDir: ".",
311
+ timeoutMs: 600_000, // 10 min — first push of large repos can be slow
312
+ env: { GIT_TERMINAL_PROMPT: "0" },
313
+ onStderr,
314
+ });
315
+ }
316
+
317
+ // ── Pre-flight: Check site files for 100MB limit ──────────────────────
318
+ onProgress(0, "Preparing deploy...");
319
+
320
+ const siteFiles = await listSiteFilesWithSizes();
321
+ const oversizedSiteFiles = siteFiles.filter((f) => f.size > MAX_FILE_SIZE);
322
+ if (oversizedSiteFiles.length > 0) {
323
+ const fileList = oversizedSiteFiles
324
+ .map((f) => ` ${f.path} (${formatSize(f.size)})`)
325
+ .join("\n");
326
+ throw new Error(
327
+ `Site files exceed GitHub's 100 MB per-file limit:\n${fileList}\n\n` +
328
+ `Remove or reduce these files before deploying.`
329
+ );
330
+ }
331
+
332
+ // Inner function containing the full init → add → commit → push sequence.
333
+ // Extracted so we can retry once on corrupt git state.
334
+ async function attemptDeploy(): Promise<DeployResult> {
335
+ // ── 1. Init git repo if needed, reinit if target repo changed ────────
336
+ // IDEMPOTENT: detect repo change and reinitialize .git so switching
337
+ // deploy targets doesn't push to the wrong remote.
338
+ const check = await git(["rev-parse", "--git-dir"]);
339
+ let needsInit = !check.success;
340
+
341
+ if (check.success) {
342
+ const originUrl = await git(["remote", "get-url", "origin"]);
343
+ if (!originUrl.success || originUrl.stdout.trim() !== repoMarker) {
344
+ // Origin missing or pointing at a different repo — wipe and reinit
345
+ const rm = await executeBinary({
346
+ binaryPath: "rm", args: ["-rf", ".git"],
347
+ workingDir: ".", timeoutMs: 10_000, env: {},
348
+ });
349
+ if (!rm.success) throw new Error(`Failed to remove stale .git: ${rm.stderr}`);
350
+ needsInit = true;
351
+ }
352
+ }
353
+
354
+ if (needsInit) {
355
+ await git(["init"]);
356
+ await git(["config", "user.email", "moss@symbiosis-lab.com"]);
357
+ await git(["config", "user.name", "moss"]);
358
+ await git(["remote", "add", "origin", repoMarker]);
359
+ }
360
+
361
+ // ── Fetch remote refs for delta compression during push ──────────────
362
+ // Without this, git has no common objects and must upload everything.
363
+ // Non-fatal: first deploy has no remote refs to fetch.
364
+ const fetchResult = await git(["fetch", "--depth=1", "origin"]);
365
+ if (!fetchResult.success) {
366
+ console.log(" No remote history to fetch (first deploy)");
367
+ }
368
+
369
+ // ── Migration: strip stale moss-managed .moss/* lines from root .gitignore ──
370
+ // Older moss versions overwrote the root .gitignore with .moss/* exclusions.
371
+ // Those rules now live in .moss/.gitignore (written by the Rust pipeline).
372
+ await executeBinary({
373
+ binaryPath: "sh",
374
+ args: ["-c", "[ -f .gitignore ] && sed -i '' '/^\\.moss/d;/^!\\.moss/d' .gitignore || true"],
375
+ workingDir: ".",
376
+ timeoutMs: 5_000,
377
+ env: {},
378
+ });
379
+
380
+ // IDEMPOTENT: remove stale locks from crashed git operations (common with iCloud)
381
+ await executeBinary({
382
+ binaryPath: "rm", args: ["-f", ".git/index.lock"],
383
+ workingDir: ".", timeoutMs: 5_000, env: {},
384
+ });
385
+ await executeBinary({
386
+ binaryPath: "rm", args: ["-f", ".git/shallow.lock"],
387
+ workingDir: ".", timeoutMs: 5_000, env: {},
388
+ });
389
+
390
+ // ── 2. Resolve current generation dir and stage its files ────────────
391
+ // .moss/build/site/ is empty in the generations model (#816). The real
392
+ // output lives at .moss/build/current → .moss/build/generations/<id>/.
393
+ // git does not follow symlinks for write-tree --prefix, so we resolve
394
+ // the symlink to the actual (relative) generation directory first.
395
+ onProgress(5, "Staging site files...");
396
+ const genDir = await resolveCurrentGenDir();
397
+ await git(["add", `${genDir}/`]);
398
+
399
+ // ── 3. Get site tree SHA directly from index ─────────────────────────
400
+ // Remove index.lock again: iCloud can re-lock the index between git add
401
+ // (which releases it) and write-tree (which needs to read it). The lock
402
+ // file is zero-bytes with iCloud extended attributes — not a real git lock.
403
+ await executeBinary({
404
+ binaryPath: "rm", args: ["-f", ".git/index.lock"],
405
+ workingDir: ".", timeoutMs: 5_000, env: {},
406
+ });
407
+ onProgress(10, "Preparing gh-pages...");
408
+ const writeTree = await git(["write-tree", `--prefix=${genDir}/`]);
409
+ if (!writeTree.success) throw new Error(`Failed to write site tree: ${sanitize(writeTree.stderr, token)}`);
410
+
411
+ // Inject .nojekyll (always) and CNAME (when domain is set) into the
412
+ // gh-pages tree. Without .nojekyll, GitHub runs Jekyll which may skip
413
+ // files starting with underscores or fail to trigger the Pages pipeline.
414
+ // Without CNAME, each force-push removes the custom domain setting.
415
+ // Both are injected in a single ls-tree → modify → mktree pass.
416
+ let treeSha = writeTree.stdout.trim();
417
+ {
418
+ // Create empty .nojekyll blob
419
+ const nojekyllHash = await executeBinary({
420
+ binaryPath: options.gitPath,
421
+ args: ["hash-object", "-w", "--stdin"],
422
+ workingDir: ".",
423
+ timeoutMs: 30_000, // iCloud can slow .git/objects writes
424
+ env: { GIT_TERMINAL_PROMPT: "0" },
425
+ stdin: "",
426
+ });
427
+
428
+ if (nojekyllHash.success) {
429
+ const lsTree = await git(["ls-tree", treeSha]);
430
+ if (lsTree.success) {
431
+ // Filter out existing .nojekyll and CNAME entries to avoid duplicates
432
+ // (user's site may already contain these files)
433
+ const filteredEntries = lsTree.stdout.trimEnd().split("\n")
434
+ .filter(line => !line.endsWith("\t.nojekyll") && !line.endsWith("\tCNAME"))
435
+ .join("\n");
436
+ let treeEntries = filteredEntries
437
+ + "\n100644 blob " + nojekyllHash.stdout.trim() + "\t.nojekyll\n";
438
+
439
+ // Additionally inject CNAME when domain is configured
440
+ if (options.domain) {
441
+ const cnameHash = await executeBinary({
442
+ binaryPath: options.gitPath,
443
+ args: ["hash-object", "-w", "--stdin"],
444
+ workingDir: ".",
445
+ timeoutMs: 30_000,
446
+ env: { GIT_TERMINAL_PROMPT: "0" },
447
+ stdin: options.domain + "\n",
448
+ });
449
+ if (cnameHash.success) {
450
+ treeEntries += "100644 blob " + cnameHash.stdout.trim() + "\tCNAME\n";
451
+ }
452
+ }
453
+
454
+ const mktree = await executeBinary({
455
+ binaryPath: options.gitPath,
456
+ args: ["mktree"],
457
+ workingDir: ".",
458
+ timeoutMs: 30_000,
459
+ env: { GIT_TERMINAL_PROMPT: "0" },
460
+ stdin: treeEntries,
461
+ });
462
+ if (mktree.success) treeSha = mktree.stdout.trim();
463
+ }
464
+ }
465
+ }
466
+
467
+ // ── 4. Parent to previous gh-pages tip for delta compression ─────────
468
+ // When the remote gh-pages ref exists, create a child commit instead of
469
+ // an orphan. This lets git delta-compress against the previous tree,
470
+ // drastically reducing upload size on repeat deploys.
471
+ // Also detect whether the tree actually changed (for accurate UI messaging).
472
+ const ghPagesTip = await git(["rev-parse", "refs/remotes/origin/gh-pages"]);
473
+ let treeChanged = true; // Assume changed unless we prove otherwise
474
+ if (ghPagesTip.success) {
475
+ const prevTree = await git(["rev-parse", `${ghPagesTip.stdout.trim()}^{tree}`]);
476
+ if (prevTree.success && prevTree.stdout.trim() === treeSha) {
477
+ treeChanged = false;
478
+ }
479
+ }
480
+ const commitTreeArgs = ghPagesTip.success
481
+ ? ["commit-tree", treeSha, "-p", ghPagesTip.stdout.trim(), "-m", "Deploy site\n\nGenerated by moss"]
482
+ : ["commit-tree", treeSha, "-m", "Deploy site\n\nGenerated by moss"];
483
+ const orphan = await git(commitTreeArgs);
484
+ if (!orphan.success) throw new Error(`Failed to create gh-pages commit: ${sanitize(orphan.stderr, token)}`);
485
+ const orphanSha = orphan.stdout.trim();
486
+
487
+ // ── 5. Push gh-pages first (fast — user sees "Deployed!" quickly) ────
488
+ onProgress(25, "Pushing to GitHub...");
489
+ const push = await git(
490
+ [
491
+ "push", "--force", "--progress", pushUrl,
492
+ `${orphanSha}:refs/heads/gh-pages`,
493
+ ],
494
+ (line) => parsePushProgress(line, 25, 95, onProgress, token),
495
+ );
496
+ if (!push.success) throw new Error(`git push failed: ${sanitize(push.stderr, token)}`);
497
+
498
+ onProgress(100, "Deployed!");
499
+
500
+ // ── 6. Deferred source backup to main branch (non-fatal) ─────────────
501
+ // Stage the entire vault (may be slow on iCloud) and push source to main.
502
+ // This happens AFTER "Deployed!" so the user isn't waiting.
503
+ //
504
+ // Emit a phase change so the parent's heartbeat shows the right message
505
+ // ("Backing up source...") instead of repeating "Deployed!" for minutes
506
+ // while iCloud-synced `git add --all` of the whole vault grinds.
507
+ onProgress(100, "Backing up source...");
508
+ let sha = "";
509
+ try {
510
+ // Check for source files >100MB and append to .gitignore
511
+ const findResult = await executeBinary({
512
+ binaryPath: "find",
513
+ args: [
514
+ ".", "-not", "-path", "./.moss/*", "-not", "-path", "./.git/*",
515
+ "-type", "f", "-size", "+100M",
516
+ ],
517
+ workingDir: ".",
518
+ timeoutMs: 30_000,
519
+ env: {},
520
+ });
521
+
522
+ const largeSourceFiles = findResult.stdout
523
+ .split("\n")
524
+ .map((l) => l.trim())
525
+ .filter((l) => l.length > 0)
526
+ .map((l) => l.startsWith("./") ? l.slice(2) : l);
527
+
528
+ if (largeSourceFiles.length > 0) {
529
+ const escapedFiles = largeSourceFiles.map((f) => f.replace(/'/g, "'\\''")).join("\\n");
530
+ await executeBinary({
531
+ binaryPath: "sh",
532
+ args: ["-c", `printf "\\n${escapedFiles}\\n" >> .gitignore`],
533
+ workingDir: ".",
534
+ timeoutMs: 5_000,
535
+ env: {},
536
+ });
537
+
538
+ const fileList = largeSourceFiles
539
+ .map((f) => `&nbsp;&nbsp;${f}`)
540
+ .join("<br>");
541
+ await showToast({
542
+ variant: "warning",
543
+ message: `Skipped ${largeSourceFiles.length} file(s) exceeding 100 MB:<br>${fileList}`,
544
+ duration: 10_000,
545
+ });
546
+ }
547
+
548
+ await git(["add", "--all"]);
549
+ const diff = await git(["diff", "--cached", "--quiet"]);
550
+ if (!diff.success) {
551
+ // Has changes to commit
552
+ const commit = await git(["commit", "-m", "Deploy site\n\nGenerated by moss"]);
553
+ if (commit.success) {
554
+ const revParse = await git(["rev-parse", "--short", "HEAD"]);
555
+ sha = revParse.success ? revParse.stdout.trim() : "";
556
+ await git(["push", pushUrl, "HEAD:refs/heads/main"]);
557
+ }
558
+ } else {
559
+ // No new changes to commit, but push any existing unpushed commits.
560
+ // This handles the case where previous deploys committed but failed
561
+ // to push (e.g., network error), leaving local main ahead of remote.
562
+ const behind = await git(["rev-list", "--count", "origin/main..HEAD"]);
563
+ if (behind.success && parseInt(behind.stdout.trim(), 10) > 0) {
564
+ await git(["push", pushUrl, "HEAD:refs/heads/main"]);
565
+ }
566
+ }
567
+ } catch {
568
+ // Source backup is non-fatal — site is already deployed to gh-pages
569
+ console.warn("Source backup to main branch failed (non-fatal)");
570
+ }
571
+
572
+ return { commitSha: sha, orphanSha, treeChanged };
573
+ }
574
+
575
+ // ── Execute with corrupt-git recovery ──────────────────────────────────
576
+ // If the deploy fails due to corrupt local git objects (common with iCloud
577
+ // sync), wipe .git and retry once. Since we force-push, no history is needed.
578
+ try {
579
+ return await attemptDeploy();
580
+ } catch (err) {
581
+ const msg = err instanceof Error ? err.message : String(err);
582
+ if (!looksLikeCorruptGit(msg)) throw err;
583
+
584
+ // Corrupt git state detected — wipe and retry once
585
+ onProgress(0, "Recovering from corrupt git state...");
586
+ console.warn("Corrupt git detected, reinitializing .git");
587
+ await executeBinary({
588
+ binaryPath: "rm", args: ["-rf", ".git"],
589
+ workingDir: ".", timeoutMs: 10_000, env: {},
590
+ });
591
+ return await attemptDeploy();
592
+ }
593
+ }