| … | … | @@ -0,0 +1,150 @@ |
| 1 | # Partial Clone App Install |
| 2 | |
| 3 | ## Problem |
| 4 | |
| 5 | `pgh app install` downloads the ENTIRE release branch (~1.4 GB for hyper-tps) even though the client only needs one platform's artifacts (~566 MB). Federation sync also transfers full packs because orphan release commits share no object history with peers. |
| 6 | |
| 7 | Current flow: |
| 8 | ``` |
| 9 | git clone --depth 1 -b release punch://key/repo # downloads ALL blobs (all platforms) |
| 10 | ``` |
| 11 | |
| 12 | ## Solution |
| 13 | |
| 14 | Use git's partial clone + sparse checkout to download only what the current platform needs. |
| 15 | |
| 16 | New flow: |
| 17 | ``` |
| 18 | git clone --filter=blob:none --sparse --depth 1 -b release punch://key/repo # 9.8 MB (trees only) |
| 19 | cd repo |
| 20 | git sparse-checkout init --cone |
| 21 | git sparse-checkout set by-arch/{host}/app/{name} # fetches only this platform's blobs (~566 MB) |
| 22 | ``` |
| 23 | |
| 24 | ### Verified |
| 25 | |
| 26 | Tested 2026-04-07 on tabertron → stortron over punch:// P2P transport: |
| 27 | - `--filter=blob:none` works over git-remote-punch (transparent proxy) |
| 28 | - Clone with filter: 9.8 MB, ~2 seconds (vs 980 MB without filter) |
| 29 | - Sparse checkout of linux-x64: 566 MB, ~55 seconds |
| 30 | - All files present: index.js, hypertps binary, .pck, .so files, node_modules |
| 31 | |
| 32 | ### Server Requirement |
| 33 | |
| 34 | Bare repos must have partial clone enabled: |
| 35 | ```bash |
| 36 | git -C ~/git-repos/REPO.git config uploadpack.allowFilter true |
| 37 | git -C ~/git-repos/REPO.git config uploadpack.allowAnySHA1InWant true |
| 38 | ``` |
| 39 | |
| 40 | Without this, server responds "filtering not recognized" and falls back to full clone. |
| 41 | |
| 42 | ## Implementation |
| 43 | |
| 44 | ### 1. Enable partial clone on all bare repos |
| 45 | |
| 46 | **File:** `lib/repo-registry.js` — `ensurePostReceiveHook()` or new `ensureRepoConfig()` |
| 47 | |
| 48 | After scanning/creating a bare repo, set: |
| 49 | ```javascript |
| 50 | await execGit(repoPath, ['config', 'uploadpack.allowFilter', 'true']) |
| 51 | await execGit(repoPath, ['config', 'uploadpack.allowAnySHA1InWant', 'true']) |
| 52 | ``` |
| 53 | |
| 54 | ### 2. Update `pgh app install` git clone path |
| 55 | |
| 56 | **File:** `lib/cli/commands/app.js` — `appInstallGit()` |
| 57 | |
| 58 | Replace: |
| 59 | ```javascript |
| 60 | await execGitInDir(parentDir, [ |
| 61 | 'clone', '--depth', '1', '-b', 'release', cloneUrl, name |
| 62 | ]) |
| 63 | ``` |
| 64 | |
| 65 | With: |
| 66 | ```javascript |
| 67 | const host = detectHost() // e.g. 'linux-x64' |
| 68 | |
| 69 | // Step 1: partial clone (trees only, no blobs — ~10 MB) |
| 70 | await execGitInDir(parentDir, [ |
| 71 | 'clone', '--filter=blob:none', '--sparse', '--depth', '1', |
| 72 | '-b', 'release', cloneUrl, name |
| 73 | ]) |
| 74 | |
| 75 | // Step 2: sparse checkout (fetch only this platform's blobs) |
| 76 | await execGitInDir(installDir, ['sparse-checkout', 'init', '--cone']) |
| 77 | await execGitInDir(installDir, [ |
| 78 | 'sparse-checkout', 'set', |
| 79 | `by-arch/${host}/app/${repoName}` |
| 80 | ]) |
| 81 | ``` |
| 82 | |
| 83 | ### 3. Update `pgh app install` update path |
| 84 | |
| 85 | **File:** `lib/cli/commands/app.js` — existing git-installed update |
| 86 | |
| 87 | Replace: |
| 88 | ```javascript |
| 89 | await execGitInDir(installDir, ['fetch', 'origin', 'release', '--depth', '1']) |
| 90 | await execGitInDir(installDir, ['reset', '--hard', 'origin/release']) |
| 91 | ``` |
| 92 | |
| 93 | With: |
| 94 | ```javascript |
| 95 | await execGitInDir(installDir, ['fetch', '--filter=blob:none', 'origin', 'release', '--depth', '1']) |
| 96 | await execGitInDir(installDir, ['reset', '--hard', 'origin/release']) |
| 97 | // Sparse checkout config persists — only fetches blobs for configured paths |
| 98 | ``` |
| 99 | |
| 100 | ### 4. Update `finishInstall` launcher |
| 101 | |
| 102 | **File:** `lib/cli/commands/app.js` — `finishInstall()` |
| 103 | |
| 104 | Detect by-arch layout and set entry point accordingly: |
| 105 | ```javascript |
| 106 | const host = detectHost() |
| 107 | const byArchEntry = path.join(installDir, 'by-arch', host, 'app', name, pkg.main || 'index.js') |
| 108 | const rootEntry = path.join(installDir, pkg.main || 'index.js') |
| 109 | |
| 110 | const entry = existsSync(byArchEntry) ? byArchEntry : rootEntry |
| 111 | // Launcher: exec bare {entry} "$@" |
| 112 | ``` |
| 113 | |
| 114 | ### 5. Federation sync — enable partial clone for syncRepo |
| 115 | |
| 116 | **File:** `lib/p2p/sync/git-sync-client-manager.js` — `_syncRepo()` |
| 117 | |
| 118 | The federation `git fetch` should also use filters when the remote supports it: |
| 119 | ```javascript |
| 120 | const child = spawn('git', [ |
| 121 | '-C', repoPath, 'fetch', '--filter=blob:none', fetchUrl, |
| 122 | 'refs/*:refs/*', '--no-tags' |
| 123 | ]) |
| 124 | ``` |
| 125 | |
| 126 | This reduces federation sync bandwidth from full pack to tree-only deltas. Blobs are fetched on demand when clients request them via sparse checkout. |
| 127 | |
| 128 | ## Fallback |
| 129 | |
| 130 | If the server doesn't support `--filter` (old git or missing config), git silently falls back to full clone. No breakage. |
| 131 | |
| 132 | The P2P connectivity check (`git ls-remote` ping) runs before clone, so connection issues are caught early regardless of filter support. |
| 133 | |
| 134 | ## Size Comparison |
| 135 | |
| 136 | | Method | Download Size | Time | |
| 137 | |--------|-------------|------| |
| 138 | | Current: `git clone --depth 1` | 980 MB | ~4 min | |
| 139 | | Partial clone (trees only) | 9.8 MB | ~2 sec | |
| 140 | | + sparse checkout (linux-x64) | +566 MB | ~55 sec | |
| 141 | | **Total with partial clone** | **576 MB** | **~1 min** | |
| 142 | | Delta fetch (subsequent update) | ~few MB | ~seconds | |
| 143 | |
| 144 | ## Future: Proper Delta Updates |
| 145 | |
| 146 | Current release commits are orphans (fresh `git commit-tree` from temp index). This defeats git's delta compression — every deploy transfers full blobs even for unchanged files. |
| 147 | |
| 148 | Fix: use `git read-tree release` to load the previous release tree into the index before `git add -A`. Only changed files create new blobs. Tree objects reuse unchanged subtrees. This would make delta fetches (test 5 in p2p-fetch-size.js) transfer only the actual changes — a few MB instead of hundreds. |
| 149 | |
| 150 | **File:** `lib/ci/deploy-hook.js` — `_commitToReleaseBranch()` |