mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

refactor: stream pushes via git protocol splice instead of clone

+1762 -498
+1
nuxt.config.ts
··· 19 19 githubWebhookSecret: '', 20 20 cronSecret: '', 21 21 workerBudgetMs: '', 22 + maxPackBytes: '', 22 23 encryptionKey: '', 23 24 atprotoPrivateJwk: '', 24 25 sessionPassword: '',
-1
package.json
··· 49 49 "@octokit/auth-app": "^8.2.0", 50 50 "@octokit/webhooks-methods": "^6.0.0", 51 51 "drizzle-orm": "^0.45.2", 52 - "execa": "^9.6.1", 53 52 "nuxt": "^4.4.4", 54 53 "nuxt-og-image": "^6.4.11", 55 54 "rolldown": "^1.0.0-rc.18",
-94
pnpm-lock.yaml
··· 54 54 drizzle-orm: 55 55 specifier: ^0.45.2 56 56 version: 0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0) 57 - execa: 58 - specifier: ^9.6.1 59 - version: 9.6.1 60 57 nuxt: 61 58 specifier: ^4.4.4 62 59 version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.33)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(esbuild@0.28.0)(eslint@10.3.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(oxlint@1.61.0(oxlint-tsgolint@0.22.0))(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.4))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.4) ··· 2709 2706 cpu: [x64] 2710 2707 os: [win32] 2711 2708 2712 - '@sec-ant/readable-stream@0.4.1': 2713 - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} 2714 - 2715 2709 '@sidvind/better-ajv-errors@3.0.1': 2716 2710 resolution: {integrity: sha512-++1mEYIeozfnwWI9P1ECvOPoacy+CgDASrmGvXPMCcqgx0YUzB01vZ78uHdQ443V6sTY+e9MzHqmN9DOls02aw==} 2717 2711 engines: {node: '>= 16.14'} ··· 3851 3845 resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} 3852 3846 engines: {node: '>=16.17'} 3853 3847 3854 - execa@9.6.1: 3855 - resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} 3856 - engines: {node: ^18.19.0 || >=20.5.0} 3857 - 3858 3848 exsolve@1.0.8: 3859 3849 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 3860 3850 ··· 3917 3907 peerDependenciesMeta: 3918 3908 picomatch: 3919 3909 optional: true 3920 - 3921 - figures@6.1.0: 3922 - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} 3923 - engines: {node: '>=18'} 3924 3910 3925 3911 file-entry-cache@8.0.0: 3926 3912 resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} ··· 4011 3997 resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} 4012 3998 engines: {node: '>=16'} 4013 3999 4014 - get-stream@9.0.1: 4015 - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} 4016 - engines: {node: '>=18'} 4017 - 4018 4000 get-tsconfig@4.14.0: 4019 4001 resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} 4020 4002 ··· 4126 4108 resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} 4127 4109 engines: {node: '>=16.17.0'} 4128 4110 4129 - human-signals@8.0.1: 4130 - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} 4131 - engines: {node: '>=18.18.0'} 4132 - 4133 4111 ieee754@1.2.1: 4134 4112 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 4135 4113 ··· 4226 4204 resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} 4227 4205 engines: {node: '>=12'} 4228 4206 4229 - is-plain-obj@4.1.0: 4230 - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} 4231 - engines: {node: '>=12'} 4232 - 4233 4207 is-reference@1.2.1: 4234 4208 resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} 4235 4209 ··· 4240 4214 is-stream@3.0.0: 4241 4215 resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} 4242 4216 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 4243 - 4244 - is-stream@4.0.1: 4245 - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} 4246 - engines: {node: '>=18'} 4247 - 4248 - is-unicode-supported@2.1.0: 4249 - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} 4250 - engines: {node: '>=18'} 4251 4217 4252 4218 is-wsl@2.2.0: 4253 4219 resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} ··· 4818 4784 package-json-from-dist@1.0.1: 4819 4785 resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 4820 4786 4821 - parse-ms@4.0.0: 4822 - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} 4823 - engines: {node: '>=18'} 4824 - 4825 4787 parseurl@1.3.3: 4826 4788 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 4827 4789 engines: {node: '>= 0.8'} ··· 5085 5047 pretty-bytes@7.1.0: 5086 5048 resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} 5087 5049 engines: {node: '>=20'} 5088 - 5089 - pretty-ms@9.3.0: 5090 - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} 5091 - engines: {node: '>=18'} 5092 5050 5093 5051 process-nextick-args@2.0.1: 5094 5052 resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} ··· 5374 5332 resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} 5375 5333 engines: {node: '>=12'} 5376 5334 5377 - strip-final-newline@4.0.0: 5378 - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} 5379 - engines: {node: '>=18'} 5380 - 5381 5335 strip-literal@3.1.0: 5382 5336 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 5383 5337 ··· 5922 5876 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 5923 5877 engines: {node: '>=10'} 5924 5878 5925 - yoctocolors@2.1.2: 5926 - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} 5927 - engines: {node: '>=18'} 5928 - 5929 5879 youch-core@0.3.3: 5930 5880 resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} 5931 5881 ··· 8179 8129 '@rollup/rollup-win32-x64-msvc@4.60.2': 8180 8130 optional: true 8181 8131 8182 - '@sec-ant/readable-stream@0.4.1': {} 8183 - 8184 8132 '@sidvind/better-ajv-errors@3.0.1(ajv@8.20.0)': 8185 8133 dependencies: 8186 8134 ajv: 8.20.0 ··· 9324 9272 signal-exit: 4.1.0 9325 9273 strip-final-newline: 3.0.0 9326 9274 9327 - execa@9.6.1: 9328 - dependencies: 9329 - '@sindresorhus/merge-streams': 4.0.0 9330 - cross-spawn: 7.0.6 9331 - figures: 6.1.0 9332 - get-stream: 9.0.1 9333 - human-signals: 8.0.1 9334 - is-plain-obj: 4.1.0 9335 - is-stream: 4.0.1 9336 - npm-run-path: 6.0.0 9337 - pretty-ms: 9.3.0 9338 - signal-exit: 4.1.0 9339 - strip-final-newline: 4.0.0 9340 - yoctocolors: 2.1.2 9341 - 9342 9275 exsolve@1.0.8: {} 9343 9276 9344 9277 fake-indexeddb@6.2.5: {} ··· 9392 9325 fdir@6.5.0(picomatch@4.0.4): 9393 9326 optionalDependencies: 9394 9327 picomatch: 4.0.4 9395 - 9396 - figures@6.1.0: 9397 - dependencies: 9398 - is-unicode-supported: 2.1.0 9399 9328 9400 9329 file-entry-cache@8.0.0: 9401 9330 dependencies: ··· 9502 9431 9503 9432 get-stream@8.0.1: {} 9504 9433 9505 - get-stream@9.0.1: 9506 - dependencies: 9507 - '@sec-ant/readable-stream': 0.4.1 9508 - is-stream: 4.0.1 9509 - 9510 9434 get-tsconfig@4.14.0: 9511 9435 dependencies: 9512 9436 resolve-pkg-maps: 1.0.0 ··· 9632 9556 9633 9557 human-signals@5.0.0: {} 9634 9558 9635 - human-signals@8.0.1: {} 9636 - 9637 9559 ieee754@1.2.1: {} 9638 9560 9639 9561 ignore@5.3.2: {} ··· 9750 9672 9751 9673 is-path-inside@4.0.0: {} 9752 9674 9753 - is-plain-obj@4.1.0: {} 9754 - 9755 9675 is-reference@1.2.1: 9756 9676 dependencies: 9757 9677 '@types/estree': 1.0.8 ··· 9760 9680 9761 9681 is-stream@3.0.0: {} 9762 9682 9763 - is-stream@4.0.1: {} 9764 - 9765 - is-unicode-supported@2.1.0: {} 9766 - 9767 9683 is-wsl@2.2.0: 9768 9684 dependencies: 9769 9685 is-docker: 2.2.1 ··· 10642 10558 10643 10559 package-json-from-dist@1.0.1: {} 10644 10560 10645 - parse-ms@4.0.0: {} 10646 - 10647 10561 parseurl@1.3.3: {} 10648 10562 10649 10563 path-browserify@1.0.1: {} ··· 10876 10790 10877 10791 pretty-bytes@7.1.0: {} 10878 10792 10879 - pretty-ms@9.3.0: 10880 - dependencies: 10881 - parse-ms: 4.0.0 10882 - 10883 10793 process-nextick-args@2.0.1: {} 10884 10794 10885 10795 process@0.11.10: {} ··· 11218 11128 ansi-regex: 6.2.2 11219 11129 11220 11130 strip-final-newline@3.0.0: {} 11221 - 11222 - strip-final-newline@4.0.0: {} 11223 11131 11224 11132 strip-literal@3.1.0: 11225 11133 dependencies: ··· 11779 11687 yargs-parser: 22.0.0 11780 11688 11781 11689 yocto-queue@0.1.0: {} 11782 - 11783 - yoctocolors@2.1.2: {} 11784 11690 11785 11691 youch-core@0.3.3: 11786 11692 dependencies:
+76
server/utils/git-wire/errors.ts
··· 1 + /** 2 + * Typed failures from the git wire splice. `reason` drives the worker's 3 + * retry-vs-give-up decision in `sync-push.ts` / `sync-ref.ts`. 4 + * 5 + * - repo-gone the knot no longer has the repo (or our key was revoked 6 + * such that it reports "not found"); terminal, mark error. 7 + * - auth-rejected ssh public-key auth refused; terminal, mark error. 8 + * - stale-old-sha our compare-and-swap lost: the knot's ref moved between 9 + * reading its advertisement and sending the command, or a 10 + * concurrent worker won. Transient; retry re-reads the tip. 11 + * - too-big the pack exceeded the configured byte cap; terminal, it 12 + * will never fit. 13 + * - other anything unclassified; transient, let the queue retry. 14 + */ 15 + export type WireFailureReason 16 + = | 'repo-gone' 17 + | 'auth-rejected' 18 + | 'stale-old-sha' 19 + | 'too-big' 20 + | 'other' 21 + 22 + export class WireError extends Error { 23 + constructor(message: string) { 24 + super(message) 25 + this.name = 'WireError' 26 + } 27 + } 28 + 29 + export class RemoteRejectedError extends WireError { 30 + constructor(message: string, public readonly reason: WireFailureReason) { 31 + super(message) 32 + this.name = 'RemoteRejectedError' 33 + } 34 + } 35 + 36 + /** 37 + * Classify ssh / sshd / knot stderr (the child process's stderr band, since 38 + * we deliberately do not request side-band multiplexing). Returns null when 39 + * nothing matches so the caller can fall back to a generic transient error. 40 + */ 41 + export function classifySshStderr(stderr: string): RemoteRejectedError | null { 42 + const lc = stderr.toLowerCase() 43 + if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) { 44 + return new RemoteRejectedError(stderr.trim(), 'repo-gone') 45 + } 46 + if (lc.includes('permission denied') || (lc.includes('publickey') && lc.includes('denied'))) { 47 + return new RemoteRejectedError(stderr.trim(), 'auth-rejected') 48 + } 49 + return null 50 + } 51 + 52 + /** 53 + * Classify a receive-pack `ng <ref> <reason>` rejection. Any rejection that 54 + * means "the ref's current value is not what you said" maps to stale-old-sha 55 + * so the worker retries against a fresh advertisement. git phrases this two 56 + * ways: `non-fast-forward` / `stale info` when updating a moved ref, and 57 + * `failed to update ref` (stderr: "reference already exists") when our command 58 + * claimed a create but the ref already exists. 59 + */ 60 + export function classifyNgReason(reason: string): RemoteRejectedError { 61 + const lc = reason.toLowerCase() 62 + if ( 63 + lc.includes('non-fast-forward') 64 + || lc.includes('fetch first') 65 + || lc.includes('stale info') 66 + || lc.includes('not a fast forward') 67 + || lc.includes('failed to update ref') 68 + || lc.includes('reference already exists') 69 + ) { 70 + return new RemoteRejectedError(reason.trim(), 'stale-old-sha') 71 + } 72 + if (lc.includes('not found') || lc.includes('does not exist')) { 73 + return new RemoteRejectedError(reason.trim(), 'repo-gone') 74 + } 75 + return new RemoteRejectedError(reason.trim(), 'other') 76 + }
+158
server/utils/git-wire/pkt-line.ts
··· 1 + /** 2 + * Git pkt-line framing (protocol v0). A pkt-line is a 4-hex-digit length 3 + * prefix (counting the 4 prefix bytes themselves) followed by that many bytes 4 + * of payload. `0000` is the flush-pkt: a section delimiter carrying no 5 + * payload. Lengths `0001`-`0003` are reserved and invalid in v0. 6 + * 7 + * See `Documentation/gitprotocol-common.txt` in git.git. 8 + */ 9 + 10 + const FLUSH = '0000' 11 + const MAX_DATA = 65516 12 + 13 + export const flushPkt: Buffer = Buffer.from(FLUSH, 'ascii') 14 + 15 + /** 16 + * Frame a payload as a pkt-line. Accepts a string (encoded UTF-8) or raw 17 + * bytes. Does NOT append a trailing newline; callers that want the 18 + * conventional `\n` (command and capability lines) must include it. 19 + */ 20 + export function encodePktLine(data: string | Buffer): Buffer { 21 + const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data 22 + if (payload.length > MAX_DATA) { 23 + throw new RangeError(`pkt-line payload too large: ${payload.length} > ${MAX_DATA}`) 24 + } 25 + const len = payload.length + 4 26 + const prefix = len.toString(16).padStart(4, '0') 27 + return Buffer.concat([Buffer.from(prefix, 'ascii'), payload]) 28 + } 29 + 30 + export type PktLine = 31 + | { type: 'line', data: Buffer } 32 + | { type: 'flush' } 33 + 34 + /** 35 + * Incrementally decode pkt-lines from a byte source, then hand back whatever 36 + * raw bytes follow the section we consumed. 37 + * 38 + * The git smart protocol switches from pkt-line framing to a raw packfile 39 + * stream mid-response (after the NAK/ACK line on a fetch). A naive reader that 40 + * buffers ahead would swallow the first chunk of the pack, so this reader 41 + * tracks exactly how much it has consumed and exposes the remainder via 42 + * `remaining()`. 43 + */ 44 + export class PktLineReader { 45 + private buf: Buffer = Buffer.alloc(0) 46 + private done = false 47 + private readonly iter: AsyncIterator<Buffer> 48 + 49 + constructor(source: AsyncIterable<Buffer | Uint8Array>) { 50 + this.iter = (async function* normalise() { 51 + for await (const chunk of source) { 52 + yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) 53 + } 54 + })() 55 + } 56 + 57 + /** 58 + * Read the next pkt-line, or `null` at end of stream. A flush-pkt is 59 + * returned as `{ type: 'flush' }` rather than ending iteration; the wire 60 + * protocol uses multiple flush-delimited sections per stream. 61 + */ 62 + async next(): Promise<PktLine | null> { 63 + while (this.buf.length < 4) { 64 + // eslint-disable-next-line no-await-in-loop -- each fill extends a shared buffer the next iteration inspects; the reads are inherently sequential 65 + if (!(await this.fill())) { 66 + if (this.buf.length === 0) return null 67 + throw new Error('unexpected end of stream: truncated pkt-line length') 68 + } 69 + } 70 + 71 + const len = Number.parseInt(this.buf.toString('ascii', 0, 4), 16) 72 + if (Number.isNaN(len)) { 73 + throw new Error(`invalid pkt-line length: ${JSON.stringify(this.buf.toString('ascii', 0, 4))}`) 74 + } 75 + if (len === 0) { 76 + this.buf = this.buf.subarray(4) 77 + return { type: 'flush' } 78 + } 79 + if (len < 4) { 80 + throw new Error(`reserved pkt-line length ${len} is invalid in protocol v0`) 81 + } 82 + 83 + while (this.buf.length < len) { 84 + // eslint-disable-next-line no-await-in-loop -- sequential read; see next() 85 + if (!(await this.fill())) { 86 + throw new Error(`unexpected end of stream: pkt-line wanted ${len} bytes, had ${this.buf.length}`) 87 + } 88 + } 89 + 90 + const data = this.buf.subarray(4, len) 91 + this.buf = this.buf.subarray(len) 92 + return { type: 'line', data } 93 + } 94 + 95 + /** 96 + * Read pkt-lines up to and including the next flush-pkt, returning the line 97 + * payloads (flush excluded). Returns `null` if the stream ends before any 98 + * line is read. 99 + */ 100 + async readUntilFlush(): Promise<Buffer[] | null> { 101 + const lines: Buffer[] = [] 102 + for (;;) { 103 + // eslint-disable-next-line no-await-in-loop -- sequential read; see next() 104 + const pkt = await this.next() 105 + if (pkt === null) return lines.length > 0 ? lines : null 106 + if (pkt.type === 'flush') return lines 107 + lines.push(pkt.data) 108 + } 109 + } 110 + 111 + /** 112 + * The bytes already buffered past the last consumed pkt-line. Used to seed 113 + * the raw packfile stream once negotiation framing ends. 114 + */ 115 + buffered(): Buffer { 116 + return this.buf 117 + } 118 + 119 + /** 120 + * Yield the remainder of the source as a raw byte stream: first any bytes 121 + * already buffered, then the rest of the underlying iterator verbatim. After 122 + * calling this, do not call `next()` again. 123 + */ 124 + async *remaining(): AsyncGenerator<Buffer> { 125 + if (this.buf.length > 0) { 126 + yield this.buf 127 + this.buf = Buffer.alloc(0) 128 + } 129 + if (this.done) return 130 + for (;;) { 131 + // eslint-disable-next-line no-await-in-loop -- sequential drain of the source iterator 132 + const { value, done } = await this.iter.next() 133 + if (done) { 134 + this.done = true 135 + return 136 + } 137 + yield value 138 + } 139 + } 140 + 141 + private async fill(): Promise<boolean> { 142 + if (this.done) return false 143 + const { value, done } = await this.iter.next() 144 + if (done) { 145 + this.done = true 146 + return false 147 + } 148 + this.buf = this.buf.length === 0 ? value : Buffer.concat([this.buf, value]) 149 + return true 150 + } 151 + } 152 + 153 + /** Decode a single line's payload as a UTF-8 string with any trailing `\n` removed. */ 154 + export function lineToString(data: Buffer): string { 155 + return data.length > 0 && data[data.length - 1] === 0x0A 156 + ? data.toString('utf8', 0, data.length - 1) 157 + : data.toString('utf8') 158 + }
+210
server/utils/git-wire/receive-pack.ts
··· 1 + import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' 2 + import { Readable } from 'node:stream' 3 + import { classifyNgReason, classifySshStderr, RemoteRejectedError, WireError } from './errors' 4 + import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line' 5 + import { type Advertisement, parseAdvertisement, ZERO_SHA } from './refs' 6 + 7 + const AGENT = 'synchub.to' 8 + const STDERR_CAP = 16 * 1024 9 + /** 10 + * Whole-session budget. receive-pack blocks indefinitely waiting for commands, 11 + * so without this a knot that accepts the connection but stalls mid-protocol 12 + * would hang a worker until its job lease expires. On expiry we SIGKILL the 13 + * child; the in-flight read sees the stream end and throws, surfacing as a 14 + * transient failure the queue retries. 15 + */ 16 + const SESSION_TIMEOUT_MS = 120_000 17 + 18 + export interface RefUpdate { 19 + ref: string 20 + /** Current value on the knot, or the zero SHA to create. The compare-and-swap. */ 21 + old: string 22 + /** New value, or the zero SHA to delete. */ 23 + next: string 24 + } 25 + 26 + /** 27 + * A spawned process exposing `git-receive-pack`'s stdio. The default factory 28 + * runs ssh to the knot; tests inject a factory that spawns the binary against 29 + * a local bare repo, so the stdio protocol is identical either way. 30 + */ 31 + export interface ReceivePackProcess { 32 + stdin: NodeJS.WritableStream 33 + stdout: AsyncIterable<Buffer> 34 + /** Last bytes of stderr, for diagnostics (we don't request side-band). */ 35 + stderr(): string 36 + kill(): void 37 + /** Resolves with the exit code once the process ends. */ 38 + done: Promise<number | null> 39 + } 40 + 41 + export type ReceivePackFactory = () => ReceivePackProcess 42 + 43 + export interface SshTarget { 44 + host: string 45 + port?: number 46 + repoPath: string 47 + sshArgs: string[] 48 + } 49 + 50 + /** Default transport: ssh to the knot and invoke its `git-receive-pack`. */ 51 + export function sshReceivePackFactory(target: SshTarget): ReceivePackFactory { 52 + return () => { 53 + const portArgs = target.port ? ['-p', String(target.port)] : [] 54 + // ssh:// transports invoke the remote command with the path including its 55 + // leading slash, single-quoted. The knot resolves repos by that path. 56 + const remoteCmd = `git-receive-pack '${target.repoPath}'` 57 + const child = spawn('ssh', [...target.sshArgs, ...portArgs, `git@${target.host}`, remoteCmd], { 58 + stdio: ['pipe', 'pipe', 'pipe'], 59 + }) 60 + return wrapChild(child) 61 + } 62 + } 63 + 64 + function wrapChild(child: ChildProcessWithoutNullStreams): ReceivePackProcess { 65 + let stderrBuf = Buffer.alloc(0) 66 + child.stderr.on('data', (chunk: Buffer) => { 67 + stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP) 68 + }) 69 + const done = new Promise<number | null>(resolve => child.on('close', resolve)) 70 + return { 71 + stdin: child.stdin, 72 + stdout: child.stdout, 73 + stderr: () => stderrBuf.toString('utf8'), 74 + kill: () => child.kill('SIGKILL'), 75 + done, 76 + } 77 + } 78 + 79 + /** 80 + * An open receive-pack session. Read `tips` after construction to learn the 81 + * knot's current refs (needed as haves and as the compare-and-swap base), 82 + * then call `push` once with the commands and packfile. 83 + */ 84 + export class ReceivePackSession { 85 + readonly tips: Map<string, string> 86 + readonly capabilities: Set<string> 87 + 88 + private readonly watchdog: NodeJS.Timeout 89 + 90 + private constructor( 91 + private readonly proc: ReceivePackProcess, 92 + private readonly reader: PktLineReader, 93 + adv: Advertisement, 94 + watchdog: NodeJS.Timeout, 95 + ) { 96 + this.tips = adv.refs 97 + this.capabilities = adv.capabilities 98 + this.watchdog = watchdog 99 + } 100 + 101 + /** Open the session and read the advertisement. */ 102 + static async open(factory: ReceivePackFactory, timeoutMs = SESSION_TIMEOUT_MS): Promise<ReceivePackSession> { 103 + const proc = factory() 104 + const watchdog = setTimeout(() => proc.kill(), timeoutMs) 105 + try { 106 + const reader = new PktLineReader(proc.stdout) 107 + const advLines = await reader.readUntilFlush() 108 + if (advLines === null) { 109 + const err = classifySshStderr(proc.stderr()) 110 + throw err ?? new WireError(`receive-pack: no advertisement (stderr: ${proc.stderr().trim() || 'empty'})`) 111 + } 112 + const adv = parseAdvertisement(advLines) 113 + assertCapabilities(adv) 114 + return new ReceivePackSession(proc, reader, adv, watchdog) 115 + } 116 + catch (err) { 117 + clearTimeout(watchdog) 118 + proc.kill() 119 + await proc.done.catch(() => null) 120 + throw err 121 + } 122 + } 123 + 124 + /** 125 + * Send the ref update commands plus (for non-deletions) the packfile, then 126 + * read and validate report-status. The packfile is streamed straight from 127 + * `packStream` into stdin; nothing is buffered. Pass `null` for pure 128 + * deletions. 129 + */ 130 + async push(updates: RefUpdate[], packStream: AsyncIterable<Buffer> | null): Promise<void> { 131 + if (updates.length === 0) throw new WireError('receive-pack: no updates') 132 + try { 133 + await writeAll(this.proc.stdin, buildCommandList(updates)) 134 + if (packStream) await pipePack(this.proc.stdin, packStream) 135 + else this.proc.stdin.end() 136 + 137 + const report = await this.reader.readUntilFlush() 138 + parseReportStatus(report ?? [], updates, this.proc.stderr()) 139 + } 140 + finally { 141 + clearTimeout(this.watchdog) 142 + this.proc.kill() 143 + await this.proc.done.catch(() => null) 144 + } 145 + } 146 + 147 + /** Close the session without pushing (advertisement-only use). */ 148 + async close(): Promise<void> { 149 + clearTimeout(this.watchdog) 150 + this.proc.stdin.end() 151 + this.proc.kill() 152 + await this.proc.done.catch(() => null) 153 + } 154 + } 155 + 156 + function assertCapabilities(adv: Advertisement): void { 157 + if (!adv.capabilities.has('report-status')) { 158 + throw new WireError('knot receive-pack does not advertise report-status') 159 + } 160 + } 161 + 162 + function buildCommandList(updates: RefUpdate[]): Buffer { 163 + const parts: Buffer[] = [] 164 + updates.forEach((u, i) => { 165 + const caps = i === 0 ? `\0report-status agent=${AGENT}/1` : '' 166 + parts.push(encodePktLine(`${u.old} ${u.next} ${u.ref}${caps}\n`)) 167 + }) 168 + parts.push(flushPkt) 169 + return Buffer.concat(parts) 170 + } 171 + 172 + function parseReportStatus(lines: Buffer[], updates: RefUpdate[], stderr: string): void { 173 + if (lines.length === 0) { 174 + const err = classifySshStderr(stderr) 175 + throw err ?? new WireError(`receive-pack: empty report-status (stderr: ${stderr.trim() || 'empty'})`) 176 + } 177 + const unpack = lineToString(lines[0]!) 178 + if (unpack !== 'unpack ok') { 179 + throw new WireError(`receive-pack: ${unpack}`) 180 + } 181 + for (const raw of lines.slice(1)) { 182 + const line = lineToString(raw) 183 + if (line.startsWith('ng ')) { 184 + // `ng <ref> <reason>` 185 + const rest = line.slice(3) 186 + const sp = rest.indexOf(' ') 187 + const reason = sp === -1 ? rest : rest.slice(sp + 1) 188 + throw classifyNgReason(reason) 189 + } 190 + } 191 + } 192 + 193 + async function writeAll(stream: NodeJS.WritableStream, data: Buffer): Promise<void> { 194 + await new Promise<void>((resolve, reject) => { 195 + stream.write(data, err => (err ? reject(err) : resolve())) 196 + }) 197 + } 198 + 199 + async function pipePack(stdin: NodeJS.WritableStream, packStream: AsyncIterable<Buffer>): Promise<void> { 200 + const src = Readable.from(packStream) 201 + await new Promise<void>((resolve, reject) => { 202 + src.on('error', reject) 203 + stdin.on('error', reject) 204 + src.pipe(stdin, { end: true }) 205 + stdin.on('finish', resolve) 206 + stdin.on('close', resolve) 207 + }) 208 + } 209 + 210 + export { RemoteRejectedError, ZERO_SHA }
+69
server/utils/git-wire/refs.ts
··· 1 + import { lineToString } from './pkt-line' 2 + 3 + const ZERO_SHA = '0000000000000000000000000000000000000000' 4 + 5 + export interface Advertisement { 6 + /** Ref name -> object SHA (unpeeled). For annotated tags this is the tag object. */ 7 + refs: Map<string, string> 8 + /** For annotated tags, the `<tag>^{}` peeled line: ref name -> commit SHA. */ 9 + peeled: Map<string, string> 10 + capabilities: Set<string> 11 + } 12 + 13 + /** 14 + * Parse a git ref advertisement (protocol v0) from a list of pkt-line 15 + * payloads (flush-pkts already stripped by the reader). 16 + * 17 + * Handles three shapes that occur in practice: 18 + * - smart-HTTP prelude: a leading `# service=git-upload-pack` line, which 19 + * the ssh transport omits; 20 + * - a populated repo: `<sha> <refname>\0<caps>` on the first ref line, 21 + * `<sha> <refname>` thereafter, with `<sha> <refname>^{}` peeled lines 22 + * for annotated tags; 23 + * - an empty repo: a single `<zero-sha> capabilities^{}\0<caps>` line that 24 + * carries capabilities but advertises no usable ref. 25 + */ 26 + export function parseAdvertisement(lines: Buffer[]): Advertisement { 27 + const refs = new Map<string, string>() 28 + const peeled = new Map<string, string>() 29 + const capabilities = new Set<string>() 30 + 31 + let first = true 32 + for (const raw of lines) { 33 + const line = lineToString(raw) 34 + if (line.startsWith('# service=')) continue 35 + 36 + let sha: string 37 + let rest: string 38 + if (first) { 39 + const nul = line.indexOf('\0') 40 + const head = nul === -1 ? line : line.slice(0, nul) 41 + const caps = nul === -1 ? '' : line.slice(nul + 1) 42 + for (const cap of caps.split(' ')) { 43 + if (cap) capabilities.add(cap) 44 + } 45 + first = false 46 + const sp = head.indexOf(' ') 47 + sha = head.slice(0, sp) 48 + rest = head.slice(sp + 1) 49 + // Empty-repo sentinel: zero SHA + the literal "capabilities^{}" name. 50 + if (sha === ZERO_SHA && rest === 'capabilities^{}') continue 51 + } 52 + else { 53 + const sp = line.indexOf(' ') 54 + sha = line.slice(0, sp) 55 + rest = line.slice(sp + 1) 56 + } 57 + 58 + if (rest.endsWith('^{}')) { 59 + peeled.set(rest.slice(0, -3), sha) 60 + } 61 + else { 62 + refs.set(rest, sha) 63 + } 64 + } 65 + 66 + return { refs, peeled, capabilities } 67 + } 68 + 69 + export { ZERO_SHA }
+134
server/utils/git-wire/upload-pack.ts
··· 1 + import { Buffer } from 'node:buffer' 2 + import { RemoteRejectedError, WireError } from './errors' 3 + import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line' 4 + import { type Advertisement, parseAdvertisement } from './refs' 5 + 6 + const AGENT = 'synchub.to' 7 + const ADVERTISEMENT_TIMEOUT_MS = 30_000 8 + 9 + function repoUrl(repoFullName: string): string { 10 + return `https://github.com/${repoFullName}.git` 11 + } 12 + 13 + function authHeader(token: string): string { 14 + return `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` 15 + } 16 + 17 + async function* streamBytes(body: ReadableStream<Uint8Array>): AsyncGenerator<Buffer> { 18 + const reader = body.getReader() 19 + try { 20 + for (;;) { 21 + // eslint-disable-next-line no-await-in-loop -- sequential drain of the response body 22 + const { value, done } = await reader.read() 23 + if (done) return 24 + if (value) yield Buffer.from(value) 25 + } 26 + } 27 + finally { 28 + reader.releaseLock() 29 + } 30 + } 31 + 32 + /** 33 + * Fetch GitHub's `git-upload-pack` ref advertisement over smart HTTP. We need 34 + * this both to resolve a ref name to a SHA (create-ref path) and, more 35 + * generally, to learn the capability set before negotiating. 36 + */ 37 + export async function fetchAdvertisement(repoFullName: string, token: string): Promise<Advertisement> { 38 + const url = `${repoUrl(repoFullName)}/info/refs?service=git-upload-pack` 39 + const res = await fetch(url, { 40 + headers: { 41 + Authorization: authHeader(token), 42 + // Pin protocol v0; v2 would frame the advertisement differently. 43 + 'Git-Protocol': 'version=0', 44 + }, 45 + signal: AbortSignal.timeout(ADVERTISEMENT_TIMEOUT_MS), 46 + }) 47 + if (!res.ok || !res.body) { 48 + throw new WireError(`github info/refs failed: ${res.status} ${res.statusText}`) 49 + } 50 + const reader = new PktLineReader(streamBytes(res.body)) 51 + const lines = await reader.readUntilFlush() 52 + // The first flush ends the `# service` banner; the advertisement follows. 53 + const adv = await reader.readUntilFlush() 54 + return parseAdvertisement([...(lines ?? []), ...(adv ?? [])]) 55 + } 56 + 57 + export interface FetchPackOptions { 58 + repoFullName: string 59 + token: string 60 + /** SHA we want fetched. Requires GitHub's allow-reachable-sha1-in-want. */ 61 + want: string 62 + /** Knot ref tips to advertise as haves so GitHub sends a thin delta. */ 63 + haves: string[] 64 + /** Abort and throw `too-big` once the pack exceeds this many bytes. */ 65 + maxBytes: number 66 + } 67 + 68 + export interface FetchPackResult { 69 + /** Raw packfile bytes. Pipe straight into receive-pack; do not buffer. */ 70 + pack: AsyncGenerator<Buffer> 71 + } 72 + 73 + /** 74 + * Negotiate a thin pack from GitHub for `want`, advertising `haves` so the 75 + * server deltas against objects the knot already holds. Returns a streaming 76 + * generator of the raw packfile bytes; the caller pipes them into 77 + * receive-pack and never materialises them. 78 + * 79 + * Protocol v0, no side-band: after the single NAK/ACK pkt-line the response 80 + * body is the raw packfile to EOF, which is exactly what we forward. 81 + */ 82 + export async function fetchPack(opts: FetchPackOptions): Promise<FetchPackResult> { 83 + const { repoFullName, token, want, haves, maxBytes } = opts 84 + 85 + const wantLine = `want ${want} thin-pack ofs-delta agent=${AGENT}/1\n` 86 + const body: Buffer[] = [encodePktLine(wantLine), flushPkt] 87 + for (const have of haves) { 88 + body.push(encodePktLine(`have ${have}\n`)) 89 + } 90 + body.push(encodePktLine('done\n')) 91 + 92 + const res = await fetch(`${repoUrl(repoFullName)}/git-upload-pack`, { 93 + method: 'POST', 94 + headers: { 95 + Authorization: authHeader(token), 96 + 'Content-Type': 'application/x-git-upload-pack-request', 97 + 'Accept': 'application/x-git-upload-pack-result', 98 + 'Git-Protocol': 'version=0', 99 + }, 100 + body: Buffer.concat(body), 101 + }) 102 + if (!res.ok || !res.body) { 103 + throw new WireError(`github git-upload-pack failed: ${res.status} ${res.statusText}`) 104 + } 105 + 106 + const reader = new PktLineReader(streamBytes(res.body)) 107 + // Read the negotiation result: one ACK/NAK line, or an ERR line on failure. 108 + const ack = await reader.next() 109 + if (ack === null || ack.type === 'flush') { 110 + throw new WireError('github git-upload-pack: empty negotiation response') 111 + } 112 + const ackStr = lineToString(ack.data) 113 + if (ackStr.startsWith('ERR ')) { 114 + // `ERR upload-pack: not our ref` is a propagation race on GitHub's side; 115 + // surface as a plain WireError so the queue retries with backoff. 116 + throw new WireError(`github git-upload-pack: ${ackStr.slice(4)}`) 117 + } 118 + if (!ackStr.startsWith('ACK') && !ackStr.startsWith('NAK')) { 119 + throw new WireError(`github git-upload-pack: unexpected negotiation line ${JSON.stringify(ackStr)}`) 120 + } 121 + 122 + async function* capped(): AsyncGenerator<Buffer> { 123 + let total = 0 124 + for await (const chunk of reader.remaining()) { 125 + total += chunk.length 126 + if (total > maxBytes) { 127 + throw new RemoteRejectedError(`pack exceeded ${maxBytes} bytes`, 'too-big') 128 + } 129 + yield chunk 130 + } 131 + } 132 + 133 + return { pack: capped() } 134 + }
-55
server/utils/git.ts
··· 1 - import { execa, type Options } from 'execa' 2 - 3 - /** 4 - * Thin wrapper over `execa` for invoking the system `git` binary with 5 - * predictable defaults. 6 - * 7 - * - Forces non-interactive mode so a misconfigured ssh setup never hangs 8 - * waiting for a passphrase or `yes/no` prompt. 9 - * - Captures stderr so callers can produce useful error messages. 10 - * - Adds a default 60s timeout; callers can override via `options.timeout`. 11 - */ 12 - export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> { 13 - const result = await execa('git', args, { 14 - timeout: 60_000, 15 - ...options, 16 - env: { 17 - // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0` 18 - // makes git fail rather than hang if it would otherwise ask for input 19 - // (e.g. credentials). 20 - GIT_TERMINAL_PROMPT: '0', 21 - // Don't pick up the running user's ssh config / known_hosts. The caller 22 - // supplies a complete GIT_SSH_COMMAND for ssh transports. 23 - GIT_CONFIG_NOSYSTEM: '1', 24 - ...options.env, 25 - }, 26 - // Buffer (default) is fine for small operations; for very large fetches 27 - // we'd want to stream stderr instead. 28 - reject: true, 29 - all: true, 30 - }) 31 - return { stdout: String(result.stdout), stderr: String(result.stderr) } 32 - } 33 - 34 - /** 35 - * Recognised remote rejection patterns from the knot when a repo no longer 36 - * exists or our key has been revoked. Surfaces as a typed error so the 37 - * worker can mark the mapping as terminally failed rather than retry forever. 38 - */ 39 - export class RemoteRejectedPushError extends Error { 40 - constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') { 41 - super(message) 42 - this.name = 'RemoteRejectedPushError' 43 - } 44 - } 45 - 46 - export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null { 47 - const lc = stderr.toLowerCase() 48 - if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) { 49 - return new RemoteRejectedPushError(stderr.trim(), 'repo-gone') 50 - } 51 - if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) { 52 - return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected') 53 - } 54 - return null 55 - }
+152
server/utils/splice.ts
··· 1 + import { 2 + type ReceivePackFactory, 3 + ReceivePackSession, 4 + type RefUpdate, 5 + sshReceivePackFactory, 6 + } from './git-wire/receive-pack' 7 + import { ZERO_SHA } from './git-wire/refs' 8 + import { fetchAdvertisement, fetchPack } from './git-wire/upload-pack' 9 + import { loadSshArgsForInstall } from './ssh-cmd' 10 + import { sshEndpointForKnot } from './sync-push-host' 11 + 12 + const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024 13 + /** Cap haves so a repo with thousands of refs can't bloat the negotiation. */ 14 + const MAX_HAVES = 256 15 + 16 + function maxPackBytes(): number { 17 + const raw = process.env.NUXT_MAX_PACK_BYTES 18 + if (!raw) return DEFAULT_MAX_PACK_BYTES 19 + const n = Number.parseInt(raw, 10) 20 + return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n 21 + } 22 + 23 + async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<{ 24 + factory: ReceivePackFactory 25 + cleanup: () => void 26 + }> { 27 + const { args, cleanup } = await loadSshArgsForInstall(installationId) 28 + const { host, port } = sshEndpointForKnot(knot) 29 + // ssh:// path form: leading slash, the knot resolves the repo by DID. 30 + const factory = sshReceivePackFactory({ host, port, repoPath: `/${repoDid}`, sshArgs: args }) 31 + return { factory, cleanup } 32 + } 33 + 34 + export interface SplicePushParams { 35 + installationId: number 36 + repoFullName: string 37 + knot: string 38 + repoDid: string 39 + /** Fully-qualified ref, e.g. `refs/heads/main`. */ 40 + ref: string 41 + /** The SHA to land on the knot. */ 42 + want: string 43 + /** GitHub installation token authorising the fetch. */ 44 + token: string 45 + } 46 + 47 + export interface SplicePushResult { 48 + status: 'synced' | 'already-synced' 49 + sha: string 50 + } 51 + 52 + /** 53 + * Stream a single ref update from GitHub to the knot without materialising a 54 + * repository: 55 + * 56 + * 1. open receive-pack, read the knot's tips; 57 + * 2. if the knot's tip for `ref` already equals `want`, no-op; 58 + * 3. fetch a thin pack from GitHub with the knot's tips as haves; 59 + * 4. send the compare-and-swap command and pipe the pack straight through; 60 + * 5. read report-status. 61 + * 62 + * Steps 1 and 3 share one ssh session: it sits idle for the duration of the 63 + * GitHub round-trip (receive-pack waits indefinitely for commands), which 64 + * keeps the knot's advertised tip as the authoritative compare-and-swap base. 65 + */ 66 + export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> { 67 + const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid) 68 + try { 69 + return await runSplice(factory, params) 70 + } 71 + finally { 72 + cleanup() 73 + } 74 + } 75 + 76 + /** The fetch + push exchange over an open session. Split out for the wire test. */ 77 + export async function runSplice( 78 + factory: ReceivePackFactory, 79 + params: { repoFullName: string, ref: string, want: string, token: string }, 80 + ): Promise<SplicePushResult> { 81 + const session = await ReceivePackSession.open(factory) 82 + let pushStarted = false 83 + try { 84 + const old = session.tips.get(params.ref) ?? ZERO_SHA 85 + if (old === params.want) { 86 + await session.close() 87 + return { status: 'already-synced', sha: params.want } 88 + } 89 + 90 + const haves = [...new Set(session.tips.values())] 91 + .filter(sha => sha !== ZERO_SHA) 92 + .slice(0, MAX_HAVES) 93 + 94 + const { pack } = await fetchPack({ 95 + repoFullName: params.repoFullName, 96 + token: params.token, 97 + want: params.want, 98 + haves, 99 + maxBytes: maxPackBytes(), 100 + }) 101 + 102 + const update: RefUpdate = { ref: params.ref, old, next: params.want } 103 + pushStarted = true 104 + await session.push([update], pack) 105 + return { status: 'synced', sha: params.want } 106 + } 107 + finally { 108 + // `push` tears the session down itself; only close here if we threw before 109 + // reaching it (e.g. the byte cap fired inside fetchPack's stream). 110 + if (!pushStarted) await session.close() 111 + } 112 + } 113 + 114 + export interface SpliceDeleteResult { 115 + status: 'synced' | 'already-absent' 116 + } 117 + 118 + /** 119 + * Delete a ref on the knot. No GitHub leg and no pack: read the knot's 120 + * advertisement, and if the ref is absent we're already done (idempotent). 121 + * Otherwise send a delete command with the advertised value as the 122 + * compare-and-swap base. 123 + */ 124 + export async function spliceDelete(params: { 125 + installationId: number 126 + knot: string 127 + repoDid: string 128 + ref: string 129 + }): Promise<SpliceDeleteResult> { 130 + const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid) 131 + try { 132 + return await runSpliceDelete(factory, params.ref) 133 + } 134 + finally { 135 + cleanup() 136 + } 137 + } 138 + 139 + /** The delete exchange over an open session. Split out for the wire test. */ 140 + export async function runSpliceDelete(factory: ReceivePackFactory, ref: string): Promise<SpliceDeleteResult> { 141 + const session = await ReceivePackSession.open(factory) 142 + const old = session.tips.get(ref) 143 + if (!old || old === ZERO_SHA) { 144 + await session.close() 145 + return { status: 'already-absent' } 146 + } 147 + // push() owns teardown for the success and rejection paths. 148 + await session.push([{ ref, old, next: ZERO_SHA }], null) 149 + return { status: 'synced' } 150 + } 151 + 152 + export { fetchAdvertisement }
+9 -18
server/utils/ssh-cmd.ts
··· 10 10 /** 11 11 * Materialise the install's SSH private key as an OpenSSH-format file on disk 12 12 * and return: 13 - * - the `GIT_SSH_COMMAND` string to point `git` at it 13 + * - `args`: the ssh option list (`-i <key> -o ...`) ready to splice into a 14 + * `spawn('ssh', [...args, target, command])` call 14 15 * - a `cleanup()` callback that synchronously removes the temp dir 15 16 * 16 17 * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename ··· 24 25 * commit can ship pinned host keys for the canonical knots once we know what 25 26 * those are. 26 27 */ 27 - export async function loadSshCommandForInstall(installationId: number): Promise<{ 28 - gitSshCommand: string 28 + export async function loadSshArgsForInstall(installationId: number): Promise<{ 29 + args: string[] 29 30 cleanup: () => void 30 31 }> { 31 32 const db = useDb() ··· 54 55 chmodSync(keyPath, 0o600) 55 56 writeFileSync(knownHostsPath, '', { mode: 0o600 }) 56 57 57 - const gitSshCommand = [ 58 - 'ssh', 59 - '-i', shellQuote(keyPath), 60 - '-o', `UserKnownHostsFile=${shellQuote(knownHostsPath)}`, 58 + const args = [ 59 + '-i', keyPath, 60 + '-o', `UserKnownHostsFile=${knownHostsPath}`, 61 61 '-o', 'StrictHostKeyChecking=accept-new', 62 62 '-o', 'IdentitiesOnly=yes', 63 63 '-o', 'BatchMode=yes', 64 64 '-o', 'ConnectTimeout=15', 65 - ].join(' ') 65 + ] 66 66 67 67 return { 68 - gitSshCommand, 68 + args, 69 69 cleanup: () => { 70 70 try { 71 71 rmSync(dir, { recursive: true, force: true }) ··· 76 76 }, 77 77 } 78 78 } 79 - 80 - /** Minimal shell-quoting for paths inside GIT_SSH_COMMAND. */ 81 - function shellQuote(s: string): string { 82 - // GIT_SSH_COMMAND is split on whitespace by git, so escape spaces. We don't 83 - // bother with full shell-quoting here because the paths we generate (in 84 - // os.tmpdir()) won't contain quotes/backslashes; this is defense in depth. 85 - if (!/[\s"'\\]/.test(s)) return s 86 - return `"${s.replace(/(["\\])/g, '\\$1')}"` 87 - }
+14
server/utils/sync-push-host.ts
··· 14 14 if (knot === 'knot1.tangled.sh') return 'tangled.org' 15 15 return knot 16 16 } 17 + 18 + /** 19 + * Split a knot value into the ssh host and optional port. Self-hosted knots 20 + * may carry a `:port` suffix for a non-default ssh port; the appview-hosted 21 + * knot maps through `sshHostForKnot` and has no port. 22 + */ 23 + export function sshEndpointForKnot(knot: string): { host: string, port?: number } { 24 + const mapped = sshHostForKnot(knot) 25 + const colon = mapped.lastIndexOf(':') 26 + if (colon === -1) return { host: mapped } 27 + const port = Number.parseInt(mapped.slice(colon + 1), 10) 28 + if (Number.isNaN(port)) return { host: mapped } 29 + return { host: mapped.slice(0, colon), port } 30 + }
+36 -80
server/utils/sync-push.ts
··· 1 - import { mkdtempSync, rmSync } from 'node:fs' 2 - import os from 'node:os' 3 - import path from 'node:path' 4 1 import { and, eq, sql } from 'drizzle-orm' 5 2 import { repoMapping } from '../db/schema' 6 3 import { useDb } from './db' 7 - import { classifyPushFailure, git, RemoteRejectedPushError } from './git' 4 + import { RemoteRejectedError } from './git-wire/errors' 8 5 import { installationOctokit } from './github-app' 9 - import { loadSshCommandForInstall } from './ssh-cmd' 10 - import { sshHostForKnot } from './sync-push-host' 6 + import { splicePush } from './splice' 11 7 12 8 const ZERO_SHA = '0000000000000000000000000000000000000000' 13 9 ··· 30 26 * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent 31 27 * or disabled. 32 28 * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against 33 - * GitHub redeliveries and v1.1's tangled-primary loop (PLAN.md). 34 - * 3. Skip ref deletions (after = 0000…). Handled by github.delete in commit 13. 35 - * 4. Bare-init /tmp scratch; fetch `after` from GitHub via smart-HTTP using 36 - * the install token; push that ref to the knot over SSH with the 37 - * install's key, force-with-lease against our last known tip. 29 + * GitHub redeliveries. This is a cache only; correctness comes from the 30 + * protocol-level compare-and-swap in the splice. 31 + * 3. Skip ref deletions (after = 0000…). Handled by github.delete. 32 + * 4. Splice: open receive-pack to the knot, fetch a thin pack of `after` 33 + * from GitHub with the knot's tips as haves, pipe it straight through. 34 + * Nothing touches disk. 38 35 * 5. Update lastSyncedRefs[ref] = after. 39 36 * 40 - * On terminal failures (repo gone from knot, auth rejected) we mark the 41 - * mapping as `status='error'` so the worker stops retrying. Transient 42 - * failures (network blips, missing objects) re-throw and the queue retries 43 - * with backoff. 37 + * On terminal failures (repo gone from knot, auth rejected, pack too big) we 38 + * mark the mapping `status='error'` so the worker stops retrying. A lost 39 + * compare-and-swap (`stale-old-sha`) and other transient failures re-throw so 40 + * the queue retries with backoff; the retry re-reads the knot's tip. 44 41 */ 45 42 export async function syncPush(payload: PushPayload): Promise<PushResult> { 46 43 const db = useDb() ··· 62 59 const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref] 63 60 if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } 64 61 65 - const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-push-')) 66 - let sshCleanup: (() => void) | undefined 62 + const octokit = await installationOctokit(payload.installationId) 63 + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 67 64 68 65 try { 69 - // 1. Bare init. No working tree, no objects until we fetch. 70 - await git(['init', '--bare', '-q'], { cwd: tmpDir }) 71 - 72 - // 2. Install-token-authed clone URL. The `x-access-token` username is 73 - // GitHub's convention for installation tokens. 74 - const octokit = await installationOctokit(payload.installationId) 75 - const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 76 - const githubUrl = `https://x-access-token:${token}@github.com/${row.githubFullName}.git` 77 - 78 - // 3. Fetch exactly the new ref. The `<sha>:<ref>` refspec asks git to 79 - // fetch the object reachable from `after` and store it under our 80 - // local refs/heads/... or refs/tags/... at the same name. 81 - await git( 82 - ['fetch', '--no-tags', '-q', githubUrl, `+${payload.after}:${payload.ref}`], 83 - { cwd: tmpDir, timeout: 120_000 }, 84 - ) 85 - 86 - // 4. Push to the knot. `force-with-lease` means "only update the ref if 87 - // its current tip on the knot still matches what we last saw". Without 88 - // a lease value we fall back to plain `--force` because we have no 89 - // way to know the knot's current tip otherwise (we don't `ls-remote`). 90 - // The lease is `<our last synced sha>` when we have one; on first 91 - // sync we use plain force. 92 - const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 93 - sshCleanup = cleanup 94 - 95 - const knotUrl = `ssh://git@${sshHostForKnot(row.knot)}/${row.tangledRepoDid}` 96 - const pushRefspec = lastSynced 97 - ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}` 98 - : `+${payload.after}:${payload.ref}` 99 - 100 - try { 101 - await git( 102 - ['push', '-q', knotUrl, ...pushRefspec.split(' ')], 103 - { 104 - cwd: tmpDir, 105 - env: { GIT_SSH_COMMAND: gitSshCommand }, 106 - timeout: 120_000, 107 - }, 108 - ) 109 - } 110 - catch (err) { 111 - const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 112 - const classified = classifyPushFailure(stderr) 113 - if (classified?.reason === 'repo-gone') { 114 - await markMappingError(row.id, 'knot reports repo no longer exists; stopping sync') 115 - return { status: 'skipped', reason: 'repo-gone' } 116 - } 117 - throw classified ?? err 118 - } 66 + const result = await splicePush({ 67 + installationId: payload.installationId, 68 + repoFullName: row.githubFullName, 69 + knot: row.knot, 70 + repoDid: row.tangledRepoDid, 71 + ref: payload.ref, 72 + want: payload.after, 73 + token, 74 + }) 119 75 120 - // 5. Update last-synced tip for this ref. Use jsonb_set to leave other 121 - // refs untouched. 122 76 await db.update(repoMapping) 123 77 .set({ 124 - lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${payload.after}"`}::jsonb, true)`, 78 + lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${result.sha}"`}::jsonb, true)`, 125 79 updatedAt: new Date(), 126 80 }) 127 81 .where(eq(repoMapping.id, row.id)) 128 82 129 83 return { status: 'synced' } 130 84 } 131 - finally { 132 - sshCleanup?.() 133 - try { 134 - rmSync(tmpDir, { recursive: true, force: true }) 85 + catch (err) { 86 + if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { 87 + await markMappingError(row.id, terminalMessage(err)) 88 + return { status: 'skipped', reason: 'repo-gone' } 135 89 } 136 - catch { 137 - // best-effort 138 - } 90 + throw err 139 91 } 140 92 } 141 93 94 + function terminalMessage(err: RemoteRejectedError): string { 95 + if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` 96 + if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' 97 + return 'knot reports repo no longer exists; stopping sync' 98 + } 99 + 142 100 /** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ 143 101 function jsonbPath(ref: string): string { 144 - // Escape any double-quotes inside the ref. We only support standard git ref 145 - // names which never contain quotes, but be defensive. 146 102 return `"${ref.replaceAll('"', '\\"')}"` 147 103 } 148 104 ··· 153 109 .where(eq(repoMapping.id, mappingId)) 154 110 } 155 111 156 - export { RemoteRejectedPushError } 112 + export { RemoteRejectedError }
+49 -101
server/utils/sync-ref.ts
··· 1 - import { mkdtempSync, rmSync } from 'node:fs' 2 - import os from 'node:os' 3 - import path from 'node:path' 4 1 import { and, eq, sql } from 'drizzle-orm' 5 2 import { repoMapping } from '../db/schema' 6 3 import { useDb } from './db' 7 - import { classifyPushFailure, git } from './git' 4 + import { RemoteRejectedError, WireError } from './git-wire/errors' 8 5 import { installationOctokit } from './github-app' 9 - import { loadSshCommandForInstall } from './ssh-cmd' 10 - import { sshHostForKnot } from './sync-push-host' 6 + import { fetchAdvertisement, spliceDelete, splicePush } from './splice' 11 7 12 8 export type RefType = 'branch' | 'tag' 13 9 ··· 15 11 installationId: number 16 12 githubRepoId: number 17 13 refType: RefType 18 - /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) \u2014 NOT 14 + /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) — NOT 19 15 * the `refs/...` qualified form. */ 20 16 ref: string 21 17 } ··· 32 28 * 33 29 * Triggered by GitHub's `create` webhook event. For branches, GitHub also 34 30 * sends a parallel `push` event (with `before = 0000…`), so the branch will 35 - * usually have been created already by the time this fires \u2014 the push to 36 - * knot is then a no-op via ref-tip dedupe. For lightweight and annotated 37 - * tags, no `push` event is sent, so this is the only path that creates them 38 - * on the knot. 31 + * usually have been created already by the time this fires — the splice is 32 + * then a no-op via the knot tip already matching. For lightweight and 33 + * annotated tags, no `push` event is sent, so this is the only path that 34 + * creates them on the knot. 35 + * 36 + * We resolve the ref name to a SHA from GitHub's advertisement (annotated tags 37 + * resolve to the tag object), then splice that SHA to the knot. 39 38 */ 40 39 export async function syncCreateRef(payload: CreateRefPayload): Promise<RefResult> { 41 40 if (payload.refType !== 'branch' && payload.refType !== 'tag') { ··· 46 45 if ('skip' in mapping) return mapping.skip 47 46 48 47 const fullRef = qualifyRef(payload.refType, payload.ref) 49 - const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-create-')) 50 - let sshCleanup: (() => void) | undefined 51 48 52 - try { 53 - await git(['init', '--bare', '-q'], { cwd: tmpDir }) 49 + const octokit = await installationOctokit(payload.installationId) 50 + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 54 51 55 - const octokit = await installationOctokit(payload.installationId) 56 - const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 57 - const githubUrl = `https://x-access-token:${token}@github.com/${mapping.githubFullName}.git` 52 + const adv = await fetchAdvertisement(mapping.githubFullName, token) 53 + const want = adv.refs.get(fullRef) 54 + if (!want) { 55 + // GitHub can deliver the create webhook before its replicas advertise the 56 + // ref. Transient: re-throw so the queue retries with backoff. 57 + throw new WireError(`github does not yet advertise ${fullRef} for ${mapping.githubFullName}`) 58 + } 58 59 59 - // Fetch the ref by name. Tags carry whatever object git stores at the 60 - // ref (commit for lightweight; tag object for annotated); fetch gives 61 - // us all the reachable objects either way. 62 - await git( 63 - ['fetch', '--no-tags', '-q', githubUrl, `+${fullRef}:${fullRef}`], 64 - { cwd: tmpDir, timeout: 120_000 }, 65 - ) 66 - 67 - const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 68 - sshCleanup = cleanup 69 - const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}` 70 - 71 - try { 72 - await git( 73 - ['push', '-q', knotUrl, `+${fullRef}:${fullRef}`], 74 - { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 120_000 }, 75 - ) 76 - } 77 - catch (err) { 78 - const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 79 - const classified = classifyPushFailure(stderr) 80 - if (classified?.reason === 'repo-gone') { 81 - await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 82 - return { status: 'skipped', reason: 'repo-gone' } 83 - } 84 - throw classified ?? err 85 - } 86 - 87 - // For branches we get the SHA from the local ref after fetch; for tags 88 - // we still update lastSyncedRefs so a subsequent push event with the 89 - // same SHA short-circuits via ref-tip dedupe. 90 - const { stdout: sha } = await git(['rev-parse', fullRef], { cwd: tmpDir }) 91 - await updateLastSyncedRef(mapping.id, fullRef, sha.trim()) 92 - 60 + try { 61 + const result = await splicePush({ 62 + installationId: payload.installationId, 63 + repoFullName: mapping.githubFullName, 64 + knot: mapping.knot, 65 + repoDid: mapping.tangledRepoDid, 66 + ref: fullRef, 67 + want, 68 + token, 69 + }) 70 + await updateLastSyncedRef(mapping.id, fullRef, result.sha) 93 71 return { status: 'synced' } 94 72 } 95 - finally { 96 - sshCleanup?.() 97 - try { 98 - rmSync(tmpDir, { recursive: true, force: true }) 73 + catch (err) { 74 + if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { 75 + await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 76 + return { status: 'skipped', reason: 'repo-gone' } 99 77 } 100 - catch { 101 - // best-effort 102 - } 78 + throw err 103 79 } 104 80 } 105 81 ··· 107 83 * Mirror a branch or tag deletion from GitHub to the configured knot. 108 84 * 109 85 * Triggered by GitHub's `delete` webhook event. For branches, GitHub also 110 - * sends a parallel `push` event with `after = 0000\u2026`, which `syncPush` 111 - * currently skips (`reason: 'deletion'`) \u2014 this is the path that actually 86 + * sends a parallel `push` event with `after = 0000…`, which `syncPush` 87 + * currently skips (`reason: 'deletion'`) — this is the path that actually 112 88 * removes the ref on the knot. Tag deletion arrives only via this event. 113 89 * 114 - * Deletion is idempotent: if the ref doesn't exist on the knot we treat it 90 + * Deletion is idempotent: if the ref is already absent on the knot we treat it 115 91 * as success. We wanted it gone, it's gone. 116 92 */ 117 93 export async function syncDeleteRef(payload: DeleteRefPayload): Promise<RefResult> { ··· 123 99 if ('skip' in mapping) return mapping.skip 124 100 125 101 const fullRef = qualifyRef(payload.refType, payload.ref) 126 - const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-delete-')) 127 - let sshCleanup: (() => void) | undefined 128 102 129 103 try { 130 - // No fetch needed; we're only telling the remote to drop a ref. 131 - await git(['init', '--bare', '-q'], { cwd: tmpDir }) 132 - 133 - const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 134 - sshCleanup = cleanup 135 - const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}` 136 - 137 - // The `:<ref>` (empty source) refspec means "delete <ref> on the remote". 138 - try { 139 - await git( 140 - ['push', '-q', knotUrl, `:${fullRef}`], 141 - { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 60_000 }, 142 - ) 143 - } 144 - catch (err) { 145 - const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 146 - // "remote ref does not exist" is success for our purposes \u2014 the ref is 147 - // gone, which is what we wanted. 148 - if (/remote ref does not exist|unable to delete.*does not exist/i.test(stderr)) { 149 - await clearLastSyncedRef(mapping.id, fullRef) 150 - return { status: 'synced' } 151 - } 152 - const classified = classifyPushFailure(stderr) 153 - if (classified?.reason === 'repo-gone') { 154 - await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 155 - return { status: 'skipped', reason: 'repo-gone' } 156 - } 157 - throw classified ?? err 158 - } 159 - 104 + await spliceDelete({ 105 + installationId: payload.installationId, 106 + knot: mapping.knot, 107 + repoDid: mapping.tangledRepoDid, 108 + ref: fullRef, 109 + }) 160 110 await clearLastSyncedRef(mapping.id, fullRef) 161 111 return { status: 'synced' } 162 112 } 163 - finally { 164 - sshCleanup?.() 165 - try { 166 - rmSync(tmpDir, { recursive: true, force: true }) 113 + catch (err) { 114 + if (err instanceof RemoteRejectedError && err.reason === 'repo-gone') { 115 + await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 116 + return { status: 'skipped', reason: 'repo-gone' } 167 117 } 168 - catch { 169 - // best-effort 170 - } 118 + throw err 171 119 } 172 120 } 173 121
+76
test/unit/git-wire-refs.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { classifyNgReason, classifySshStderr } from '../../server/utils/git-wire/errors' 3 + import { parseAdvertisement, ZERO_SHA } from '../../server/utils/git-wire/refs' 4 + 5 + function lines(...ls: string[]): Buffer[] { 6 + return ls.map(l => Buffer.from(l)) 7 + } 8 + 9 + describe('parseAdvertisement', () => { 10 + it('parses a populated repo with capabilities on the first ref line', () => { 11 + const adv = parseAdvertisement(lines( 12 + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\0report-status delete-refs thin-pack agent=git/2.39\n', 13 + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/dev\n', 14 + )) 15 + expect(adv.refs.get('refs/heads/main')).toBe('a'.repeat(40)) 16 + expect(adv.refs.get('refs/heads/dev')).toBe('b'.repeat(40)) 17 + expect(adv.capabilities.has('thin-pack')).toBe(true) 18 + expect(adv.capabilities.has('delete-refs')).toBe(true) 19 + }) 20 + 21 + it('skips the smart-HTTP service prelude', () => { 22 + const adv = parseAdvertisement(lines( 23 + '# service=git-upload-pack\n', 24 + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\0thin-pack\n', 25 + )) 26 + expect(adv.refs.get('refs/heads/main')).toBe('a'.repeat(40)) 27 + expect(adv.capabilities.has('thin-pack')).toBe(true) 28 + }) 29 + 30 + it('records peeled annotated-tag lines separately', () => { 31 + const adv = parseAdvertisement(lines( 32 + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v1\0thin-pack\n', 33 + 'cccccccccccccccccccccccccccccccccccccccc refs/tags/v1^{}\n', 34 + )) 35 + expect(adv.refs.get('refs/tags/v1')).toBe('a'.repeat(40)) 36 + expect(adv.peeled.get('refs/tags/v1')).toBe('c'.repeat(40)) 37 + }) 38 + 39 + it('handles the empty-repo capabilities sentinel without inventing a ref', () => { 40 + const adv = parseAdvertisement(lines( 41 + `${ZERO_SHA} capabilities^{}\0report-status delete-refs\n`, 42 + )) 43 + expect(adv.refs.size).toBe(0) 44 + expect(adv.capabilities.has('report-status')).toBe(true) 45 + }) 46 + }) 47 + 48 + describe('classifySshStderr', () => { 49 + it('classifies repo-gone', () => { 50 + expect(classifySshStderr('fatal: repository not found')?.reason).toBe('repo-gone') 51 + expect(classifySshStderr('ERROR: does not exist')?.reason).toBe('repo-gone') 52 + }) 53 + 54 + it('classifies auth-rejected', () => { 55 + expect(classifySshStderr('git@host: Permission denied (publickey).')?.reason).toBe('auth-rejected') 56 + }) 57 + 58 + it('returns null for unrecognised stderr', () => { 59 + expect(classifySshStderr('warning: something benign')).toBeNull() 60 + }) 61 + }) 62 + 63 + describe('classifyNgReason', () => { 64 + it('classifies a stale compare-and-swap as stale-old-sha', () => { 65 + expect(classifyNgReason('non-fast-forward').reason).toBe('stale-old-sha') 66 + expect(classifyNgReason('stale info').reason).toBe('stale-old-sha') 67 + }) 68 + 69 + it('classifies a missing repo', () => { 70 + expect(classifyNgReason('repository does not exist').reason).toBe('repo-gone') 71 + }) 72 + 73 + it('falls back to other', () => { 74 + expect(classifyNgReason('funny business').reason).toBe('other') 75 + }) 76 + })
+96
test/unit/pkt-line.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { encodePktLine, flushPkt, lineToString, PktLineReader } from '../../server/utils/git-wire/pkt-line' 3 + 4 + async function* chunks(...parts: (Buffer | string)[]): AsyncGenerator<Buffer> { 5 + for (const p of parts) yield typeof p === 'string' ? Buffer.from(p) : p 6 + } 7 + 8 + describe('pkt-line', () => { 9 + describe('encodePktLine', () => { 10 + it('frames a payload with a 4-hex-digit length including the prefix', () => { 11 + // "hello\n" is 6 bytes, +4 prefix = 10 = 0x000a. 12 + expect(encodePktLine('hello\n').toString()).toBe('000ahello\n') 13 + }) 14 + 15 + it('frames an empty payload as length 4', () => { 16 + expect(encodePktLine('').toString()).toBe('0004') 17 + }) 18 + 19 + it('does not append a trailing newline of its own', () => { 20 + expect(encodePktLine('want abc').toString()).toBe('000cwant abc') 21 + }) 22 + 23 + it('rejects payloads larger than the max', () => { 24 + expect(() => encodePktLine(Buffer.alloc(65517))).toThrow(/too large/) 25 + }) 26 + 27 + it('exposes the flush-pkt as 0000', () => { 28 + expect(flushPkt.toString()).toBe('0000') 29 + }) 30 + }) 31 + 32 + describe('PktLineReader', () => { 33 + it('decodes consecutive lines', async () => { 34 + const r = new PktLineReader(chunks('0006a\n0006b\n')) 35 + expect(lineToString((await r.next() as { data: Buffer }).data)).toBe('a') 36 + expect(lineToString((await r.next() as { data: Buffer }).data)).toBe('b') 37 + expect(await r.next()).toBeNull() 38 + }) 39 + 40 + it('returns flush-pkts as a distinct type without ending iteration', async () => { 41 + const r = new PktLineReader(chunks('0006a\n00000006b\n')) 42 + expect((await r.next())!.type).toBe('line') 43 + expect((await r.next())!.type).toBe('flush') 44 + expect((await r.next())!.type).toBe('line') 45 + expect(await r.next()).toBeNull() 46 + }) 47 + 48 + it('reassembles a line split across chunk boundaries', async () => { 49 + const r = new PktLineReader(chunks('00', '0a', 'hel', 'lo\n')) 50 + expect(lineToString((await r.next() as { data: Buffer }).data)).toBe('hello') 51 + }) 52 + 53 + it('readUntilFlush collects line payloads and stops at flush', async () => { 54 + const r = new PktLineReader(chunks('0006a\n0006b\n0000')) 55 + const lines = await r.readUntilFlush() 56 + expect(lines!.map(l => lineToString(l))).toEqual(['a', 'b']) 57 + }) 58 + 59 + it('hands raw trailing bytes back via remaining(), including pre-buffered ones', async () => { 60 + // One pkt-line "NAK\n" then raw pack bytes "PACK..." arriving in the 61 + // same chunk: the reader must not swallow the pack head. 62 + const r = new PktLineReader(chunks('0008NAK\nPACK\x00\x01\x02', 'more')) 63 + const first = await r.next() 64 + expect(lineToString((first as { data: Buffer }).data)).toBe('NAK') 65 + 66 + let raw = Buffer.alloc(0) 67 + for await (const c of r.remaining()) raw = Buffer.concat([raw, c]) 68 + expect(raw.toString('binary')).toBe('PACK\x00\x01\x02more') 69 + }) 70 + 71 + it('throws on a truncated length prefix', async () => { 72 + const r = new PktLineReader(chunks('00')) 73 + await expect(r.next()).rejects.toThrow(/truncated pkt-line length/) 74 + }) 75 + 76 + it('throws on a truncated payload', async () => { 77 + const r = new PktLineReader(chunks('000ahel')) 78 + await expect(r.next()).rejects.toThrow(/wanted 10 bytes/) 79 + }) 80 + 81 + it('throws on a reserved length', async () => { 82 + const r = new PktLineReader(chunks('0001')) 83 + await expect(r.next()).rejects.toThrow(/reserved pkt-line length/) 84 + }) 85 + }) 86 + 87 + describe('lineToString', () => { 88 + it('strips a single trailing newline', () => { 89 + expect(lineToString(Buffer.from('abc\n'))).toBe('abc') 90 + }) 91 + 92 + it('leaves a line without a trailing newline intact', () => { 93 + expect(lineToString(Buffer.from('abc'))).toBe('abc') 94 + }) 95 + }) 96 + })
+147
test/unit/receive-pack.spec.ts
··· 1 + import { execFileSync } from 'node:child_process' 2 + import { afterEach, beforeEach, describe, expect, it } from 'vitest' 3 + import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 4 + import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack' 5 + import { ZERO_SHA } from '../../server/utils/git-wire/refs' 6 + import { fakeGithubFetch, GitFixture, localReceivePackFactory } from '../utils/git-wire' 7 + import { fetchPack } from '../../server/utils/git-wire/upload-pack' 8 + 9 + async function* fromBuffer(b: Buffer): AsyncGenerator<Buffer> { 10 + yield b 11 + } 12 + 13 + async function push(factory: ReturnType<typeof localReceivePackFactory>, updates: Parameters<ReceivePackSession['push']>[0], pack: AsyncIterable<Buffer> | null) { 14 + const session = await ReceivePackSession.open(factory) 15 + await session.push(updates, pack) 16 + } 17 + 18 + async function drain(gen: AsyncGenerator<Buffer>): Promise<Buffer> { 19 + const parts: Buffer[] = [] 20 + for await (const c of gen) parts.push(c) 21 + return Buffer.concat(parts) 22 + } 23 + 24 + describe('receive-pack (against real git-receive-pack)', () => { 25 + let fx: GitFixture 26 + let realFetch: typeof globalThis.fetch 27 + 28 + beforeEach(() => { 29 + fx = new GitFixture() 30 + realFetch = globalThis.fetch 31 + }) 32 + 33 + afterEach(() => { 34 + globalThis.fetch = realFetch 35 + fx.cleanup() 36 + }) 37 + 38 + /** Build a pack on disk for `want` and return it as a single buffer. */ 39 + async function packFor(ghBare: string, want: string, haves: string[]): Promise<Buffer> { 40 + globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', ghBare]])) as unknown as typeof globalThis.fetch 41 + const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want, haves, maxBytes: 1 << 30 }) 42 + return drain(pack) 43 + } 44 + 45 + it('pushes a new ref into an empty knot repo', async () => { 46 + const gh = fx.initBare('gh.git') 47 + const work = fx.initWork('work') 48 + const sha = fx.commit(work, 'a.txt', 'hello') 49 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 50 + const knot = fx.initBare('knot.git') 51 + 52 + const pack = await packFor(gh, sha, []) 53 + await push( 54 + localReceivePackFactory(knot), 55 + [{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }], 56 + fromBuffer(pack), 57 + ) 58 + 59 + expect(fx.revParse(knot, 'refs/heads/main')).toBe(sha) 60 + }) 61 + 62 + it('fast-forwards an existing ref with a thin incremental pack', async () => { 63 + const gh = fx.initBare('gh.git') 64 + const work = fx.initWork('work') 65 + const first = fx.commit(work, 'a.txt', 'one') 66 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 67 + const knot = fx.initBare('knot.git') 68 + await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: first }], fromBuffer(await packFor(gh, first, []))) 69 + 70 + const second = fx.commit(work, 'b.txt', 'two') 71 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 72 + await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: first, next: second }], fromBuffer(await packFor(gh, second, [first]))) 73 + 74 + expect(fx.revParse(knot, 'refs/heads/main')).toBe(second) 75 + }) 76 + 77 + it('rejects a stale compare-and-swap as stale-old-sha', async () => { 78 + const gh = fx.initBare('gh.git') 79 + const work = fx.initWork('work') 80 + const first = fx.commit(work, 'a.txt', 'one') 81 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 82 + const knot = fx.initBare('knot.git') 83 + await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: first }], fromBuffer(await packFor(gh, first, []))) 84 + 85 + const second = fx.commit(work, 'b.txt', 'two') 86 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 87 + 88 + // Claim the knot is still empty when it actually points at `first`. 89 + await expect( 90 + push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: second }], fromBuffer(await packFor(gh, second, [first]))), 91 + ).rejects.toMatchObject({ constructor: RemoteRejectedError, reason: 'stale-old-sha' }) 92 + }) 93 + 94 + it('deletes a ref with no pack', async () => { 95 + const gh = fx.initBare('gh.git') 96 + const work = fx.initWork('work') 97 + const sha = fx.commit(work, 'a.txt', 'hello') 98 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 99 + const knot = fx.initBare('knot.git') 100 + await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }], fromBuffer(await packFor(gh, sha, []))) 101 + 102 + await push(localReceivePackFactory(knot), [{ ref: 'refs/heads/main', old: sha, next: ZERO_SHA }], null) 103 + 104 + expect(() => execFileSync('git', ['rev-parse', 'refs/heads/main'], { cwd: knot })).toThrow(/unknown revision|ambiguous argument|fatal/) 105 + }) 106 + 107 + it('kills a stalled session once the watchdog fires', async () => { 108 + // A factory whose child accepts the connection but never advertises: the 109 + // open() read would block forever without the watchdog. 110 + const stalled = () => { 111 + let resolveDone: (code: number | null) => void 112 + const done = new Promise<number | null>(r => { resolveDone = r }) 113 + // eslint-disable-next-line require-yield -- models a stalled stream that blocks until killed and never emits 114 + async function* neverYields(): AsyncGenerator<Buffer> { 115 + await done 116 + } 117 + return { 118 + stdin: { write: (_d: unknown, cb?: (e?: Error) => void) => cb?.(), end: () => {} } as unknown as NodeJS.WritableStream, 119 + stdout: neverYields(), 120 + stderr: () => '', 121 + kill: () => resolveDone(null), 122 + done, 123 + } 124 + } 125 + await expect(ReceivePackSession.open(stalled, 50)).rejects.toThrow(/end of stream|advertisement/) 126 + }) 127 + 128 + it('pushes an annotated tag', async () => { 129 + const gh = fx.initBare('gh.git') 130 + const work = fx.initWork('work') 131 + fx.commit(work, 'a.txt', 'hello') 132 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 133 + fx.git(['tag', '-a', 'v1', '-m', 'release'], work) 134 + const tagSha = fx.git(['rev-parse', 'refs/tags/v1'], work) 135 + fx.pushTo(work, gh, 'refs/tags/v1:refs/tags/v1') 136 + const knot = fx.initBare('knot.git') 137 + 138 + await push( 139 + localReceivePackFactory(knot), 140 + [{ ref: 'refs/tags/v1', old: ZERO_SHA, next: tagSha }], 141 + fromBuffer(await packFor(gh, tagSha, [])), 142 + ) 143 + 144 + expect(fx.revParse(knot, 'refs/tags/v1')).toBe(tagSha) 145 + expect(fx.git(['cat-file', '-t', 'refs/tags/v1'], knot)).toBe('tag') 146 + }) 147 + })
+113
test/unit/splice.spec.ts
··· 1 + import { execFileSync } from 'node:child_process' 2 + import { afterEach, beforeEach, describe, expect, it } from 'vitest' 3 + import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 4 + import { runSplice, runSpliceDelete } from '../../server/utils/splice' 5 + import { fakeGithubFetch, GitFixture, localReceivePackFactory } from '../utils/git-wire' 6 + 7 + describe('splice (end-to-end against real git binaries)', () => { 8 + let fx: GitFixture 9 + let realFetch: typeof globalThis.fetch 10 + 11 + beforeEach(() => { 12 + fx = new GitFixture() 13 + realFetch = globalThis.fetch 14 + }) 15 + 16 + afterEach(() => { 17 + globalThis.fetch = realFetch 18 + fx.cleanup() 19 + }) 20 + 21 + function wireGithub(ghBare: string) { 22 + globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', ghBare]])) as unknown as typeof globalThis.fetch 23 + } 24 + 25 + it('mirrors a first push into an empty knot, transferring objects end to end', async () => { 26 + const gh = fx.initBare('gh.git') 27 + const work = fx.initWork('work') 28 + const sha = fx.commit(work, 'a.txt', 'hello world') 29 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 30 + const knot = fx.initBare('knot.git') 31 + wireGithub(gh) 32 + 33 + const result = await runSplice(localReceivePackFactory(knot), { 34 + repoFullName: 'owner/repo', 35 + ref: 'refs/heads/main', 36 + want: sha, 37 + token: 'tok', 38 + }) 39 + 40 + expect(result).toEqual({ status: 'synced', sha }) 41 + expect(fx.revParse(knot, 'refs/heads/main')).toBe(sha) 42 + expect(execFileSync('git', ['cat-file', '-p', `${sha}:a.txt`], { cwd: knot, encoding: 'utf8' })).toBe('hello world') 43 + }) 44 + 45 + it('streams only the delta on a follow-up push (thin pack via knot tips as haves)', async () => { 46 + const gh = fx.initBare('gh.git') 47 + const work = fx.initWork('work') 48 + const first = fx.commit(work, 'a.txt', 'a'.repeat(8000)) 49 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 50 + const knot = fx.initBare('knot.git') 51 + wireGithub(gh) 52 + 53 + await runSplice(localReceivePackFactory(knot), { repoFullName: 'owner/repo', ref: 'refs/heads/main', want: first, token: 'tok' }) 54 + 55 + const second = fx.commit(work, 'b.txt', 'b'.repeat(8000)) 56 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 57 + const result = await runSplice(localReceivePackFactory(knot), { repoFullName: 'owner/repo', ref: 'refs/heads/main', want: second, token: 'tok' }) 58 + 59 + expect(result).toEqual({ status: 'synced', sha: second }) 60 + expect(fx.revParse(knot, 'refs/heads/main')).toBe(second) 61 + }) 62 + 63 + it('no-ops when the knot tip already equals want', async () => { 64 + const gh = fx.initBare('gh.git') 65 + const work = fx.initWork('work') 66 + const sha = fx.commit(work, 'a.txt', 'hello') 67 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 68 + const knot = fx.initBare('knot.git') 69 + wireGithub(gh) 70 + 71 + await runSplice(localReceivePackFactory(knot), { repoFullName: 'owner/repo', ref: 'refs/heads/main', want: sha, token: 'tok' }) 72 + const again = await runSplice(localReceivePackFactory(knot), { repoFullName: 'owner/repo', ref: 'refs/heads/main', want: sha, token: 'tok' }) 73 + 74 + expect(again).toEqual({ status: 'already-synced', sha }) 75 + }) 76 + 77 + it('aborts a push that exceeds the byte cap', async () => { 78 + const gh = fx.initBare('gh.git') 79 + const work = fx.initWork('work') 80 + const sha = fx.commit(work, 'a.txt', 'x'.repeat(20_000)) 81 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 82 + const knot = fx.initBare('knot.git') 83 + wireGithub(gh) 84 + 85 + const prev = process.env.NUXT_MAX_PACK_BYTES 86 + process.env.NUXT_MAX_PACK_BYTES = '10' 87 + try { 88 + await expect( 89 + runSplice(localReceivePackFactory(knot), { repoFullName: 'owner/repo', ref: 'refs/heads/main', want: sha, token: 'tok' }), 90 + ).rejects.toMatchObject({ constructor: RemoteRejectedError, reason: 'too-big' }) 91 + } 92 + finally { 93 + if (prev === undefined) delete process.env.NUXT_MAX_PACK_BYTES 94 + else process.env.NUXT_MAX_PACK_BYTES = prev 95 + } 96 + }) 97 + 98 + it('deletes a ref and treats an already-absent ref as success', async () => { 99 + const gh = fx.initBare('gh.git') 100 + const work = fx.initWork('work') 101 + const sha = fx.commit(work, 'a.txt', 'hello') 102 + fx.pushTo(work, gh, 'HEAD:refs/heads/main') 103 + const knot = fx.initBare('knot.git') 104 + wireGithub(gh) 105 + 106 + await runSplice(localReceivePackFactory(knot), { repoFullName: 'owner/repo', ref: 'refs/heads/main', want: sha, token: 'tok' }) 107 + 108 + expect(await runSpliceDelete(localReceivePackFactory(knot), 'refs/heads/main')).toEqual({ status: 'synced' }) 109 + expect(() => fx.revParse(knot, 'refs/heads/main')).toThrow(/unknown revision|ambiguous argument|fatal/) 110 + 111 + expect(await runSpliceDelete(localReceivePackFactory(knot), 'refs/heads/gone')).toEqual({ status: 'already-absent' }) 112 + }) 113 + })
+131
test/unit/sync-push.spec.ts
··· 1 + import crypto from 'node:crypto' 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 3 + import { installation, repoMapping } from '../../server/db/schema' 4 + import { clearDb, setDb, useDb } from '../../server/utils/db' 5 + import { clearEncryptionKeyCache } from '../../server/utils/encryption' 6 + import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 7 + import { createTestDb } from '../utils/db' 8 + 9 + const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 10 + const ZERO = '0'.repeat(40) 11 + 12 + const splicePushMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string, sha: string }>>() 13 + const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 14 + 15 + vi.mock('../../server/utils/splice', () => ({ 16 + splicePush: (params: Record<string, unknown>) => splicePushMock(params), 17 + })) 18 + 19 + vi.mock('../../server/utils/github-app', () => ({ 20 + installationOctokit: async () => ({ auth: octokitAuthMock }), 21 + })) 22 + 23 + const { syncPush } = await import('../../server/utils/sync-push') 24 + 25 + describe('sync-push', () => { 26 + beforeEach(async () => { 27 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 28 + clearEncryptionKeyCache() 29 + 30 + setDb(await createTestDb()) 31 + await useDb().insert(installation).values({ 32 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 33 + }) 34 + 35 + splicePushMock.mockReset() 36 + octokitAuthMock.mockReset() 37 + octokitAuthMock.mockResolvedValue({ token: 'install-token' }) 38 + splicePushMock.mockResolvedValue({ status: 'synced', sha: 'a'.repeat(40) }) 39 + }) 40 + 41 + afterEach(() => { 42 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 43 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 44 + clearEncryptionKeyCache() 45 + clearDb() 46 + }) 47 + 48 + async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 49 + await useDb().insert(repoMapping).values({ 50 + installationId: 1, 51 + githubRepoId: 9001, 52 + githubFullName: 'alice/my-project', 53 + tangledRepoDid: 'did:plc:repo-xyz', 54 + tangledFullName: 'did:plc:abc/my-project', 55 + knot: 'knot1.tangled.sh', 56 + status: 'active', 57 + ...over, 58 + }) 59 + } 60 + 61 + const payload = (over: Record<string, unknown> = {}) => ({ 62 + installationId: 1, 63 + githubRepoId: 9001, 64 + ref: 'refs/heads/main', 65 + before: ZERO, 66 + after: 'a'.repeat(40), 67 + ...over, 68 + }) 69 + 70 + it('skips when no mapping exists', async () => { 71 + expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'no-mapping' }) 72 + expect(splicePushMock).not.toHaveBeenCalled() 73 + }) 74 + 75 + it('skips when disabled', async () => { 76 + await seedMapping({ disabledAt: new Date() }) 77 + expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'disabled' }) 78 + }) 79 + 80 + it('skips ref deletions (after = zero sha)', async () => { 81 + await seedMapping() 82 + expect(await syncPush(payload({ after: ZERO }))).toEqual({ status: 'skipped', reason: 'deletion' }) 83 + expect(splicePushMock).not.toHaveBeenCalled() 84 + }) 85 + 86 + it('dedupes a redelivery via lastSyncedRefs', async () => { 87 + await seedMapping({ lastSyncedRefs: { 'refs/heads/main': 'a'.repeat(40) } }) 88 + expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'already-synced' }) 89 + expect(splicePushMock).not.toHaveBeenCalled() 90 + }) 91 + 92 + it('splices the push and records the new tip', async () => { 93 + await seedMapping() 94 + const result = await syncPush(payload()) 95 + expect(result).toEqual({ status: 'synced' }) 96 + expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 97 + ref: 'refs/heads/main', 98 + want: 'a'.repeat(40), 99 + repoDid: 'did:plc:repo-xyz', 100 + knot: 'knot1.tangled.sh', 101 + token: 'install-token', 102 + })) 103 + const rows = await useDb().select().from(repoMapping) 104 + expect((rows[0].lastSyncedRefs as Record<string, string>)['refs/heads/main']).toBe('a'.repeat(40)) 105 + }) 106 + 107 + it('marks mapping error and stops on a terminal too-big failure', async () => { 108 + await seedMapping() 109 + splicePushMock.mockRejectedValue(new RemoteRejectedError('pack exceeded', 'too-big')) 110 + expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'repo-gone' }) 111 + const rows = await useDb().select().from(repoMapping) 112 + expect(rows[0].status).toBe('error') 113 + expect(rows[0].lastError).toMatch(/size limit/) 114 + }) 115 + 116 + it('marks mapping error when the knot rejects our key', async () => { 117 + await seedMapping() 118 + splicePushMock.mockRejectedValue(new RemoteRejectedError('denied', 'auth-rejected')) 119 + expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'repo-gone' }) 120 + const rows = await useDb().select().from(repoMapping) 121 + expect(rows[0].status).toBe('error') 122 + }) 123 + 124 + it('rethrows a transient stale-old-sha for queue retry', async () => { 125 + await seedMapping() 126 + splicePushMock.mockRejectedValue(new RemoteRejectedError('stale', 'stale-old-sha')) 127 + await expect(syncPush(payload())).rejects.toMatchObject({ reason: 'stale-old-sha' }) 128 + const rows = await useDb().select().from(repoMapping) 129 + expect(rows[0].status).toBe('active') 130 + }) 131 + })
+78 -149
test/unit/sync-ref.spec.ts
··· 3 3 import { installation, repoMapping } from '../../server/db/schema' 4 4 import { clearDb, setDb, useDb } from '../../server/utils/db' 5 5 import { clearEncryptionKeyCache } from '../../server/utils/encryption' 6 + import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 6 7 import { createTestDb } from '../utils/db' 7 8 8 9 const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 9 10 10 - // Mock git + ssh + octokit so we can exercise mapping-lookup + envelope 11 - // branches without invoking real binaries. 12 - const gitMock = vi.fn<(args: string[], opts?: unknown) => Promise<{ stdout: string, stderr: string }>>() 13 - const sshLoadMock = vi.fn<(installationId: number) => Promise<{ gitSshCommand: string, cleanup: () => void }>>() 11 + const fetchAdvertisementMock = vi.fn<(repo: string, token: string) => Promise<{ refs: Map<string, string> }>>() 12 + const splicePushMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string, sha: string }>>() 13 + const spliceDeleteMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string }>>() 14 14 const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 15 15 16 - vi.mock('../../server/utils/git', async () => { 17 - const actual = await vi.importActual<typeof import('../../server/utils/git')>('../../server/utils/git') 18 - return { 19 - ...actual, 20 - git: (args: string[], opts: unknown) => gitMock(args, opts), 21 - } 22 - }) 23 - 24 - vi.mock('../../server/utils/ssh-cmd', () => ({ 25 - loadSshCommandForInstall: (id: number) => sshLoadMock(id), 16 + vi.mock('../../server/utils/splice', () => ({ 17 + fetchAdvertisement: (repo: string, token: string) => fetchAdvertisementMock(repo, token), 18 + splicePush: (params: Record<string, unknown>) => splicePushMock(params), 19 + spliceDelete: (params: Record<string, unknown>) => spliceDeleteMock(params), 26 20 })) 27 21 28 22 vi.mock('../../server/utils/github-app', () => ({ 29 - installationOctokit: async () => ({ 30 - auth: octokitAuthMock, 31 - }), 23 + installationOctokit: async () => ({ auth: octokitAuthMock }), 32 24 })) 33 25 34 26 const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref') ··· 44 36 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 45 37 }) 46 38 47 - gitMock.mockReset() 48 - sshLoadMock.mockReset() 39 + fetchAdvertisementMock.mockReset() 40 + splicePushMock.mockReset() 41 + spliceDeleteMock.mockReset() 49 42 octokitAuthMock.mockReset() 50 43 51 - sshLoadMock.mockResolvedValue({ gitSshCommand: 'ssh -i /tmp/key', cleanup: () => {} }) 52 44 octokitAuthMock.mockResolvedValue({ token: 'install-token' }) 53 - gitMock.mockResolvedValue({ stdout: 'abc1234567890abc1234567890abc1234567890a', stderr: '' }) 45 + fetchAdvertisementMock.mockResolvedValue({ 46 + refs: new Map([ 47 + ['refs/heads/main', 'a'.repeat(40)], 48 + ['refs/heads/feature-x', 'b'.repeat(40)], 49 + ['refs/tags/v1.0.0', 'c'.repeat(40)], 50 + ]), 51 + }) 52 + splicePushMock.mockResolvedValue({ status: 'synced', sha: 'a'.repeat(40) }) 53 + spliceDeleteMock.mockResolvedValue({ status: 'synced' }) 54 54 }) 55 55 56 56 afterEach(() => { ··· 78 78 it('skips non-branch/tag ref types', async () => { 79 79 await seedMapping() 80 80 const result = await syncCreateRef({ 81 - installationId: 1, 82 - githubRepoId: 9001, 83 - refType: 'repository' as never, 84 - ref: 'whatever', 81 + installationId: 1, githubRepoId: 9001, refType: 'repository' as never, ref: 'whatever', 85 82 }) 86 83 expect(result).toEqual({ status: 'skipped', reason: 'not-branch-or-tag' }) 87 - expect(gitMock).not.toHaveBeenCalled() 84 + expect(splicePushMock).not.toHaveBeenCalled() 88 85 }) 89 86 90 87 it('skips when no mapping exists', async () => { 91 - const result = await syncCreateRef({ 92 - installationId: 1, 93 - githubRepoId: 9001, 94 - refType: 'branch', 95 - ref: 'main', 96 - }) 88 + const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 97 89 expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' }) 98 90 }) 99 91 100 92 it('skips when mapping is disabled', async () => { 101 93 await seedMapping({ disabledAt: new Date() }) 102 - const result = await syncCreateRef({ 103 - installationId: 1, 104 - githubRepoId: 9001, 105 - refType: 'branch', 106 - ref: 'main', 107 - }) 94 + const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 108 95 expect(result).toEqual({ status: 'skipped', reason: 'disabled' }) 109 96 }) 110 97 111 - it('qualifies branch refs as refs/heads/<name>', async () => { 98 + it('resolves a branch ref to its SHA and splices it', async () => { 112 99 await seedMapping() 113 - await syncCreateRef({ 114 - installationId: 1, 115 - githubRepoId: 9001, 116 - refType: 'branch', 117 - ref: 'feature-x', 118 - }) 100 + await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'feature-x' }) 101 + expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 102 + ref: 'refs/heads/feature-x', 103 + want: 'b'.repeat(40), 104 + repoDid: 'did:plc:repo-xyz', 105 + })) 106 + }) 119 107 120 - // git init, git fetch, git push, git rev-parse 121 - const calls = gitMock.mock.calls.map(c => c[0]) 122 - const fetch = calls.find(args => args[0] === 'fetch') 123 - const push = calls.find(args => args[0] === 'push') 124 - expect(fetch).toBeDefined() 125 - expect(fetch).toContain('+refs/heads/feature-x:refs/heads/feature-x') 126 - expect(push).toContain('+refs/heads/feature-x:refs/heads/feature-x') 108 + it('resolves a tag ref to its SHA', async () => { 109 + await seedMapping() 110 + await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'tag', ref: 'v1.0.0' }) 111 + expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 112 + ref: 'refs/tags/v1.0.0', 113 + want: 'c'.repeat(40), 114 + })) 127 115 }) 128 116 129 - it('qualifies tag refs as refs/tags/<name>', async () => { 117 + it('retries (throws) when github does not yet advertise the ref', async () => { 130 118 await seedMapping() 131 - await syncCreateRef({ 132 - installationId: 1, 133 - githubRepoId: 9001, 134 - refType: 'tag', 135 - ref: 'v1.0.0', 136 - }) 137 - const calls = gitMock.mock.calls.map(c => c[0]) 138 - const fetch = calls.find(args => args[0] === 'fetch') 139 - expect(fetch).toContain('+refs/tags/v1.0.0:refs/tags/v1.0.0') 119 + fetchAdvertisementMock.mockResolvedValue({ refs: new Map() }) 120 + await expect( 121 + syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }), 122 + ).rejects.toThrow(/does not yet advertise/) 123 + expect(splicePushMock).not.toHaveBeenCalled() 140 124 }) 141 125 142 - it('routes push for knot1.tangled.sh via tangled.org', async () => { 126 + it('updates lastSyncedRefs with the synced SHA', async () => { 143 127 await seedMapping() 144 - await syncCreateRef({ 145 - installationId: 1, 146 - githubRepoId: 9001, 147 - refType: 'branch', 148 - ref: 'main', 149 - }) 150 - const push = gitMock.mock.calls.map(c => c[0]).find(a => a[0] === 'push')! 151 - const url = push.find(a => a.startsWith('ssh://'))! 152 - expect(url).toContain('@tangled.org/') 128 + splicePushMock.mockResolvedValue({ status: 'synced', sha: 'd'.repeat(40) }) 129 + await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 130 + 131 + const rows = await useDb().select().from(repoMapping) 132 + expect((rows[0].lastSyncedRefs as Record<string, string>)['refs/heads/main']).toBe('d'.repeat(40)) 153 133 }) 154 134 155 - it('updates lastSyncedRefs with the fetched SHA', async () => { 135 + it('marks mapping error when the knot reports repo gone', async () => { 156 136 await seedMapping() 157 - gitMock.mockImplementation(async args => { 158 - if (args[0] === 'rev-parse') { 159 - return { stdout: 'deadbeef1234567890deadbeef1234567890dead', stderr: '' } 160 - } 161 - return { stdout: '', stderr: '' } 162 - }) 137 + splicePushMock.mockRejectedValue(new RemoteRejectedError('gone', 'repo-gone')) 138 + const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 139 + expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' }) 140 + const rows = await useDb().select().from(repoMapping) 141 + expect(rows[0].status).toBe('error') 142 + }) 163 143 164 - await syncCreateRef({ 165 - installationId: 1, 166 - githubRepoId: 9001, 167 - refType: 'branch', 168 - ref: 'main', 169 - }) 170 - 171 - const db = useDb() 172 - const rows = await db.select().from(repoMapping) 173 - const refs = rows[0].lastSyncedRefs as Record<string, string> 174 - expect(refs['refs/heads/main']).toBe('deadbeef1234567890deadbeef1234567890dead') 144 + it('rethrows a transient stale-old-sha for queue retry', async () => { 145 + await seedMapping() 146 + splicePushMock.mockRejectedValue(new RemoteRejectedError('stale', 'stale-old-sha')) 147 + await expect( 148 + syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }), 149 + ).rejects.toMatchObject({ reason: 'stale-old-sha' }) 175 150 }) 176 151 }) 177 152 178 153 describe('syncDeleteRef', () => { 179 - it('uses the empty-source refspec to delete on the remote', async () => { 154 + it('splices a delete for the qualified ref', async () => { 180 155 await seedMapping() 181 - await syncDeleteRef({ 182 - installationId: 1, 183 - githubRepoId: 9001, 184 - refType: 'branch', 185 - ref: 'old-branch', 186 - }) 187 - const push = gitMock.mock.calls.map(c => c[0]).find(a => a[0] === 'push')! 188 - expect(push).toContain(':refs/heads/old-branch') 189 - expect(push.some(s => s.startsWith(':'))).toBe(true) 156 + await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'old-branch' }) 157 + expect(spliceDeleteMock).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/old-branch' })) 190 158 }) 191 159 192 - it('treats "remote ref does not exist" as success', async () => { 160 + it('treats an already-absent ref as success', async () => { 193 161 await seedMapping() 194 - gitMock.mockImplementation(async args => { 195 - if (args[0] === 'push') { 196 - throw Object.assign(new Error('exit 1'), { 197 - stderr: 'error: unable to delete \'refs/tags/v9\': remote ref does not exist\n', 198 - }) 199 - } 200 - return { stdout: '', stderr: '' } 201 - }) 202 - 203 - const result = await syncDeleteRef({ 204 - installationId: 1, 205 - githubRepoId: 9001, 206 - refType: 'tag', 207 - ref: 'v9', 208 - }) 162 + spliceDeleteMock.mockResolvedValue({ status: 'already-absent' }) 163 + const result = await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'tag', ref: 'v9' }) 209 164 expect(result).toEqual({ status: 'synced' }) 210 165 }) 211 166 212 167 it('removes the ref from lastSyncedRefs', async () => { 213 - const db = useDb() 214 - await seedMapping({ 215 - lastSyncedRefs: { 'refs/heads/main': 'abc', 'refs/heads/old': 'def' }, 216 - }) 217 - 218 - await syncDeleteRef({ 219 - installationId: 1, 220 - githubRepoId: 9001, 221 - refType: 'branch', 222 - ref: 'old', 223 - }) 224 - 225 - const rows = await db.select().from(repoMapping) 226 - const refs = rows[0].lastSyncedRefs as Record<string, string> 227 - expect(refs).toEqual({ 'refs/heads/main': 'abc' }) 168 + await seedMapping({ lastSyncedRefs: { 'refs/heads/main': 'abc', 'refs/heads/old': 'def' } }) 169 + await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'old' }) 170 + const rows = await useDb().select().from(repoMapping) 171 + expect(rows[0].lastSyncedRefs).toEqual({ 'refs/heads/main': 'abc' }) 228 172 }) 229 173 230 174 it('marks mapping as error if knot reports repo gone', async () => { 231 175 await seedMapping() 232 - gitMock.mockImplementation(async args => { 233 - if (args[0] === 'push') { 234 - throw Object.assign(new Error('exit 1'), { 235 - stderr: 'fatal: repository not found\n', 236 - }) 237 - } 238 - return { stdout: '', stderr: '' } 239 - }) 240 - 241 - const result = await syncDeleteRef({ 242 - installationId: 1, 243 - githubRepoId: 9001, 244 - refType: 'branch', 245 - ref: 'main', 246 - }) 176 + spliceDeleteMock.mockRejectedValue(new RemoteRejectedError('gone', 'repo-gone')) 177 + const result = await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }) 247 178 expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' }) 248 - 249 - const db = useDb() 250 - const rows = await db.select().from(repoMapping) 179 + const rows = await useDb().select().from(repoMapping) 251 180 expect(rows[0].status).toBe('error') 252 181 }) 253 182 })
+92
test/unit/upload-pack.spec.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 + import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 3 + import { fetchAdvertisement, fetchPack } from '../../server/utils/git-wire/upload-pack' 4 + import { fakeGithubFetch, GitFixture } from '../utils/git-wire' 5 + 6 + const PACK_MAGIC = Buffer.from('PACK') 7 + 8 + async function drain(gen: AsyncGenerator<Buffer>): Promise<Buffer> { 9 + const parts: Buffer[] = [] 10 + for await (const c of gen) parts.push(c) 11 + return Buffer.concat(parts) 12 + } 13 + 14 + describe('upload-pack (against real git-upload-pack)', () => { 15 + let fx: GitFixture 16 + let realFetch: typeof globalThis.fetch 17 + 18 + beforeEach(() => { 19 + fx = new GitFixture() 20 + realFetch = globalThis.fetch 21 + }) 22 + 23 + afterEach(() => { 24 + globalThis.fetch = realFetch 25 + fx.cleanup() 26 + vi.restoreAllMocks() 27 + }) 28 + 29 + function wire(repos: Map<string, string>) { 30 + globalThis.fetch = fakeGithubFetch(repos) as unknown as typeof globalThis.fetch 31 + } 32 + 33 + it('resolves a ref name to a SHA via the advertisement', async () => { 34 + const bare = fx.initBare('gh.git') 35 + const work = fx.initWork('work') 36 + const sha = fx.commit(work, 'a.txt', 'hello') 37 + fx.pushTo(work, bare, 'HEAD:refs/heads/main') 38 + wire(new Map([['owner/repo', bare]])) 39 + 40 + const adv = await fetchAdvertisement('owner/repo', 'tok') 41 + expect(adv.refs.get('refs/heads/main')).toBe(sha) 42 + expect(adv.capabilities.has('thin-pack')).toBe(true) 43 + }) 44 + 45 + it('fetches a full pack on first sync (no haves)', async () => { 46 + const bare = fx.initBare('gh.git') 47 + const work = fx.initWork('work') 48 + const sha = fx.commit(work, 'a.txt', 'hello') 49 + fx.pushTo(work, bare, 'HEAD:refs/heads/main') 50 + wire(new Map([['owner/repo', bare]])) 51 + 52 + const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 'tok', want: sha, haves: [], maxBytes: 1 << 30 }) 53 + const bytes = await drain(pack) 54 + expect(bytes.subarray(0, 4)).toEqual(PACK_MAGIC) 55 + expect(bytes.length).toBeGreaterThan(0) 56 + }) 57 + 58 + it('sends a smaller pack when the knot tip is offered as a have', async () => { 59 + const bare = fx.initBare('gh.git') 60 + const work = fx.initWork('work') 61 + const first = fx.commit(work, 'a.txt', 'a'.repeat(5000)) 62 + fx.pushTo(work, bare, 'HEAD:refs/heads/main') 63 + const second = fx.commit(work, 'b.txt', 'b'.repeat(5000)) 64 + fx.pushTo(work, bare, 'HEAD:refs/heads/main') 65 + wire(new Map([['owner/repo', bare]])) 66 + 67 + const full = await drain((await fetchPack({ repoFullName: 'owner/repo', token: 'tok', want: second, haves: [], maxBytes: 1 << 30 })).pack) 68 + const incremental = await drain((await fetchPack({ repoFullName: 'owner/repo', token: 'tok', want: second, haves: [first], maxBytes: 1 << 30 })).pack) 69 + 70 + expect(incremental.subarray(0, 4)).toEqual(PACK_MAGIC) 71 + expect(incremental.length).toBeLessThan(full.length) 72 + }) 73 + 74 + it('aborts with too-big once the byte cap is exceeded', async () => { 75 + const bare = fx.initBare('gh.git') 76 + const work = fx.initWork('work') 77 + const sha = fx.commit(work, 'a.txt', 'x'.repeat(10_000)) 78 + fx.pushTo(work, bare, 'HEAD:refs/heads/main') 79 + wire(new Map([['owner/repo', bare]])) 80 + 81 + const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 'tok', want: sha, haves: [], maxBytes: 10 }) 82 + await expect(drain(pack)).rejects.toMatchObject({ 83 + constructor: RemoteRejectedError, 84 + reason: 'too-big', 85 + }) 86 + }) 87 + 88 + it('throws on a 404 from github', async () => { 89 + wire(new Map()) 90 + await expect(fetchAdvertisement('missing/repo', 'tok')).rejects.toThrow(/404/) 91 + }) 92 + })
+121
test/utils/git-wire.ts
··· 1 + import { type ChildProcessWithoutNullStreams, execFileSync, spawn, spawnSync } from 'node:child_process' 2 + import { mkdtempSync, rmSync } from 'node:fs' 3 + import os from 'node:os' 4 + import path from 'node:path' 5 + import type { ReceivePackFactory } from '../../server/utils/git-wire/receive-pack' 6 + import { encodePktLine, flushPkt } from '../../server/utils/git-wire/pkt-line' 7 + 8 + /** 9 + * Local git fixtures for wire-protocol tests. No network, no ssh: we drive the 10 + * same `git-upload-pack` / `git-receive-pack` binaries GitHub and the knot run 11 + * server-side, so the bytes on the pipe are real protocol output. 12 + */ 13 + export class GitFixture { 14 + readonly dir: string 15 + 16 + constructor() { 17 + this.dir = mkdtempSync(path.join(os.tmpdir(), 'gitwire-test-')) 18 + } 19 + 20 + git(args: string[], cwd = this.dir): string { 21 + return execFileSync('git', args, { 22 + cwd, 23 + encoding: 'utf8', 24 + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' }, 25 + }).trim() 26 + } 27 + 28 + initBare(name: string): string { 29 + const repo = path.join(this.dir, name) 30 + this.git(['init', '-q', '--bare', repo]) 31 + return repo 32 + } 33 + 34 + /** Create a non-bare work repo with one commit on `main`, return its path. */ 35 + initWork(name: string): string { 36 + const repo = path.join(this.dir, name) 37 + this.git(['init', '-q', '-b', 'main', repo]) 38 + this.git(['config', 'user.email', 't@example.com'], repo) 39 + this.git(['config', 'user.name', 'Test'], repo) 40 + return repo 41 + } 42 + 43 + commit(workRepo: string, file: string, content: string): string { 44 + execFileSync('bash', ['-c', `printf %s ${JSON.stringify(content)} > ${JSON.stringify(path.join(workRepo, file))}`]) 45 + this.git(['add', '.'], workRepo) 46 + this.git(['commit', '-q', '-m', `add ${file}`], workRepo) 47 + return this.git(['rev-parse', 'HEAD'], workRepo) 48 + } 49 + 50 + pushTo(workRepo: string, bareRepo: string, refspec: string): void { 51 + this.git(['push', '-q', bareRepo, refspec], workRepo) 52 + } 53 + 54 + revParse(repo: string, ref: string): string { 55 + return this.git(['rev-parse', ref], repo) 56 + } 57 + 58 + cleanup(): void { 59 + rmSync(this.dir, { recursive: true, force: true }) 60 + } 61 + } 62 + 63 + /** 64 + * Stand in for GitHub's `git-upload-pack` HTTP endpoint by running the binary 65 + * in `--stateless-rpc` mode against a local bare repo. Mirrors the request / 66 + * response framing the real endpoint uses, so `upload-pack.ts` can be pointed 67 + * at it through a patched `fetch`. 68 + */ 69 + export function fakeGithubFetch(repos: Map<string, string>) { 70 + return async function fetchImpl(input: string | URL, init?: RequestInit): Promise<Response> { 71 + const url = typeof input === 'string' ? input : input.toString() 72 + const match = url.match(/github\.com\/(.+?)\.git\/(info\/refs|git-upload-pack)/) 73 + if (!match) throw new Error(`fakeGithubFetch: unexpected url ${url}`) 74 + const repoPath = repos.get(match[1]!) 75 + if (!repoPath) return new Response(null, { status: 404, statusText: 'Not Found' }) 76 + 77 + if (match[2] === 'info/refs') { 78 + const adv = execFileSync('git-upload-pack', ['--stateless-rpc', '--advertise-refs', repoPath]) 79 + // The HTTP transport prepends the service banner + flush; the binary does not. 80 + const banner = Buffer.concat([ 81 + encodePktLine('# service=git-upload-pack\n'), 82 + flushPkt, 83 + ]) 84 + return new Response(new Uint8Array(Buffer.concat([banner, adv])), { status: 200 }) 85 + } 86 + 87 + const reqBody = Buffer.from(await new Response(init!.body as BodyInit).arrayBuffer()) 88 + const proc = spawnSync('git-upload-pack', ['--stateless-rpc', repoPath], { input: reqBody, maxBuffer: 1 << 30 }) 89 + if (proc.status !== 0) { 90 + throw new Error(`git-upload-pack exited ${proc.status}: ${proc.stderr.toString()}`) 91 + } 92 + return new Response(new Uint8Array(proc.stdout), { status: 200 }) 93 + } 94 + } 95 + 96 + const STDERR_CAP = 16 * 1024 97 + 98 + /** 99 + * A `ReceivePackFactory` that spawns the real `git-receive-pack` binary 100 + * against a local bare repo, bypassing ssh. The stdio protocol is identical 101 + * to what the knot speaks. 102 + */ 103 + export function localReceivePackFactory(bareRepo: string): ReceivePackFactory { 104 + return () => { 105 + const child: ChildProcessWithoutNullStreams = spawn('git-receive-pack', [bareRepo], { 106 + stdio: ['pipe', 'pipe', 'pipe'], 107 + }) 108 + let stderrBuf = Buffer.alloc(0) 109 + child.stderr.on('data', (chunk: Buffer) => { 110 + stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP) 111 + }) 112 + const done = new Promise<number | null>(resolve => child.on('close', code => resolve(code))) 113 + return { 114 + stdin: child.stdin, 115 + stdout: child.stdout, 116 + stderr: () => stderrBuf.toString('utf8'), 117 + kill: () => child.kill('SIGKILL'), 118 + done, 119 + } 120 + } 121 + }