| author | Divy Srivastava <me@littledivy.com> | 2026-08-18 12:45:57 +0530 |
|---|---|---|
| committer | Divy Srivastava <me@littledivy.com> | 2026-08-18 13:49:26 +0530 |
| commit | 1af7b19b36d9720582af237d1bcfdd417fa9ce3e (patch) | |
| tree | 85efc9106ef04bc48820dc10f0560ae436e43afd | |
| download | dgit-1af7b19b36.tar.gz zip | |
initial commit
Diffstat
| .gitignore (new) | +4 | -0 |
| LICENSE (new) | +21 | -0 |
| README.md (new) | +98 | -0 |
| package-lock.json (new) | +1589 | -0 |
| package.json (new) | +20 | -0 |
| scripts/e2e.sh (new) | +160 | -0 |
| src/env.ts (new) | +15 | -0 |
| src/git/blame.ts (new) | +87 | -0 |
| src/git/diff.ts (new) | +136 | -0 |
| src/git/objects.ts (new) | +136 | -0 |
| src/git/oidset.ts (new) | +91 | -0 |
| src/git/pack.ts (new) | +234 | -0 |
| src/git/packstore.ts (new) | +586 | -0 |
| src/git/pktline.ts (new) | +46 | -0 |
| src/git/protocol.ts (new) | +491 | -0 |
| src/git/sha1.ts (new) | +116 | -0 |
| src/git/snapshot.ts (new) | +144 | -0 |
| src/git/store.ts (new) | +178 | -0 |
| src/git/util.ts (new) | +39 | -0 |
| src/git/zlib.ts (new) | +29 | -0 |
| src/index.ts (new) | +251 | -0 |
| src/registry.ts (new) | +88 | -0 |
| src/repo.ts (new) | +1365 | -0 |
| src/ui/highlight.ts (new) | +186 | -0 |
| src/ui/html.ts (new) | +138 | -0 |
| src/ui/markdown.ts (new) | +143 | -0 |
| src/ui/style.ts (new) | +420 | -0 |
| tsconfig.json (new) | +15 | -0 |
| wrangler.celld.jsonc (new) | +25 | -0 |
| wrangler.jsonc (new) | +31 | -0 |
30 files changed, 6882 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +.dev.vars +dist/ diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Divy Srivastava + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md @@ -0,0 +1,98 @@ +# dgit + +Durable **git**. + +dgit is a git server for Cloudflare Workers and for your own machines +with [celld](https://celld.dev). Each repository is a Durable Object: a +small server with a name and a private SQLite database that holds the +repository's objects and refs, speaks the git smart HTTP protocol to a +stock git client, and renders a cgit-style web interface. There is no +origin server, no filesystem, and no GitHub in the critical path. A +repository nobody touches costs almost nothing, and applications shard +by construction: one hot repository cannot slow another. Reads are +public; pushes authenticate; pushing to a name that does not exist +creates the repository. + +## How it works + +dgit implements git in TypeScript: pkt-line framing, packfile parsing +with ofs- and ref-delta resolution, pack generation over a streaming +SHA-1, commit/tree/tag codecs, and a Myers diff. The one dependency is +pako, for zlib. + +A push streams into the repository's cell and is stored as the packfile +the client sent; an index maps each object id to its pack, offset, and +delta base, so the client's compression is preserved rather than +re-derived. A clone walks the closure of the requested refs and copies +the stored compressed bytes verbatim into the outgoing pack. Fetch +negotiation excludes the closure of the client's haves, cut correctly +at shallow boundaries, so an incremental fetch downloads only what is +missing. Shallow clones (`--depth`, deepening, `--unshallow`), thin +packs, side-band progress, forced updates, and ref deletion behave as +they do against any git server. + +The web interface is the cgit surface: summary, refs, log with search +and per-path history, tree, blob with syntax highlighting, blame, +commit and arbitrary-range diffs, `format-patch` output that applies +cleanly with `git am`, tar.gz and zip snapshots of any ref, about pages +rendered from the README, atom feeds, and commit-activity statistics. +Repositories carry a description, an owner, a section on the index +page, and a private flag that hides them and gates every read behind +the push token. + +## Deploy to Cloudflare + +```sh +npm install +npx wrangler deploy +npx wrangler secret put GIT_TOKEN # the push password +``` + +Then push anything: + +```sh +git remote add origin https://<your-host>/myrepo.git +git push -u origin main +``` + +A Workers request is bounded at 128MB of memory and five minutes of +CPU, so a very large history lands as a series of smaller pushes rather +than one; day-to-day pushes, clones, and fetches fit comfortably. A +full-history clone of a repository with millions of objects can exceed +the CPU bound — shallow and incremental fetches of the same repository +are fine. + +## Self-host on celld + +celld runs the same Worker against a bucket you own, with none of the +managed platform's request bounds. Set a real `GIT_TOKEN` var in +`wrangler.celld.jsonc` first: + +```sh +celld deploy wrangler.celld.jsonc --bucket s3://my-cells --endpoint https://... +CELLD_V8_HEAP_LIMIT_MB=4096 CELLD_LTX_DURABILITY_TIMEOUT_SECS=180 \ +celld --bucket s3://my-cells --endpoint https://... \ + --listen 0.0.0.0:8080 --internal-listen 10.0.0.1:8081 --advertise 10.0.0.1:8081 +``` + +Each repository's SQLite database replicates to the bucket; nodes are +disposable, and a killed node's repositories come back bit-identical. +The heap and durability-deadline variables give large single-cell +ingests the room the defaults do not. + +## Operate + +```sh +curl -X PUT -u x:$GIT_TOKEN -d '{"description":"...","section":"tools","private":false}' \ + https://<host>/myrepo/config # describe and place a repository +curl -X POST -u x:$GIT_TOKEN https://<host>/myrepo/gc # prune unreachable objects +curl -X DELETE -u x:$GIT_TOKEN https://<host>/myrepo # delete a repository +``` + +Garbage collection also runs by itself, from a Durable Object alarm, +after a forced update or a ref deletion. `GIT_TOKENS` holds additional +comma-separated tokens; `MAX_PUSH_MB` caps a single push. + +## Contributions + +Pull requests are disabled. Send a `git format-patch` attachment to [me@littledivy.com](mailto:me@littledivy.com). diff --git a/package-lock.json b/package-lock.json @@ -0,0 +1,1589 @@ +{ + "name": "dgit", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dgit", + "version": "0.1.0", + "dependencies": { + "pako": "^2.1.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260811.1", + "@types/pako": "^2.0.3", + "typescript": "^5.5.0", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260811.1.tgz", + "integrity": "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260811.1.tgz", + "integrity": "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260811.1.tgz", + "integrity": "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260811.1.tgz", + "integrity": "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260811.1.tgz", + "integrity": "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260818.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260818.1.tgz", + "integrity": "sha512-a89taQDbqb7Ni+xAVSsiOSd5wQPcbBJBnZgIG3EujVdDcdQkGVwab3xa2e9z29uqtMQb/P2gYDeDcNXcBRSWQQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true, + "license": "MIT" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260811.1-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260811.1-alpha.tgz", + "integrity": "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260811.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260811.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260811.1.tgz", + "integrity": "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260811.1", + "@cloudflare/workerd-darwin-arm64": "1.20260811.1", + "@cloudflare/workerd-linux-64": "1.20260811.1", + "@cloudflare/workerd-linux-arm64": "1.20260811.1", + "@cloudflare/workerd-windows-64": "1.20260811.1" + } + }, + "node_modules/wrangler": { + "version": "4.123.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.123.0.tgz", + "integrity": "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260811.1-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260811.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260811.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json @@ -0,0 +1,20 @@ +{ + "name": "dgit", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "pako": "^2.1.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260811.1", + "@types/pako": "^2.0.3", + "typescript": "^5.5.0", + "wrangler": "^4.0.0" + } +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# cdgit end-to-end suite. Usage: e2e.sh <base-url> <token> +BASE=${1:-http://127.0.0.1:8787} +TOKEN=${2:-devtoken} +HOSTPORT=${BASE#http://}; HOSTPORT=${HOSTPORT#https://} +AUTH_BASE=$(echo "$BASE" | sed "s|://|://x:$TOKEN@|") +DIR=$(mktemp -d /tmp/cgit-e2e.XXXXXX) +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok: $1"; } +fail() { FAIL=$((FAIL+1)); echo "FAIL: $1"; } +check() { if [ "$1" = "$2" ]; then ok "$3"; else fail "$3 (got '$1' want '$2')"; fi } + +cd "$DIR" +export GIT_TERMINAL_PROMPT=0 +GITC="git -c credential.helper= -c advice.detachedHead=false" + +echo "== setup: source repo with history, tags, binary, symlink ==" +git init -q -b main src && cd src +git config user.email dev@example.com && git config user.name "Dev One" +cat > README.md <<'EOF' +# demo project + +A **demo** for `cdgit`. + +- clone it +- push it + +```js +const x = 1; +``` +EOF +mkdir -p lib docs +printf 'export function add(a, b) {\n return a + b;\n}\n' > lib/math.js +printf 'hello docs\n' > docs/guide.txt +ln -s lib/math.js mathlink.js +git add -A && git commit -qm "initial commit" +printf 'export function add(a, b) {\n return a + b;\n}\n\nexport function mul(a, b) {\n return a * b;\n}\n' > lib/math.js +git add -A && git commit -qm "add mul function" --author="Dev Two <two@example.com>" +head -c 2500000 /dev/urandom > big.bin +git add -A && git commit -qm "add binary asset" +git tag -a v1.0 -m "release one point oh" +git tag lightweight-tag +for i in 1 2 3 4 5; do echo "line $i" >> docs/guide.txt; git add -A; git commit -qm "guide update $i"; done +cd "$DIR" + +# clean slate for repeat runs +curl -s -o /dev/null -X DELETE -u "x:$TOKEN" "$BASE/demo" +curl -s -o /dev/null -X DELETE -u "x:$TOKEN" "$BASE/private-repo" + +echo "== 1. push ==" +(cd src && $GITC push -q "$AUTH_BASE/demo.git" main --tags); check $? 0 "initial push" + +echo "== 2. full clone + fsck + content ==" +$GITC clone -q "$BASE/demo.git" full 2>/dev/null; check $? 0 "clone" +(cd full && git fsck --strict > /dev/null 2>&1); check $? 0 "fsck --strict" +diff -r --exclude=.git src full > /dev/null; check $? 0 "content identical" +check "$(cd full && git tag | tr '\n' ' ')" "$(cd src && git tag | tr '\n' ' ')" "tags match" + +echo "== 3. shallow clone ==" +$GITC clone -q --depth 1 "$BASE/demo.git" shallow 2>/dev/null; check $? 0 "clone --depth 1" +check "$(cd shallow && git log --oneline 2>/dev/null | wc -l | tr -d ' ')" "1" "shallow has 1 commit" +(cd shallow && test -f .git/shallow); check $? 0 ".git/shallow exists" +(cd shallow && $GITC fetch -q --depth 3 2>/dev/null); check $? 0 "deepen to 3" +check "$(cd shallow && git log --oneline | wc -l | tr -d ' ')" "3" "deepened to 3 commits" +(cd shallow && $GITC fetch -q --unshallow 2>/dev/null); check $? 0 "unshallow" +check "$(cd shallow && git log --oneline | wc -l | tr -d ' ')" "$(cd src && git log --oneline | wc -l | tr -d ' ')" "unshallow = full history" +(cd shallow && git fsck > /dev/null 2>&1); check $? 0 "fsck after unshallow" + +echo "== 4. incremental fetch is small ==" +(cd src && echo "tweak" >> README.md && git add -A && git commit -qm "small tweak" && $GITC push -q "$AUTH_BASE/demo.git" main) +out=$(cd full && $GITC fetch origin 2>&1) +objs=$(echo "$out" | grep -o "Enumerating objects: [0-9]*" | grep -o "[0-9]*") +if [ -n "$objs" ] && [ "$objs" -le 5 ]; then ok "incremental fetch sent $objs objects (not whole repo)"; else fail "incremental fetch sent '$objs' objects"; fi +(cd full && $GITC merge -q --ff-only origin/main && git fsck > /dev/null 2>&1); check $? 0 "merged + fsck" + +echo "== 5. sideband progress ==" +prog=$($GITC clone "$BASE/demo.git" progress-test 2>&1 | grep -c "remote:"); rm -rf progress-test +if [ "$prog" -ge 1 ]; then ok "server progress shown via sideband"; else fail "no sideband progress"; fi + +echo "== 6. force push + gc ==" +(cd src && git commit -q --amend -m "small tweak (rewritten)" && $GITC push -q --force "$AUTH_BASE/demo.git" main); check $? 0 "force push" +gc="" +for attempt in 1 2 3; do + gc=$(curl -s -X POST -u "x:$TOKEN" "$BASE/demo/gc") + echo "$gc" | grep -q '"removed"' && break + sleep 3 # celld may still be proving durability of the push burst +done +echo " gc says: $gc" +echo "$gc" | grep -q '"removed":[1-9]'; check $? 0 "gc removed stranded objects" +$GITC clone -q "$BASE/demo.git" post-gc 2>/dev/null && (cd post-gc && git fsck > /dev/null 2>&1); check $? 0 "clone+fsck after gc" + +echo "== 7. branch create/delete, non-ff rejection ==" +(cd src && git checkout -qb feature && echo f > f.txt && git add -A && git commit -qm "feature work" && $GITC push -q "$AUTH_BASE/demo.git" feature && git checkout -q main); check $? 0 "branch push" +(cd src && $GITC push -q "$AUTH_BASE/demo.git" :feature); check $? 0 "branch delete" +(cd full && git commit -q --allow-empty -m "diverge" && $GITC push -q "$AUTH_BASE/demo.git" main > /dev/null 2>&1); check $? 1 "non-ff push rejected" +(cd full && git reset -q --hard origin/main) + +echo "== 8. snapshots ==" +curl -s -o snap.tar.gz "$BASE/demo/snapshot/demo-v1.0.tar.gz" +mkdir -p snap && tar xzf snap.tar.gz -C snap; check $? 0 "tar.gz extracts" +(cd src && git checkout -q v1.0 && diff -r --exclude=.git . "$DIR/snap/demo-v1.0" > /dev/null; r=$?; git checkout -q main; exit $r); check $? 0 "tar.gz content matches v1.0" +test -L snap/demo-v1.0/mathlink.js; check $? 0 "symlink preserved in tar" +curl -s -o snap.zip "$BASE/demo/snapshot/demo-v1.0.zip" +mkdir -p snapz && (cd snapz && unzip -qo ../snap.zip); check $? 0 "zip extracts" +diff snap/demo-v1.0/README.md snapz/demo-v1.0/README.md > /dev/null; check $? 0 "zip content matches" + +echo "== 9. patch applies with git am; rawdiff with git apply ==" +tip=$(cd src && git rev-parse HEAD) +curl -s "$BASE/demo/patch/?id=$tip" -o tip.patch +git init -q -b main am-test && (cd am-test && git config user.email t@t && git config user.name t \ + && $GITC fetch -q "$BASE/demo.git" "$tip" && git checkout -q "$tip~1" \ + && git am -q "$DIR/tip.patch"); check $? 0 "git am applies /patch output" +check "$(cd am-test && git diff --stat HEAD "$tip" | wc -l | tr -d ' ')" "0" "am result tree == original commit" +curl -s "$BASE/demo/rawdiff/?id=$tip" -o tip.diff +(cd am-test && git checkout -q "$tip~1" && git apply --check "$DIR/tip.diff"); check $? 0 "git apply accepts /rawdiff output" + +echo "== 10. UI pages ==" +for p in "/demo/" "/demo/about/" "/demo/refs/" "/demo/log/" "/demo/log/?qt=author&q=two" "/demo/log/?path=docs/guide.txt" "/demo/tree/" "/demo/tree/lib/math.js" "/demo/blame/lib/math.js" "/demo/commit/" "/demo/diff/" "/demo/stats/" "/demo/stats/?period=w" "/demo/tag/?h=v1.0" "/demo/atom/"; do + code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$p") + check "$code" "200" "GET $p" +done +curl -s "$BASE/demo/about/" | grep -q "<h1>demo project</h1>"; check $? 0 "about renders markdown h1" +curl -s "$BASE/demo/about/" | grep -q "<strong>demo</strong>"; check $? 0 "about renders bold" +curl -s "$BASE/demo/log/?qt=author&q=two" | grep -q "add mul function"; check $? 0 "author search finds commit" +n=$(curl -s "$BASE/demo/log/?qt=author&q=two" | grep -c "Dev One"); check "$n" "0" "author search excludes others" +curl -s "$BASE/demo/log/?path=docs/guide.txt" | grep -q "guide update 5"; check $? 0 "path-limited log" +curl -s "$BASE/demo/tree/lib/math.js" | grep -q "hl-k"; check $? 0 "syntax highlighting present" +curl -s "$BASE/demo/blame/lib/math.js" | grep -q "add mul"; fbcode=$? +curl -s "$BASE/demo/blame/lib/math.js" | grep -q "sha1"; check $? 0 "blame page has attributions" +curl -s "$BASE/demo/tag/?h=v1.0" | grep -q "release one point oh"; check $? 0 "tag page shows message" +curl -s "$BASE/demo/atom/" | grep -q "<feed xmlns"; check $? 0 "atom feed" +blob=$(cd src && git rev-parse "HEAD:lib/math.js") +curl -s "$BASE/demo/blob/?id=$blob" | grep -q "function add"; check $? 0 "blob by id" + +echo "== 11. config: description/section/owner + index sections ==" +curl -s -X PUT -u "x:$TOKEN" -d '{"description":"a lovely demo","section":"experiments","owner":"divy"}' "$BASE/demo/config" > /dev/null +curl -s "$BASE/" | grep -q "a lovely demo"; check $? 0 "index shows description" +curl -s "$BASE/" | grep -q "reposection.*experiments\|experiments"; check $? 0 "index shows section" + +echo "== 12. private repos ==" +git init -q -b main priv && (cd priv && git config user.email t@t && git config user.name t && echo secret > s.txt && git add -A && git commit -qm secret && $GITC push -q "$AUTH_BASE/private-repo.git" main) +curl -s -X PUT -u "x:$TOKEN" -d '{"private":true}' "$BASE/private-repo/config" > /dev/null +check "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/private-repo/")" "401" "private UI needs auth" +check "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/private-repo.git/info/refs?service=git-upload-pack")" "401" "private clone needs auth" +$GITC clone -q "$BASE/private-repo.git" priv-anon 2>/dev/null; check $? 128 "anonymous clone of private repo fails" +$GITC clone -q "$AUTH_BASE/private-repo.git" priv-auth 2>/dev/null; check $? 0 "authed clone of private repo" +curl -s "$BASE/" | grep -q "private-repo"; check $? 1 "private repo hidden from index" + +echo "== 13. delete repo ==" +check "$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -u "x:$TOKEN" "$BASE/private-repo")" "200" "DELETE repo" +check "$(curl -s -o /dev/null -w '%{http_code}' -u "x:$TOKEN" "$BASE/private-repo/")" "404" "deleted repo is 404" + +echo "== 14. auth hard checks ==" +check "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/demo.git/info/refs?service=git-receive-pack")" "401" "push advert needs auth" +check "$(curl -s -o /dev/null -w '%{http_code}' -u "x:wrong" -X POST "$BASE/demo.git/git-receive-pack")" "401" "wrong token rejected" +check "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/nope.git/info/refs?service=git-upload-pack")" "404" "unknown repo 404" + +echo +echo "=== RESULT: $PASS passed, $FAIL failed (workdir $DIR) ===" +exit $((FAIL > 0)) diff --git a/src/env.ts b/src/env.ts @@ -0,0 +1,15 @@ +import type { Registry } from "./registry"; + +export interface Env { + REPO: DurableObjectNamespace; + REGISTRY: DurableObjectNamespace<Registry>; + /** shared secret required for git push (Basic auth password) */ + GIT_TOKEN?: string; + /** optional comma-separated extra tokens (multiple users) */ + GIT_TOKENS?: string; + /** max accepted push size in MB (default 512) */ + MAX_PUSH_MB?: string; + SITE_NAME?: string; + SITE_DESC?: string; + SITE_OWNER?: string; +} diff --git a/src/git/blame.ts b/src/git/blame.ts @@ -0,0 +1,87 @@ +import { td } from "./util"; +import { diffLines } from "./diff"; +import { Commit } from "./objects"; + +export interface BlameLine { + line: string; + oid: string; + author: string; + time: number; +} + +export interface BlameHistoryEntry { + oid: string; + commit: Commit; + /** blob content of the target path at this commit, null if absent */ + blob: Uint8Array | null; +} + +const MAX_BLAME_LINES = 5000; + +/** + * Line attribution over first-parent history. `history` is the path-limited + * log, newest first, where each entry carries the file's blob at that commit. + * Walking from the tip backwards: lines that disappear when stepping to the + * previous version were introduced by the commit being examined. + */ +export function blame(history: BlameHistoryEntry[]): BlameLine[] | null { + if (!history.length || !history[0].blob) return null; + const tipText = td.decode(history[0].blob); + const tipLines = tipText.split("\n"); + if (tipLines[tipLines.length - 1] === "") tipLines.pop(); + if (tipLines.length > MAX_BLAME_LINES) return null; + + const owner: (BlameHistoryEntry | null)[] = tipLines.map(() => null); + // pos[i] = line number of tip line i in the version currently being examined + const pos: number[] = tipLines.map((_, i) => i); + + for (let h = 0; h < history.length; h++) { + const cur = history[h]; + const parent = history[h + 1]; + const curText = cur.blob ? td.decode(cur.blob) : ""; + const parentText = parent?.blob ? td.decode(parent.blob) : ""; + + if (!parent || !parent.blob) { + // file was created here: everything still unowned belongs to this commit + for (let i = 0; i < owner.length; i++) { + if (!owner[i] && pos[i] >= 0) owner[i] = cur; + } + break; + } + + const ops = diffLines(parentText, curText); + if (!ops) { + for (let i = 0; i < owner.length; i++) if (!owner[i] && pos[i] >= 0) owner[i] = cur; + break; + } + // map: line index in cur -> line index in parent (only for unchanged lines) + const eqMap = new Map<number, number>(); + let ci = 0, pi = 0; + for (const op of ops) { + if (op.tag === "eq") { + eqMap.set(ci, pi); + ci++; + pi++; + } else if (op.tag === "add") { + ci++; + } else { + pi++; + } + } + for (let i = 0; i < owner.length; i++) { + if (owner[i] || pos[i] < 0) continue; + const mapped = eqMap.get(pos[i]); + if (mapped === undefined) { + owner[i] = cur; // introduced (or last touched) by this commit + pos[i] = -1; + } else { + pos[i] = mapped; + } + } + } + const oldest = history[history.length - 1]; + return tipLines.map((line, i) => { + const o = owner[i] ?? oldest; + return { line, oid: o.oid, author: o.commit.author.name, time: o.commit.author.time }; + }); +} diff --git a/src/git/diff.ts b/src/git/diff.ts @@ -0,0 +1,136 @@ +export interface DiffOp { + tag: "eq" | "del" | "add"; + line: string; +} + +export interface Hunk { + aStart: number; + aLen: number; + bStart: number; + bLen: number; + ops: DiffOp[]; +} + +const MAX_LINES = 40000; + +/** Myers O(ND) line diff. Returns null when the input is too large. */ +export function diffLines(aText: string, bText: string): DiffOp[] | null { + const a = aText.split("\n"); + const b = bText.split("\n"); + if (a[a.length - 1] === "") a.pop(); + if (b[b.length - 1] === "") b.pop(); + const N = a.length, M = b.length; + if (N + M > MAX_LINES) return null; + + const max = N + M; + const offset = max; + const v = new Int32Array(2 * max + 2); + const trace: Int32Array[] = []; + let dFound = -1; + outer: for (let d = 0; d <= max; d++) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + let x: number; + if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) { + x = v[offset + k + 1]; + } else { + x = v[offset + k - 1] + 1; + } + let y = x - k; + while (x < N && y < M && a[x] === b[y]) { + x++; + y++; + } + v[offset + k] = x; + if (x >= N && y >= M) { + dFound = d; + break outer; + } + } + } + + const ops: DiffOp[] = []; + let x = N, y = M; + for (let d = dFound; d > 0; d--) { + const vPrev = trace[d]; + const k = x - y; + const prevK = + k === -d || (k !== d && vPrev[offset + k - 1] < vPrev[offset + k + 1]) ? k + 1 : k - 1; + const prevX = vPrev[offset + prevK]; + const prevY = prevX - prevK; + while (x > prevX && y > prevY) { + ops.push({ tag: "eq", line: a[--x] }); + y--; + } + if (x === prevX) { + ops.push({ tag: "add", line: b[--y] }); + } else { + ops.push({ tag: "del", line: a[--x] }); + } + } + while (x > 0) { + ops.push({ tag: "eq", line: a[--x] }); + y--; + } + ops.reverse(); + return ops; +} + +/** Group diff ops into unified hunks with `context` lines of context. */ +export function toHunks(ops: DiffOp[], context = 3): Hunk[] { + // indexes of non-eq ops + const changes: number[] = []; + ops.forEach((op, i) => { + if (op.tag !== "eq") changes.push(i); + }); + if (!changes.length) return []; + + // merge change ranges whose context windows touch + const ranges: [number, number][] = []; + let start = changes[0], end = changes[0]; + for (const i of changes.slice(1)) { + if (i - end <= context * 2) { + end = i; + } else { + ranges.push([start, end]); + start = end = i; + } + } + ranges.push([start, end]); + + const hunks: Hunk[] = []; + let aLine = 1, bLine = 1, opIdx = 0; + for (const [s, e] of ranges) { + const from = Math.max(0, s - context); + const to = Math.min(ops.length - 1, e + context); + // advance line counters up to `from` + while (opIdx < from) { + const op = ops[opIdx++]; + if (op.tag !== "add") aLine++; + if (op.tag !== "del") bLine++; + } + const hunk: Hunk = { aStart: aLine, aLen: 0, bStart: bLine, bLen: 0, ops: [] }; + while (opIdx <= to) { + const op = ops[opIdx++]; + hunk.ops.push(op); + if (op.tag !== "add") { + hunk.aLen++; + aLine++; + } + if (op.tag !== "del") { + hunk.bLen++; + bLine++; + } + } + if (hunk.aLen === 0) hunk.aStart = aLine - 1; + if (hunk.bLen === 0) hunk.bStart = bLine - 1; + hunks.push(hunk); + } + return hunks; +} + +export function isBinary(data: Uint8Array): boolean { + const n = Math.min(data.length, 8000); + for (let i = 0; i < n; i++) if (data[i] === 0) return true; + return false; +} diff --git a/src/git/objects.ts b/src/git/objects.ts @@ -0,0 +1,136 @@ +import { te, td, toHex, concat, sha1hex } from "./util"; + +export type ObjType = "commit" | "tree" | "blob" | "tag"; + +export const TYPE_NUM: Record<ObjType, number> = { commit: 1, tree: 2, blob: 3, tag: 4 }; +export const NUM_TYPE: Record<number, ObjType> = { 1: "commit", 2: "tree", 3: "blob", 4: "tag" }; + +export function objectHeader(type: ObjType, size: number): Uint8Array { + return te.encode(`${type} ${size}\0`); +} + +export function hashObject(type: ObjType, data: Uint8Array): string { + return sha1hex(concat([objectHeader(type, data.length), data])); +} + +export interface Person { + name: string; + email: string; + /** unix seconds */ + time: number; + tz: string; +} + +export interface Commit { + tree: string; + parents: string[]; + author: Person; + committer: Person; + message: string; + /** first line of the message */ + subject: string; +} + +export interface Tag { + object: string; + type: string; + tag: string; + tagger: Person | null; + message: string; +} + +export interface TreeEntry { + mode: string; // e.g. "100644", "40000", "120000", "160000" + name: string; + oid: string; +} + +function parsePerson(line: string): Person { + // "Name <email> 1234567890 +0100" + const m = line.match(/^(.*?) <(.*?)> (\d+) ([+-]\d{4})$/); + if (!m) return { name: line, email: "", time: 0, tz: "+0000" }; + return { name: m[1], email: m[2], time: parseInt(m[3], 10), tz: m[4] }; +} + +function splitHeaders(text: string): { headers: [string, string][]; message: string } { + const nn = text.indexOf("\n\n"); + const head = nn === -1 ? text : text.slice(0, nn); + const message = nn === -1 ? "" : text.slice(nn + 2); + const headers: [string, string][] = []; + for (const line of head.split("\n")) { + // continuation lines (gpgsig etc.) start with a space; append to previous + if (line.startsWith(" ") && headers.length) { + headers[headers.length - 1][1] += "\n" + line.slice(1); + continue; + } + const sp = line.indexOf(" "); + if (sp > 0) headers.push([line.slice(0, sp), line.slice(sp + 1)]); + } + return { headers, message }; +} + +export function parseCommit(data: Uint8Array): Commit { + const { headers, message } = splitHeaders(td.decode(data)); + const c: Commit = { + tree: "", + parents: [], + author: { name: "", email: "", time: 0, tz: "+0000" }, + committer: { name: "", email: "", time: 0, tz: "+0000" }, + message, + subject: message.split("\n", 1)[0] ?? "", + }; + for (const [k, v] of headers) { + if (k === "tree") c.tree = v; + else if (k === "parent") c.parents.push(v); + else if (k === "author") c.author = parsePerson(v); + else if (k === "committer") c.committer = parsePerson(v); + } + return c; +} + +export function parseTag(data: Uint8Array): Tag { + const { headers, message } = splitHeaders(td.decode(data)); + const t: Tag = { object: "", type: "", tag: "", tagger: null, message }; + for (const [k, v] of headers) { + if (k === "object") t.object = v; + else if (k === "type") t.type = v; + else if (k === "tag") t.tag = v; + else if (k === "tagger") t.tagger = parsePerson(v); + } + return t; +} + +export function parseTree(data: Uint8Array): TreeEntry[] { + const entries: TreeEntry[] = []; + let pos = 0; + while (pos < data.length) { + let sp = pos; + while (data[sp] !== 0x20) sp++; + const mode = td.decode(data.subarray(pos, sp)); + let nul = sp + 1; + while (data[nul] !== 0) nul++; + const name = td.decode(data.subarray(sp + 1, nul)); + const oid = toHex(data.subarray(nul + 1, nul + 21)); + entries.push({ mode, name, oid }); + pos = nul + 21; + } + return entries; +} + +export function isTreeMode(mode: string): boolean { + return mode === "40000" || mode === "040000"; +} + +export function isGitlinkMode(mode: string): boolean { + return mode === "160000"; +} + +/** Render a tree-entry mode the way cgit/ls-tree does: d rwx r-x r-x etc. */ +export function modeString(mode: string): string { + const m = parseInt(mode, 8); + if (isTreeMode(mode)) return "d---------"; + if (isGitlinkMode(mode)) return "m---------"; + if ((m & 0o170000) === 0o120000) return "lrwxrwxrwx"; + const bits = (n: number) => `${n & 4 ? "r" : "-"}${n & 2 ? "w" : "-"}${n & 1 ? "x" : "-"}`; + return `-${bits((m >> 6) & 7)}${bits((m >> 3) & 7)}${bits(m & 7)}`; +} diff --git a/src/git/oidset.ts b/src/git/oidset.ts @@ -0,0 +1,91 @@ +import { fromHex, toHex } from "./util"; + +/** + * Memory-compact set + insertion-ordered list of object ids. A JS + * Set<string> of 40-char hex strings costs ~100 bytes per entry — at + * Linux scale (10M objects) that is gigabytes. This stores raw 20-byte + * digests in typed arrays: ~20B per entry plus a u32 open-addressing table. + */ +export class OidSet { + private table: Uint32Array; // 1-based indices into the entry list; 0 = empty + private mask: number; + private data: Uint8Array; // 20 bytes per entry, insertion order + private count = 0; + + constructor(expected = 1024) { + let cap = 2048; + while (cap < expected * 2) cap *= 2; + this.table = new Uint32Array(cap); + this.mask = cap - 1; + this.data = new Uint8Array(Math.max(expected, 1024) * 20); + } + + get size(): number { + return this.count; + } + + private hashAt(bytes: Uint8Array, off: number): number { + // oids are uniformly random; the first 4 bytes are a fine hash + return ((bytes[off] << 24) | (bytes[off + 1] << 16) | (bytes[off + 2] << 8) | bytes[off + 3]) >>> 0; + } + + private equalsEntry(idx: number, bytes: Uint8Array, off: number): boolean { + const base = idx * 20; + for (let i = 0; i < 20; i++) { + if (this.data[base + i] !== bytes[off + i]) return false; + } + return true; + } + + private grow(): void { + const newTable = new Uint32Array(this.table.length * 2); + const newMask = newTable.length - 1; + for (let i = 0; i < this.count; i++) { + let slot = this.hashAt(this.data, i * 20) & newMask; + while (newTable[slot] !== 0) slot = (slot + 1) & newMask; + newTable[slot] = i + 1; + } + this.table = newTable; + this.mask = newMask; + } + + /** Adds a hex oid; returns false if it was already present. */ + addHex(hex: string): boolean { + return this.addBytes(fromHex(hex), 0); + } + + addBytes(bytes: Uint8Array, off: number): boolean { + if ((this.count + 1) * 2 > this.table.length) this.grow(); + let slot = this.hashAt(bytes, off) & this.mask; + while (this.table[slot] !== 0) { + if (this.equalsEntry(this.table[slot] - 1, bytes, off)) return false; + slot = (slot + 1) & this.mask; + } + if ((this.count + 1) * 20 > this.data.length) { + const bigger = new Uint8Array(this.data.length * 2); + bigger.set(this.data); + this.data = bigger; + } + this.data.set(bytes.subarray(off, off + 20), this.count * 20); + this.table[slot] = ++this.count; + return true; + } + + hasHex(hex: string): boolean { + return this.hasBytes(fromHex(hex), 0); + } + + hasBytes(bytes: Uint8Array, off: number): boolean { + let slot = this.hashAt(bytes, off) & this.mask; + while (this.table[slot] !== 0) { + if (this.equalsEntry(this.table[slot] - 1, bytes, off)) return true; + slot = (slot + 1) & this.mask; + } + return false; + } + + /** Hex oid of the i-th inserted entry. */ + atHex(i: number): string { + return toHex(this.data.subarray(i * 20, i * 20 + 20)); + } +} diff --git a/src/git/pack.ts b/src/git/pack.ts @@ -0,0 +1,234 @@ +import { te, td, toHex, fromHex, concat, sha1hex, Sha1 } from "./util"; +import { deflate, inflateEntry } from "./zlib"; +import { ObjType, NUM_TYPE, TYPE_NUM, objectHeader, hashObject } from "./objects"; + +export interface PackedObject { + oid: string; + type: ObjType; + data: Uint8Array; +} + +const OFS_DELTA = 6; +const REF_DELTA = 7; + +interface RawEntry { + offset: number; // offset of the entry header within the pack (ofs-delta base key) + type: number; + data: Uint8Array; // object content, or delta payload for delta entries + baseOffset?: number; + baseOid?: string; + resolved?: { type: ObjType; data: Uint8Array }; +} + +export function applyDelta(base: Uint8Array, delta: Uint8Array): Uint8Array { + let pos = 0; + const varint = () => { + let r = 0, shift = 0, b: number; + do { + b = delta[pos++]; + r += (b & 0x7f) * 2 ** shift; + shift += 7; + } while (b & 0x80); + return r; + }; + const srcSize = varint(); + const tgtSize = varint(); + if (srcSize !== base.length) throw new Error("delta base size mismatch"); + const out = new Uint8Array(tgtSize); + let op = 0; + while (pos < delta.length) { + const cmd = delta[pos++]; + if (cmd & 0x80) { + // copy from base + let off = 0, size = 0; + if (cmd & 0x01) off = delta[pos++]; + if (cmd & 0x02) off |= delta[pos++] << 8; + if (cmd & 0x04) off |= delta[pos++] << 16; + if (cmd & 0x08) off += delta[pos++] * 0x1000000; + if (cmd & 0x10) size = delta[pos++]; + if (cmd & 0x20) size |= delta[pos++] << 8; + if (cmd & 0x40) size |= delta[pos++] << 16; + if (size === 0) size = 0x10000; + out.set(base.subarray(off, off + size), op); + op += size; + } else if (cmd) { + // insert literal + out.set(delta.subarray(pos, pos + cmd), op); + op += cmd; + pos += cmd; + } else { + throw new Error("invalid delta opcode 0"); + } + } + if (op !== tgtSize) throw new Error("delta target size mismatch"); + return out; +} + +/** + * Parse and index a packfile: inflate every entry, resolve ofs/ref deltas + * (including thin-pack bases fetched through `getBase`), and hash each object. + */ +export function indexPack( + pack: Uint8Array, + getBase: (oid: string) => { type: ObjType; data: Uint8Array } | null +): PackedObject[] { + if (pack.length < 32 || td.decode(pack.subarray(0, 4)) !== "PACK") + throw new Error("bad pack signature"); + const view = new DataView(pack.buffer, pack.byteOffset, pack.byteLength); + const version = view.getUint32(4); + if (version !== 2 && version !== 3) throw new Error(`unsupported pack version ${version}`); + const count = view.getUint32(8); + + // verify trailer checksum + const trailer = toHex(pack.subarray(pack.length - 20)); + const actual = sha1hex(pack.subarray(0, pack.length - 20)); + if (trailer !== actual) throw new Error("pack checksum mismatch"); + + const entries: RawEntry[] = []; + let pos = 12; + for (let i = 0; i < count; i++) { + const offset = pos; + let byte = pack[pos++]; + const type = (byte >> 4) & 7; + while (byte & 0x80) { + byte = pack[pos++]; + } + const entry: RawEntry = { offset, type, data: new Uint8Array(0) }; + if (type === OFS_DELTA) { + byte = pack[pos++]; + let off = byte & 0x7f; + while (byte & 0x80) { + byte = pack[pos++]; + off = (off + 1) * 128 + (byte & 0x7f); + } + entry.baseOffset = offset - off; + } else if (type === REF_DELTA) { + entry.baseOid = toHex(pack.subarray(pos, pos + 20)); + pos += 20; + } else if (!NUM_TYPE[type]) { + throw new Error(`bad object type ${type} at ${offset}`); + } + const { data, end } = inflateEntry(pack, pos); + entry.data = data; + pos = end; + if (type !== OFS_DELTA && type !== REF_DELTA) { + entry.resolved = { type: NUM_TYPE[type], data }; + } + entries.push(entry); + } + + // resolve deltas; bases can be other pack entries or (thin pack) store objects + const byOffset = new Map<number, RawEntry>(); + for (const e of entries) byOffset.set(e.offset, e); + const oidOf = new Map<RawEntry, string>(); + const byOid = new Map<string, RawEntry>(); + + const hashEntry = (e: RawEntry) => { + if (!e.resolved || oidOf.has(e)) return; + const oid = hashObject(e.resolved.type, e.resolved.data); + oidOf.set(e, oid); + byOid.set(oid, e); + }; + for (const e of entries) hashEntry(e); + + let unresolved = entries.filter((e) => !e.resolved); + while (unresolved.length) { + let progress = false; + const next: RawEntry[] = []; + for (const e of unresolved) { + let base: { type: ObjType; data: Uint8Array } | null = null; + if (e.baseOffset !== undefined) { + base = byOffset.get(e.baseOffset)?.resolved ?? null; + } else if (e.baseOid) { + base = byOid.get(e.baseOid)?.resolved ?? getBase(e.baseOid); + } + if (base) { + e.resolved = { type: base.type, data: applyDelta(base.data, e.data) }; + hashEntry(e); + progress = true; + } else { + next.push(e); + } + } + if (!progress) throw new Error(`cannot resolve ${next.length} delta object(s): missing base`); + unresolved = next; + } + + return entries.map((e) => ({ oid: oidOf.get(e)!, type: e.resolved!.type, data: e.resolved!.data })); +} + +const REF_DELTA_NUM = 7; + +function encodeTypeSizeNum(typeNum: number, size: number): Uint8Array { + const bytes: number[] = []; + let first = (typeNum << 4) | (size & 0x0f); + size = Math.floor(size / 16); + while (size > 0) { + bytes.push(first | 0x80); + first = size & 0x7f; + size = Math.floor(size / 128); + } + bytes.push(first); + return new Uint8Array(bytes); +} + +function encodeTypeSize(type: ObjType, size: number): Uint8Array { + return encodeTypeSizeNum(TYPE_NUM[type], size); +} + +/** + * Incremental packfile writer: emits raw pack bytes through `emit` while + * keeping the running SHA-1 for the trailer, so packs can be streamed + * without ever materializing the whole file. + */ +export class PackWriter { + private sha = new Sha1(); + + constructor(private emit: (chunk: Uint8Array) => void) {} + + private out(chunk: Uint8Array): void { + this.sha.update(chunk); + this.emit(chunk); + } + + header(count: number): void { + const h = new Uint8Array(12); + h.set(te.encode("PACK"), 0); + const dv = new DataView(h.buffer); + dv.setUint32(4, 2); + dv.setUint32(8, count); + this.out(h); + } + + object(type: ObjType, data: Uint8Array): void { + this.out(encodeTypeSize(type, data.length)); + this.out(deflate(data)); + } + + /** Copy a stored full entry verbatim (already zlib-compressed). */ + rawFull(type: ObjType, entrySize: number, compressed: Uint8Array): void { + this.out(encodeTypeSize(type, entrySize)); + this.out(compressed); + } + + /** Copy a stored delta entry verbatim, addressed as a ref-delta. */ + rawDelta(entrySize: number, baseOid: string, compressed: Uint8Array): void { + this.out(encodeTypeSizeNum(REF_DELTA_NUM, entrySize)); + this.out(fromHex(baseOid)); + this.out(compressed); + } + + finish(): void { + this.emit(this.sha.digest()); // trailer is not part of the hashed content + } +} + +/** Convenience: build a whole pack in memory (used for small internal packs). */ +export function buildPack(objects: { type: ObjType; data: Uint8Array }[]): Uint8Array { + const parts: Uint8Array[] = []; + const w = new PackWriter((c) => parts.push(c)); + w.header(objects.length); + for (const o of objects) w.object(o.type, o.data); + w.finish(); + return concat(parts); +} diff --git a/src/git/packstore.ts b/src/git/packstore.ts @@ -0,0 +1,586 @@ +import pako from "pako"; +import { td, concat, toHex, Sha1 } from "./util"; +import { ObjType, NUM_TYPE, objectHeader } from "./objects"; +import { applyDelta } from "./pack"; + +export const PACK_CHUNK = 1024 * 1024; +const SUBTLE_THRESHOLD = 256 * 1024; +/** real Workers isolates have a hard 128MB total; self-hosted celld nodes run multi-GB heaps */ +const TIGHT_MEMORY = typeof caches !== "undefined"; +const MAX_BUFFERED_ENTRY = (TIGHT_MEMORY ? 8 : 32) * 1024 * 1024; +/** full entries up to this size are kept in cache — they are likely delta bases */ +const CACHE_ENTRY_LIMIT = (TIGHT_MEMORY ? 2 : 4) * 1024 * 1024; +/** recent entry offsets kept in memory for ofs-delta base resolution */ +const OFFSET_WINDOW = 150_000; + +export interface ObjRec { + type: ObjType; + data: Uint8Array; +} + +export interface PackedEntry { + oid: string; + packId: number; + offset: number; + dataOff: number; + dataLen: number; + type: ObjType; + size: number; + entrySize: number; + baseOid: string | null; +} + +export type ExternalResolver = (oid: string) => ObjRec | null; + +/** Byte-budgeted LRU cache of inflated objects (delta-chain bases, hot commits/trees). */ +export class ObjCache { + private map = new Map<string, ObjRec>(); + private bytes = 0; + + constructor(private budget: number) {} + + get(oid: string): ObjRec | undefined { + const hit = this.map.get(oid); + if (hit) { + this.map.delete(oid); + this.map.set(oid, hit); + } + return hit; + } + + put(oid: string, obj: ObjRec): void { + if (obj.data.length > this.budget / 4) return; + if (this.map.has(oid)) return; + this.map.set(oid, obj); + this.bytes += obj.data.length; + while (this.bytes > this.budget) { + const oldest = this.map.keys().next().value as string; + this.bytes -= this.map.get(oldest)!.data.length; + this.map.delete(oldest); + } + } +} + +// ingest is strictly sequential, so scratch hashers serve every object +const scratchSha = new Sha1(); +const streamSha = new Sha1(); + +async function hashObjectAsync(type: ObjType, data: Uint8Array): Promise<string> { + if (data.length >= SUBTLE_THRESHOLD) { + const full = concat([objectHeader(type, data.length), data]); + return toHex(new Uint8Array(await crypto.subtle.digest("SHA-1", full as BufferSource))); + } + return toHex(scratchSha.reset().update(objectHeader(type, data.length)).update(data).digest()); +} + +/** Buffered sequential reader over a pack stored as chunk rows. */ +class PackReader { + pos = 0; + private seq = -1; + private chunk: Uint8Array = new Uint8Array(0); + + constructor(private sql: SqlStorage, private packId: number, readonly size: number) {} + + private ensure(): void { + const want = Math.floor(this.pos / PACK_CHUNK); + if (want !== this.seq) { + const rows = this.sql + .exec<{ data: ArrayBuffer }>( + "SELECT data FROM pack_data WHERE pack_id = ? AND seq = ?", + this.packId, + want + ) + .toArray(); + if (!rows.length) throw new Error(`pack ${this.packId}: missing chunk ${want}`); + this.chunk = new Uint8Array(rows[0].data); + this.seq = want; + } + } + + byte(): number { + this.ensure(); + return this.chunk[this.pos++ - this.seq * PACK_CHUNK]; + } + + /** Remaining bytes of the chunk containing pos (never empty while pos < size). */ + window(): Uint8Array { + this.ensure(); + return this.chunk.subarray(this.pos - this.seq * PACK_CHUNK); + } +} + +/** + * Pack-native object database: received packfiles are stored verbatim in + * chunk rows and indexed (oid -> pack/offset/base), preserving the client's + * delta compression. This is what lets Linux-sized repos fit and stream. + */ +export class PackStore { + constructor(private sql: SqlStorage, private extern: ExternalResolver) { + this.init(); + } + + private init(): void { + this.sql.exec(` + CREATE TABLE IF NOT EXISTS pack_meta ( + pack_id INTEGER PRIMARY KEY, + size INTEGER NOT NULL, + count INTEGER NOT NULL, + created INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS pack_data ( + pack_id INTEGER NOT NULL, + seq INTEGER NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (pack_id, seq) + ); + CREATE TABLE IF NOT EXISTS pack_objects ( + oid TEXT PRIMARY KEY, + pack_id INTEGER NOT NULL, + offset INTEGER NOT NULL, + data_off INTEGER NOT NULL, + data_len INTEGER NOT NULL, + type TEXT NOT NULL, + size INTEGER NOT NULL, + entry_size INTEGER NOT NULL, + base_oid TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pack_objects_loc ON pack_objects(pack_id, offset); + CREATE TABLE IF NOT EXISTS pack_pending ( + pack_id INTEGER NOT NULL, + offset INTEGER NOT NULL, + data_off INTEGER NOT NULL, + data_len INTEGER NOT NULL, + entry_size INTEGER NOT NULL, + base_oid TEXT, + base_offset INTEGER, + PRIMARY KEY (pack_id, offset) + ); + `); + } + + wipe(): void { + for (const t of ["pack_meta", "pack_data", "pack_objects", "pack_pending"]) { + this.sql.exec(`DROP TABLE IF EXISTS ${t}`); + } + } + + /** Drop all packs and start empty (small-repo gc migrates objects out first). */ + reset(): void { + this.wipe(); + this.init(); + } + + countObjects(): number { + return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM pack_objects").one().n; + } + + lookup(oid: string): PackedEntry | null { + const rows = this.sql + .exec<{ + pack_id: number; offset: number; data_off: number; data_len: number; + type: ObjType; size: number; entry_size: number; base_oid: string | null; + }>( + "SELECT pack_id, offset, data_off, data_len, type, size, entry_size, base_oid FROM pack_objects WHERE oid = ?", + oid + ) + .toArray(); + const r = rows[0]; + if (!r) return null; + return { + oid, packId: r.pack_id, offset: r.offset, dataOff: r.data_off, dataLen: r.data_len, + type: r.type, size: r.size, entrySize: r.entry_size, baseOid: r.base_oid, + }; + } + + typeAndSize(oid: string): { type: ObjType; size: number } | null { + const rows = this.sql + .exec<{ type: ObjType; size: number }>("SELECT type, size FROM pack_objects WHERE oid = ?", oid) + .toArray(); + return rows[0] ?? null; + } + + findOidPrefix(prefix: string): string[] { + return this.sql + .exec<{ oid: string }>("SELECT oid FROM pack_objects WHERE oid LIKE ? LIMIT 2", prefix + "%") + .toArray() + .map((r) => r.oid); + } + + /** + * Raw stored (still-compressed) bytes of a pack region. Chunk rows are + * fetched one at a time: a wide range in a single SELECT can exceed the + * runtime's result-set size cap (celld materializes blob rows as JSON). + */ + readRaw(packId: number, off: number, len: number): Uint8Array { + const first = Math.floor(off / PACK_CHUNK); + const last = Math.floor((off + len - 1) / PACK_CHUNK); + const out = new Uint8Array(len); + for (let seq = first; seq <= last; seq++) { + const rows = this.sql + .exec<{ data: ArrayBuffer }>( + "SELECT data FROM pack_data WHERE pack_id = ? AND seq = ?", + packId, + seq + ) + .toArray(); + if (!rows.length) continue; + const chunk = new Uint8Array(rows[0].data); + const chunkStart = seq * PACK_CHUNK; + const from = Math.max(off, chunkStart); + const to = Math.min(off + len, chunkStart + chunk.length); + if (to > from) out.set(chunk.subarray(from - chunkStart, to - chunkStart), from - off); + } + return out; + } + + /** Inflate + delta-resolve an object out of pack storage. */ + getObject(oid: string, cache: ObjCache): ObjRec | null { + const cached = cache.get(oid); + if (cached) return cached; + const entry = this.lookup(oid); + if (!entry) return null; + const raw = pako.inflate(this.readRaw(entry.packId, entry.dataOff, entry.dataLen)); + let obj: ObjRec; + if (entry.baseOid) { + const base = this.getObject(entry.baseOid, cache) ?? this.extern(entry.baseOid); + if (!base) throw new Error(`missing delta base ${entry.baseOid} for ${oid}`); + obj = { type: base.type, data: applyDelta(base.data, raw) }; + } else { + obj = { type: entry.type, data: raw }; + } + cache.put(oid, obj); + return obj; + } + + /** + * Ingest a packfile arriving as a byte stream: store chunks verbatim + * (verifying the SHA-1 trailer on the fly), then index sequentially. + * Deltas resolve eagerly against the LRU cache — pack ordering keeps + * bases hot — so the straggler pass afterwards is nearly empty. Memory + * stays bounded regardless of pack size. + */ + async ingest( + firstBytes: Uint8Array, + reader: ReadableStreamDefaultReader<Uint8Array> | null, + opts: { + maxBytes: number; + cache: ObjCache; + onProgress?: (msg: string) => void; + /** called periodically so the runtime can flush its write buffer — + * workerd holds a request's dirty pages in the 128MB isolate heap */ + flush?: () => Promise<void>; + } + ): Promise<{ packId: number; count: number }> { + // a failed or interrupted ingest leaves rows without a pack_meta entry; + // reclaim that space before starting (pack_meta is only written on success) + const orphans = this.sql + .exec<{ pack_id: number }>( + "SELECT DISTINCT d.pack_id AS pack_id FROM pack_data d LEFT JOIN pack_meta m ON m.pack_id = d.pack_id WHERE m.pack_id IS NULL" + ) + .toArray(); + for (const o of orphans) { + for (const t of ["pack_data", "pack_objects", "pack_pending"]) { + this.sql.exec(`DELETE FROM ${t} WHERE pack_id = ?`, o.pack_id); + } + } + const packId = + (this.sql.exec<{ m: number | null }>("SELECT MAX(pack_id) AS m FROM pack_meta").one().m ?? 0) + 1; + const started = Date.now(); + const say = (msg: string) => { + opts.onProgress?.(msg); + console.log(`[ingest pack ${packId} +${Math.round((Date.now() - started) / 1000)}s] ${msg.trim()}`); + }; + + // phase A: stream bytes into chunk rows, hashing all but the trailing 20. + // Strictly linear: a cursor fills a fixed 1MB buffer — the source may + // arrive as one multi-GB chunk (celld buffers request bodies), so any + // re-concatenation of the remainder would go quadratic. + const sha = new Sha1(); + let tail = new Uint8Array(0); // rolling 20-byte lookbehind (the trailer) + let total = 0; + let seq = 0; + const chunkBuf = new Uint8Array(PACK_CHUNK); + let chunkLen = 0; + let lastLogged = 0; + const feed = (data: Uint8Array) => { + total += data.length; + if (total > opts.maxBytes) throw new Error("pack exceeds maximum push size"); + if (total - lastLogged >= 64 * 1024 * 1024) { + lastLogged = total; + say(`Receiving pack: ${Math.round(total / 1048576)}MB\n`); + } + const joined = tail.length ? concat([tail, data]) : data; + if (joined.length > 20) { + sha.update(joined.subarray(0, joined.length - 20)); + tail = joined.slice(joined.length - 20); + } else { + tail = joined.slice(); + } + let off = 0; + while (off < data.length) { + const take = Math.min(PACK_CHUNK - chunkLen, data.length - off); + chunkBuf.set(data.subarray(off, off + take), chunkLen); + chunkLen += take; + off += take; + if (chunkLen === PACK_CHUNK) { + this.sql.exec( + "INSERT INTO pack_data (pack_id, seq, data) VALUES (?, ?, ?)", + packId, + seq++, + chunkBuf.slice().buffer + ); + chunkLen = 0; + } + } + }; + // feed in bounded slices with yields, so one giant buffered body doesn't + // monopolize the event loop for a minutes-long synchronous stretch + const FEED_SLICE = 8 * 1024 * 1024; + const feedSliced = async (data: Uint8Array) => { + for (let off = 0; off < data.length; off += FEED_SLICE) { + feed(data.subarray(off, off + FEED_SLICE)); + if (data.length > FEED_SLICE) await new Promise((res) => setTimeout(res, 0)); + await opts.flush?.(); + } + }; + if (firstBytes.length) await feedSliced(firstBytes); + if (reader) { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value?.length) await feedSliced(value); + } + } + if (chunkLen > 0) { + this.sql.exec( + "INSERT INTO pack_data (pack_id, seq, data) VALUES (?, ?, ?)", + packId, + seq++, + chunkBuf.slice(0, chunkLen).buffer + ); + } + if (total < 32) throw new Error("pack too small"); + if (toHex(tail) !== toHex(sha.digest())) throw new Error("pack checksum mismatch"); + say(`Received pack: ${Math.round(total / 1048576)}MB\n`); + + // phase B: sequential scan with eager delta resolution + const r = new PackReader(this.sql, packId, total); + const magic = new Uint8Array(4); + for (let i = 0; i < 4; i++) magic[i] = r.byte(); + if (td.decode(magic) !== "PACK") throw new Error("bad pack signature"); + let version = 0, count = 0; + for (let i = 0; i < 4; i++) version = (version << 8) | r.byte(); + for (let i = 0; i < 4; i++) count = (count << 8) | r.byte(); + if (version !== 2 && version !== 3) throw new Error(`unsupported pack version ${version}`); + + // single-row statements: celld's SQLite caps bound variables far lower + // than workerd, and in-process inserts are cheap even at Linux scale + const insertObject = (row: (string | number | null)[]) => { + // OR IGNORE, never OR REPLACE: a duplicate object must keep its FIRST + // location. Re-pointing it at the incoming pack means the orphan sweep + // erases it from the index if this ingest fails — objects that were + // safely stored become unresolvable ("missing base") forever after. + this.sql.exec( + "INSERT OR IGNORE INTO pack_objects (oid, pack_id, offset, data_off, data_len, type, size, entry_size, base_oid) VALUES (?,?,?,?,?,?,?,?,?)", + ...row + ); + }; + const insertPending = (row: (string | number | null)[]) => { + this.sql.exec( + "INSERT INTO pack_pending (pack_id, offset, data_off, data_len, entry_size, base_oid, base_offset) VALUES (?,?,?,?,?,?,?)", + ...row + ); + }; + + // rotating offset -> oid window: ofs-delta bases are nearly always recent + let winCur = new Map<number, string>(); + let winPrev = new Map<number, string>(); + const winPut = (off: number, oid: string) => { + winCur.set(off, oid); + if (winCur.size >= OFFSET_WINDOW) { + winPrev = winCur; + winCur = new Map(); + } + }; + const oidByOffset = (off: number): string | null => { + const hit = winCur.get(off) ?? winPrev.get(off); + if (hit) return hit; + const rows = this.sql + .exec<{ oid: string }>("SELECT oid FROM pack_objects WHERE pack_id = ? AND offset = ?", packId, off) + .toArray(); + return rows[0]?.oid ?? null; + }; + + let pendingCount = 0; + for (let i = 0; i < count; i++) { + const offset = r.pos; + let byte = r.byte(); + const type = (byte >> 4) & 7; + let entrySize = byte & 15; + let shift = 4; + while (byte & 0x80) { + byte = r.byte(); + entrySize += (byte & 0x7f) * 2 ** shift; + shift += 7; + } + let baseOid: string | null = null; + let baseOffset: number | null = null; + if (type === 6) { + byte = r.byte(); + let off = byte & 0x7f; + while (byte & 0x80) { + byte = r.byte(); + off = (off + 1) * 128 + (byte & 0x7f); + } + baseOffset = offset - off; + } else if (type === 7) { + const b = new Uint8Array(20); + for (let j = 0; j < 20; j++) b[j] = r.byte(); + baseOid = toHex(b); + } else if (!NUM_TYPE[type]) { + throw new Error(`bad object type ${type} at ${offset}`); + } + + const dataOff = r.pos; + const isFull = type >= 1 && type <= 4; + const objType = isFull ? NUM_TYPE[type] : null; + const buffered = entrySize <= MAX_BUFFERED_ENTRY; + const pieces: Uint8Array[] = []; + if (isFull && !buffered) streamSha.reset().update(objectHeader(objType!, entrySize)); + let inflated = 0; + const inf = new pako.Inflate(); + (inf as unknown as { onData: (c: Uint8Array) => void }).onData = (c: Uint8Array) => { + inflated += c.length; + if (buffered) pieces.push(c); + else if (isFull) streamSha.update(c); + }; + const anyInf = inf as unknown as { err: number; msg: string; ended: boolean; strm: { avail_in: number } }; + while (!anyInf.ended) { + if (r.pos >= total) throw new Error(`truncated pack entry at ${offset}`); + const win = r.window(); + inf.push(win, false); + if (anyInf.err) throw new Error(`inflate failed at ${dataOff}: ${anyInf.msg}`); + r.pos += anyInf.ended ? win.length - anyInf.strm.avail_in : win.length; + } + if (inflated !== entrySize) throw new Error(`entry size mismatch at ${offset}`); + const dataLen = r.pos - dataOff; + + if (isFull) { + let content: Uint8Array | null = null; + let oid: string; + if (buffered) { + content = pieces.length === 1 ? pieces[0] : concat(pieces); + oid = await hashObjectAsync(objType!, content); + } else { + oid = toHex(streamSha.digest()); + } + insertObject([oid, packId, offset, dataOff, dataLen, objType!, entrySize, entrySize, null]); + winPut(offset, oid); + if (content && content.length <= CACHE_ENTRY_LIMIT) { + opts.cache.put(oid, { type: objType!, data: content }); + } + } else { + const resolvedBase = baseOid ?? (baseOffset !== null ? oidByOffset(baseOffset) : null); + let resolved = false; + if (buffered && resolvedBase) { + let base: ObjRec | null = opts.cache.get(resolvedBase) ?? null; + if (!base) { + try { + base = this.getObject(resolvedBase, opts.cache); + } catch { + base = null; + } + } + if (!base) base = this.extern(resolvedBase); + if (base) { + const delta = pieces.length === 1 ? pieces[0] : concat(pieces); + const content = applyDelta(base.data, delta); + const oid = await hashObjectAsync(base.type, content); + insertObject([oid, packId, offset, dataOff, dataLen, base.type, content.length, entrySize, resolvedBase]); + winPut(offset, oid); + if (content.length <= CACHE_ENTRY_LIMIT) { + opts.cache.put(oid, { type: base.type, data: content }); + } + resolved = true; + } + } + if (!resolved) { + insertPending([packId, offset, dataOff, dataLen, entrySize, baseOid, baseOffset]); + pendingCount++; + } + } + + if (i % 2000 === 1999) { + await new Promise((res) => setTimeout(res, 0)); + await opts.flush?.(); + if (i % 100000 === 99999) say(`Indexing objects: ${i + 1}/${count}\n`); + } + } + await opts.flush?.(); + say(`Indexed ${count} objects (${pendingCount} deferred)\n`); + + // phase C: stragglers — deltas whose base appeared later in the pack, + // or thin-pack bases that live in another pack / loose storage + let pendingTotal = this.sql + .exec<{ n: number }>("SELECT COUNT(*) AS n FROM pack_pending WHERE pack_id = ?", packId) + .one().n; + let resolvedTotal = 0; + while (pendingTotal > 0) { + let resolvedThisPass = 0; + let lastOffset = -1; + for (;;) { + const page = this.sql + .exec<{ + offset: number; data_off: number; data_len: number; entry_size: number; + base_oid: string | null; base_offset: number | null; + }>( + "SELECT offset, data_off, data_len, entry_size, base_oid, base_offset FROM pack_pending WHERE pack_id = ? AND offset > ? ORDER BY offset LIMIT 500", + packId, + lastOffset + ) + .toArray(); + if (!page.length) break; + for (const row of page) { + lastOffset = row.offset; + let baseOid = row.base_oid; + if (!baseOid && row.base_offset !== null) { + baseOid = oidByOffset(row.base_offset); + if (!baseOid) continue; // base itself still pending + } + if (!baseOid) continue; + let base: ObjRec | null = null; + try { + base = this.getObject(baseOid, opts.cache) ?? this.extern(baseOid); + } catch { + base = null; + } + if (!base) continue; // thin base not available (yet) + const delta = pako.inflate(this.readRaw(packId, row.data_off, row.data_len)); + const data = applyDelta(base.data, delta); + const oid = await hashObjectAsync(base.type, data); + insertObject([oid, packId, row.offset, row.data_off, row.data_len, base.type, data.length, row.entry_size, baseOid]); + this.sql.exec("DELETE FROM pack_pending WHERE pack_id = ? AND offset = ?", packId, row.offset); + winPut(row.offset, oid); + if (data.length <= CACHE_ENTRY_LIMIT) opts.cache.put(oid, { type: base.type, data }); + resolvedTotal++; + resolvedThisPass++; + if (resolvedTotal % 2000 === 0) { + await new Promise((res) => setTimeout(res, 0)); + if (resolvedTotal % 100000 === 0) say(`Resolving deltas: ${resolvedTotal}\n`); + } + } + } + pendingTotal -= resolvedThisPass; + if (resolvedThisPass === 0 && pendingTotal > 0) { + throw new Error(`cannot resolve ${pendingTotal} delta object(s): missing base`); + } + } + if (resolvedTotal) say(`Resolved ${resolvedTotal} deferred delta(s)\n`); + + this.sql.exec( + "INSERT INTO pack_meta (pack_id, size, count, created) VALUES (?, ?, ?, ?)", + packId, total, count, Date.now() + ); + return { packId, count }; + } +} diff --git a/src/git/pktline.ts b/src/git/pktline.ts @@ -0,0 +1,46 @@ +import { te, td, concat } from "./util"; + +export const FLUSH = te.encode("0000"); + +/** Encode one pkt-line (length prefix includes the 4 prefix bytes). */ +export function pkt(payload: string | Uint8Array): Uint8Array { + const body = typeof payload === "string" ? te.encode(payload) : payload; + if (body.length > 65516) throw new Error("pkt-line too long"); + return concat([te.encode((body.length + 4).toString(16).padStart(4, "0")), body]); +} + +export function pktLines(...payloads: (string | Uint8Array)[]): Uint8Array { + return concat(payloads.map(pkt)); +} + +export type Pkt = { kind: "flush" | "delim" | "line"; raw: Uint8Array; text: string }; + +/** Iterates pkt-lines from a buffer; `rest()` returns unparsed bytes (e.g. a packfile). */ +export class PktParser { + pos = 0; + constructor(private buf: Uint8Array) {} + + read(): Pkt | null { + if (this.pos + 4 > this.buf.length) return null; + const len = parseInt(td.decode(this.buf.subarray(this.pos, this.pos + 4)), 16); + if (Number.isNaN(len)) throw new Error("bad pkt-line length"); + if (len === 0) { + this.pos += 4; + return { kind: "flush", raw: new Uint8Array(0), text: "" }; + } + if (len === 1) { + this.pos += 4; + return { kind: "delim", raw: new Uint8Array(0), text: "" }; + } + if (len < 4 || this.pos + len > this.buf.length) throw new Error("bad pkt-line"); + const raw = this.buf.subarray(this.pos + 4, this.pos + len); + this.pos += len; + let text = td.decode(raw); + if (text.endsWith("\n")) text = text.slice(0, -1); + return { kind: "line", raw, text }; + } + + rest(): Uint8Array { + return this.buf.subarray(this.pos); + } +} diff --git a/src/git/protocol.ts b/src/git/protocol.ts @@ -0,0 +1,491 @@ +import { concat, isOid, ZERO_OID, te } from "./util"; +import { pkt, FLUSH, PktParser } from "./pktline"; +import { GitStore } from "./store"; +import { PackWriter } from "./pack"; +import { OidSet } from "./oidset"; +import { parseCommit, parseTag, parseTree, isGitlinkMode, TYPE_NUM } from "./objects"; + +const AGENT = "agent=dgit/0.3"; +const INFINITE_DEPTH = 0x7fffffff; +const SIDEBAND_CHUNK = 32 * 1024; +const WALK_YIELD = 5000; + +export type Service = "git-upload-pack" | "git-receive-pack"; + +/** GET /info/refs?service=... — smart ref advertisement (protocol v0). */ +export function advertisement(store: GitStore, service: Service): Uint8Array { + const caps = + service === "git-upload-pack" + ? ["shallow", "side-band-64k", `symref=HEAD:${store.head()}`, AGENT].join(" ") + : ["report-status", "delete-refs", "ofs-delta", "side-band-64k", AGENT].join(" "); + + const lines: Uint8Array[] = [pkt(`# service=${service}\n`), FLUSH]; + const refs: { name: string; target: string }[] = []; + if (service === "git-upload-pack") { + const head = store.resolveHead(); + if (head) refs.push({ name: "HEAD", target: head }); + } + refs.push(...store.refs()); + + if (refs.length === 0) { + lines.push(pkt(`${ZERO_OID} capabilities^{}\0${caps}\n`)); + } else { + refs.forEach((r, i) => { + lines.push(pkt(i === 0 ? `${r.target} ${r.name}\0${caps}\n` : `${r.target} ${r.name}\n`)); + }); + } + lines.push(FLUSH); + return concat(lines); +} + +export function sidebandFrames(band: number, payload: Uint8Array): Uint8Array[] { + const frames: Uint8Array[] = []; + for (let off = 0; off < payload.length; off += SIDEBAND_CHUNK) { + const chunk = payload.subarray(off, off + SIDEBAND_CHUNK); + const framed = new Uint8Array(chunk.length + 1); + framed[0] = band; + framed.set(chunk, 1); + frames.push(pkt(framed)); + } + return frames; +} + +interface UploadRequest { + wants: string[]; + haves: string[]; + done: boolean; + clientShallows: string[]; + deepen: number; // 0 = no depth limit requested + caps: Set<string>; +} + +function parseUploadRequest(body: Uint8Array): UploadRequest { + const parser = new PktParser(body); + const req: UploadRequest = { + wants: [], + haves: [], + done: false, + clientShallows: [], + deepen: 0, + caps: new Set(), + }; + for (let p = parser.read(); p !== null; p = parser.read()) { + if (p.kind !== "line") continue; + const line = p.text; + if (line.startsWith("want ")) { + req.wants.push(line.slice(5, 45)); + // capabilities ride on the first want line + for (const cap of line.slice(45).trim().split(" ")) if (cap) req.caps.add(cap); + } else if (line.startsWith("have ")) { + req.haves.push(line.slice(5, 45)); + } else if (line.startsWith("shallow ")) { + req.clientShallows.push(line.slice(8, 48)); + } else if (line.startsWith("deepen ")) { + req.deepen = parseInt(line.slice(7), 10) || 0; + } else if (line === "done") { + req.done = true; + } + } + return req; +} + +/** Follow tag objects down to the underlying commit oid (or null). */ +function peelToCommitOid(store: GitStore, oid: string): string | null { + for (let i = 0; i < 10; i++) { + const obj = store.get(oid); + if (!obj) return null; + if (obj.type === "commit") return oid; + if (obj.type === "tag") { + oid = parseTag(obj.data).object; + continue; + } + return null; + } + return null; +} + +/** + * Depth-limited commit set from the wants (BFS, min depth wins; the tip is + * depth 1, like git). Boundary commits are included but their parents cut. + */ +function computeDepthSet( + store: GitStore, + wants: string[], + depth: number +): { commits: Set<string>; boundary: Set<string> } { + const commits = new Set<string>(); + const boundary = new Set<string>(); + const queue: { oid: string; depth: number }[] = []; + for (const w of wants) { + const c = peelToCommitOid(store, w); + if (c && !commits.has(c)) { + commits.add(c); + queue.push({ oid: c, depth: 1 }); + } + } + while (queue.length) { + const { oid, depth: d } = queue.shift()!; + const obj = store.get(oid); + if (obj?.type !== "commit") continue; + const parents = parseCommit(obj.data).parents; + if (d >= depth) { + if (parents.length) boundary.add(oid); + continue; + } + boundary.delete(oid); // reachable within depth via this (shorter) path + for (const p of parents) { + if (!commits.has(p) && store.has(p)) { + commits.add(p); + queue.push({ oid: p, depth: d + 1 }); + } + } + } + return { commits, boundary }; +} + +/** + * Everything the client already has: closure of its haves, cut at its + * shallow boundaries (a shallow client does NOT have the parents of its + * shallow commits, nor their trees). + */ +async function excludedObjects( + store: GitStore, + haves: string[], + clientShallows: string[] +): Promise<OidSet> { + const shallowStops = new Set(clientShallows); + const excluded = new OidSet(haves.length * 64); + const commitStack: string[] = []; + let ops = 0; + const yieldMaybe = async () => { + if (++ops % WALK_YIELD === 0) await new Promise((r) => setTimeout(r, 0)); + }; + for (const h of haves) { + if (store.has(h) && excluded.addHex(h)) commitStack.push(h); + } + const trees: string[] = []; + while (commitStack.length) { + const oid = commitStack.pop()!; + const obj = store.get(oid); + if (!obj) continue; + await yieldMaybe(); + if (obj.type === "tag") { + const t = parseTag(obj.data).object; + if (t && store.has(t) && excluded.addHex(t)) commitStack.push(t); + continue; + } + if (obj.type !== "commit") continue; + const c = parseCommit(obj.data); + if (excluded.addHex(c.tree)) trees.push(c.tree); + if (shallowStops.has(oid)) continue; // client's history stops here + for (const p of c.parents) { + if (store.has(p) && excluded.addHex(p)) commitStack.push(p); + } + } + while (trees.length) { + const oid = trees.pop()!; + const obj = store.get(oid); + if (obj?.type !== "tree") continue; + await yieldMaybe(); + for (const e of parseTree(obj.data)) { + if (isGitlinkMode(e.mode)) continue; + if (excluded.addHex(e.oid) && store.typeAndSize(e.oid)?.type === "tree") trees.push(e.oid); + } + } + return excluded; +} + +/** + * Objects to pack: closure of wants, minus excluded, cut at commitLimit. + * An excluded commit is not re-sent, but the walk still descends through + * its parents: with a shallow client, commits BELOW its boundary are not + * excluded and must be reachable even when the walk enters via commits the + * client already has (deepen and unshallow fetches). Blobs are added by + * type lookup alone — their content is never inflated during the walk. + */ +async function collectPackOids( + store: GitStore, + wants: string[], + excluded: OidSet, + commitLimit: Set<string> | null +): Promise<OidSet> { + const send = new OidSet(4096); + const visited = new OidSet(4096); + const stack = [...wants]; + let ops = 0; + while (stack.length) { + const oid = stack.pop()!; + if (!visited.addHex(oid)) continue; + if (++ops % WALK_YIELD === 0) await new Promise((r) => setTimeout(r, 0)); + const meta = store.typeAndSize(oid); + if (!meta) throw new Error(`missing object ${oid}`); + if (meta.type === "commit" && commitLimit && !commitLimit.has(oid)) continue; + if (excluded.hasHex(oid)) { + if (meta.type === "commit") { + const full = store.get(oid)!; + stack.push(...parseCommit(full.data).parents); + } + continue; + } + send.addHex(oid); + if (meta.type === "blob") continue; // leaf: membership only + const full = store.get(oid)!; + if (full.type === "commit") { + const c = parseCommit(full.data); + stack.push(c.tree, ...c.parents); + } else if (full.type === "tag") { + const t = parseTag(full.data); + if (t.object) stack.push(t.object); + } else if (full.type === "tree") { + for (const e of parseTree(full.data)) { + if (!isGitlinkMode(e.mode)) stack.push(e.oid); + } + } + } + return send; +} + +/** + * POST /git-upload-pack — stateless protocol v0 with single-ack negotiation, + * shallow (--depth) support, side-band-64k, and a streamed pack response. + * Stored pack entries are copied verbatim (deltas preserved) whenever their + * base ships in the same pack; everything else is inflated and re-deflated. + */ +export async function uploadPack(store: GitStore, body: Uint8Array): Promise<Response> { + const headers = { + "content-type": "application/x-git-upload-pack-result", + "cache-control": "no-cache", + }; + const req = parseUploadRequest(body); + if (!req.wants.length || req.wants.some((w) => !isOid(w))) { + return new Response(pkt("ERR no valid wants\n") as unknown as BodyInit, { headers }); + } + for (const w of req.wants) { + if (!store.has(w)) { + return new Response(pkt(`ERR upload-pack: not our ref ${w}\n`) as unknown as BodyInit, { headers }); + } + } + + const preamble: Uint8Array[] = []; + + // shallow section (only when the client asked to deepen) + let commitLimit: Set<string> | null = null; + if (req.deepen > 0) { + const clientShallowSet = new Set(req.clientShallows); + if (req.deepen >= INFINITE_DEPTH) { + // --unshallow: full history, everything the client thought was shallow opens up + for (const s of req.clientShallows) { + if (store.has(s)) preamble.push(pkt(`unshallow ${s}\n`)); + } + } else { + const { commits, boundary } = computeDepthSet(store, req.wants, req.deepen); + commitLimit = commits; + for (const b of boundary) { + if (!clientShallowSet.has(b)) preamble.push(pkt(`shallow ${b}\n`)); + } + for (const s of req.clientShallows) { + if (commits.has(s) && !boundary.has(s)) preamble.push(pkt(`unshallow ${s}\n`)); + } + } + preamble.push(FLUSH); + } + + // single-ack negotiation: ACK the first common object, else NAK. A round + // with neither haves nor done (shallow discovery) gets no ack line at all — + // a stray NAK would desync the client's stateless response stream. + const firstCommon = req.haves.find((h) => isOid(h) && store.has(h)); + if (req.done || req.haves.length) { + preamble.push(pkt(firstCommon ? `ACK ${firstCommon}\n` : "NAK\n")); + } + + if (!req.done) { + // negotiation round only — client will POST again + return new Response(concat(preamble) as unknown as BodyInit, { headers }); + } + + const excluded = await excludedObjects(store, req.haves, req.clientShallows); + const send = await collectPackOids(store, req.wants, excluded, commitLimit); + const sideband = req.caps.has("side-band-64k"); + const noProgress = req.caps.has("no-progress"); + + // stream the pack: preamble, then pack bytes (side-band framed if negotiated) + const pending: Uint8Array[] = [concat(preamble)]; + let buffered: Uint8Array[] = []; + let bufferedLen = 0; + const flushBuffered = () => { + if (!bufferedLen) return; + const payload = concat(buffered); + buffered = []; + bufferedLen = 0; + if (sideband) pending.push(...sidebandFrames(1, payload)); + else pending.push(payload); + }; + const writer = new PackWriter((chunk) => { + buffered.push(chunk); + bufferedLen += chunk.length; + if (bufferedLen >= SIDEBAND_CHUNK) flushBuffered(); + }); + + let i = 0; + let finished = false; + const total = send.size; + const stream = new ReadableStream<Uint8Array>({ + start: (ctrl) => { + if (sideband && !noProgress) { + pending.push(...sidebandFrames(2, te.encode(`Enumerating objects: ${total}, done.\n`))); + } + writer.header(total); + for (const c of pending) ctrl.enqueue(c); + pending.length = 0; + }, + pull: (ctrl) => { + try { + for (let n = 0; n < 64 && i < total; n++, i++) { + const oid = send.atHex(i); + const entry = store.packs.lookup(oid); + if (entry && !entry.baseOid) { + writer.rawFull(entry.type, entry.entrySize, store.packs.readRaw(entry.packId, entry.dataOff, entry.dataLen)); + } else if (entry && entry.baseOid && send.hasHex(entry.baseOid)) { + writer.rawDelta(entry.entrySize, entry.baseOid, store.packs.readRaw(entry.packId, entry.dataOff, entry.dataLen)); + } else { + const obj = store.get(oid); + if (!obj) throw new Error(`missing object ${oid}`); + writer.object(obj.type, obj.data); + } + } + if (i >= total && !finished) { + finished = true; + writer.finish(); + flushBuffered(); + if (sideband) pending.push(FLUSH); + } else { + flushBuffered(); + } + for (const c of pending) ctrl.enqueue(c); + pending.length = 0; + if (finished) ctrl.close(); + } catch (err) { + ctrl.error(err); + } + }, + cancel: () => { + // client went away mid-transfer (e.g. negotiation round abort); nothing to clean up + }, + }); + return new Response(stream, { headers }); +} + +/** Is `anc` an ancestor of (or equal to) `desc`? Bounded walk. */ +export function isAncestor(store: GitStore, anc: string, desc: string): boolean { + if (anc === desc) return true; + const seen = new Set<string>([desc]); + const stack = [desc]; + let visited = 0; + while (stack.length && visited++ < 10000) { + const obj = store.get(stack.pop()!); + if (obj?.type !== "commit") continue; + for (const p of parseCommit(obj.data).parents) { + if (p === anc) return true; + if (!seen.has(p)) { + seen.add(p); + stack.push(p); + } + } + } + return false; +} + +export interface PushCommand { + old: string; + next: string; + ref: string; +} + +export interface CommandResult { + ref: string; + ok: boolean; + msg?: string; +} + +/** Parse the command section of a receive-pack request (already buffered). */ +export function parsePushCommands(section: Uint8Array): { commands: PushCommand[]; caps: string[] } { + const parser = new PktParser(section); + const commands: PushCommand[] = []; + let caps: string[] = []; + for (let p = parser.read(); p !== null; p = parser.read()) { + if (p.kind === "flush") break; + if (p.kind !== "line") continue; + let line = p.text; + const nul = line.indexOf("\0"); + if (nul !== -1) { + caps = line.slice(nul + 1).trim().split(" "); + line = line.slice(0, nul); + } + const m = line.match(/^([0-9a-f]{40}) ([0-9a-f]{40}) (.+)$/); + if (m) commands.push({ old: m[1], next: m[2], ref: m[3] }); + } + return { commands, caps }; +} + +/** Apply ref updates after the pack (if any) has been ingested. */ +export function applyPushCommands( + store: GitStore, + commands: PushCommand[], + unpackError: string | null +): { results: CommandResult[]; changed: boolean; needsGc: boolean } { + const results: CommandResult[] = []; + let changed = false; + let needsGc = false; + for (const cmd of commands) { + if (unpackError) { + results.push({ ref: cmd.ref, ok: false, msg: "unpacker error" }); + continue; + } + if (!cmd.ref.startsWith("refs/") || cmd.ref.includes("..") || /[\s~^:?*\[\\]/.test(cmd.ref)) { + results.push({ ref: cmd.ref, ok: false, msg: "funny refname" }); + continue; + } + const current = store.getRef(cmd.ref) ?? ZERO_OID; + if (current !== cmd.old) { + results.push({ ref: cmd.ref, ok: false, msg: "fetch first" }); + continue; + } + if (cmd.next === ZERO_OID) { + store.delRef(cmd.ref); + needsGc = true; + } else { + if (!store.has(cmd.next)) { + results.push({ ref: cmd.ref, ok: false, msg: "missing necessary objects" }); + continue; + } + if (cmd.old !== ZERO_OID && !isAncestor(store, cmd.old, cmd.next)) { + needsGc = true; // forced update strands old history + } + store.setRef(cmd.ref, cmd.next); + } + changed = true; + results.push({ ref: cmd.ref, ok: true }); + } + + // keep HEAD pointing at a branch that exists (first push wins, prefer main/master) + if (changed && store.getRef(store.head()) === null) { + const branches = store.refs().filter((r) => r.name.startsWith("refs/heads/")); + const preferred = + branches.find((b) => b.name === "refs/heads/main") ?? + branches.find((b) => b.name === "refs/heads/master") ?? + branches[0]; + if (preferred) store.setHead(preferred.name); + } + return { results, changed, needsGc }; +} + +/** report-status payload; ends with its own flush (nested inside band 1 when sidebanded). */ +export function renderStatus(results: CommandResult[], unpackError: string | null, sideband: boolean): Uint8Array { + const lines: Uint8Array[] = [pkt(unpackError ? `unpack ${unpackError}\n` : "unpack ok\n")]; + for (const r of results) { + lines.push(pkt(r.ok ? `ok ${r.ref}\n` : `ng ${r.ref} ${r.msg}\n`)); + } + lines.push(FLUSH); + if (!sideband) return concat(lines); + return concat([...sidebandFrames(1, concat(lines)), FLUSH]); +} diff --git a/src/git/sha1.ts b/src/git/sha1.ts @@ -0,0 +1,116 @@ +/** + * Incremental SHA-1. Workers' crypto.subtle is one-shot and async; pack + * streaming needs a running digest and the object database wants sync + * hashing, so we carry our own (git still speaks SHA-1 for object ids). + */ +export class Sha1 { + private h0 = 0x67452301 | 0; + private h1 = 0xefcdab89 | 0; + private h2 = 0x98badcfe | 0; + private h3 = 0x10325476 | 0; + private h4 = 0xc3d2e1f0 | 0; + private block = new Uint8Array(64); + private blockLen = 0; + private bytes = 0; + private w = new Int32Array(80); + + /** Reinitialize so one instance can hash many inputs without reallocating. */ + reset(): this { + this.h0 = 0x67452301 | 0; + this.h1 = 0xefcdab89 | 0; + this.h2 = 0x98badcfe | 0; + this.h3 = 0x10325476 | 0; + this.h4 = 0xc3d2e1f0 | 0; + this.blockLen = 0; + this.bytes = 0; + return this; + } + + update(data: Uint8Array): this { + this.bytes += data.length; + let off = 0; + if (this.blockLen > 0) { + const need = 64 - this.blockLen; + const take = Math.min(need, data.length); + this.block.set(data.subarray(0, take), this.blockLen); + this.blockLen += take; + off = take; + if (this.blockLen === 64) { + this.compress(this.block, 0); + this.blockLen = 0; + } + } + while (off + 64 <= data.length) { + this.compress(data, off); + off += 64; + } + if (off < data.length) { + this.block.set(data.subarray(off), 0); + this.blockLen = data.length - off; + } + return this; + } + + digest(): Uint8Array { + const bitLenHi = Math.floor((this.bytes * 8) / 0x100000000); + const bitLenLo = (this.bytes * 8) >>> 0; + const pad = new Uint8Array(((this.blockLen < 56 ? 56 : 120) - this.blockLen) + 8); + pad[0] = 0x80; + const dv = new DataView(pad.buffer); + dv.setUint32(pad.length - 8, bitLenHi); + dv.setUint32(pad.length - 4, bitLenLo); + this.update(pad); + const out = new Uint8Array(20); + const ov = new DataView(out.buffer); + ov.setInt32(0, this.h0); + ov.setInt32(4, this.h1); + ov.setInt32(8, this.h2); + ov.setInt32(12, this.h3); + ov.setInt32(16, this.h4); + return out; + } + + private compress(buf: Uint8Array, off: number): void { + const w = this.w; + for (let i = 0; i < 16; i++) { + const j = off + i * 4; + w[i] = (buf[j] << 24) | (buf[j + 1] << 16) | (buf[j + 2] << 8) | buf[j + 3]; + } + for (let i = 16; i < 80; i++) { + const n = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]; + w[i] = (n << 1) | (n >>> 31); + } + let a = this.h0, b = this.h1, c = this.h2, d = this.h3, e = this.h4; + for (let i = 0; i < 80; i++) { + let f: number, k: number; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5a827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ed9eba1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8f1bbcdc | 0; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6 | 0; + } + const t = (((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0; + e = d; + d = c; + c = (b << 30) | (b >>> 2); + b = a; + a = t; + } + this.h0 = (this.h0 + a) | 0; + this.h1 = (this.h1 + b) | 0; + this.h2 = (this.h2 + c) | 0; + this.h3 = (this.h3 + d) | 0; + this.h4 = (this.h4 + e) | 0; + } +} + +export function sha1(data: Uint8Array): Uint8Array { + return new Sha1().update(data).digest(); +} diff --git a/src/git/snapshot.ts b/src/git/snapshot.ts @@ -0,0 +1,144 @@ +import pako from "pako"; +import { te, concat } from "./util"; + +export interface SnapshotFile { + /** path inside the archive, without the top-level prefix */ + path: string; + mode: number; // 0o644 / 0o755 + symlink: boolean; + data: Uint8Array; +} + + +function octal(n: number, width: number): Uint8Array { + const s = n.toString(8).padStart(width - 1, "0") + "\0"; + return te.encode(s); +} + +function tarHeader(path: string, mode: number, size: number, mtime: number, typeflag: string, linkname: string): Uint8Array { + const h = new Uint8Array(512); + let name = path; + let prefix = ""; + if (te.encode(name).length > 100) { + // ustar prefix split at a slash + const idx = path.slice(0, 155).lastIndexOf("/"); + if (idx > 0) { + prefix = path.slice(0, idx); + name = path.slice(idx + 1); + } + } + h.set(te.encode(name).subarray(0, 100), 0); + h.set(octal(mode, 8), 100); + h.set(octal(0, 8), 108); // uid + h.set(octal(0, 8), 116); // gid + h.set(octal(size, 12), 124); + h.set(octal(mtime, 12), 136); + h.set(te.encode(" "), 148); // checksum placeholder = spaces + h.set(te.encode(typeflag), 156); + h.set(te.encode(linkname).subarray(0, 100), 157); + h.set(te.encode("ustar\0" + "00"), 257); + h.set(te.encode("root").subarray(0, 32), 265); + h.set(te.encode("root").subarray(0, 32), 297); + h.set(te.encode(prefix).subarray(0, 155), 345); + let sum = 0; + for (let i = 0; i < 512; i++) sum += h[i]; + h.set(te.encode(sum.toString(8).padStart(6, "0") + "\0 "), 148); + return h; +} + +export function tarGz(prefix: string, files: SnapshotFile[], mtime: number): Uint8Array { + const parts: Uint8Array[] = []; + for (const f of files) { + const path = `${prefix}/${f.path}`; + if (f.symlink) { + const target = new TextDecoder().decode(f.data); + parts.push(tarHeader(path, 0o777, 0, mtime, "2", target)); + continue; + } + parts.push(tarHeader(path, f.mode, f.data.length, mtime, "0", "")); + parts.push(f.data); + const pad = (512 - (f.data.length % 512)) % 512; + if (pad) parts.push(new Uint8Array(pad)); + } + parts.push(new Uint8Array(1024)); // end-of-archive + return pako.gzip(concat(parts)); +} + + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(data: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function dosDateTime(unixSecs: number): { time: number; date: number } { + const d = new Date(unixSecs * 1000); + const date = (((d.getUTCFullYear() - 1980) & 0x7f) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(); + const time = (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1); + return { time, date }; +} + +export function zip(prefix: string, files: SnapshotFile[], mtime: number): Uint8Array { + const { time, date } = dosDateTime(mtime); + const parts: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + for (const f of files) { + const name = te.encode(`${prefix}/${f.path}`); + const crc = crc32(f.data); + const compressed = f.data.length ? pako.deflateRaw(f.data) : new Uint8Array(0); + const method = f.data.length ? 8 : 0; + const local = new Uint8Array(30 + name.length); + const lv = new DataView(local.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint16(4, 20, true); + lv.setUint16(8, method, true); + lv.setUint16(10, time, true); + lv.setUint16(12, date, true); + lv.setUint32(14, crc, true); + lv.setUint32(18, compressed.length, true); + lv.setUint32(22, f.data.length, true); + lv.setUint16(26, name.length, true); + local.set(name, 30); + parts.push(local, compressed); + + const cd = new Uint8Array(46 + name.length); + const cv = new DataView(cd.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint16(4, (3 << 8) | 20, true); // made by unix + cv.setUint16(6, 20, true); + cv.setUint16(10, method, true); + cv.setUint16(12, time, true); + cv.setUint16(14, date, true); + cv.setUint32(16, crc, true); + cv.setUint32(20, compressed.length, true); + cv.setUint32(24, f.data.length, true); + cv.setUint16(28, name.length, true); + cv.setUint32(38, ((f.symlink ? 0o120000 | 0o777 : 0o100000 | f.mode) << 16) >>> 0, true); + cv.setUint32(42, offset, true); + cd.set(name, 46); + central.push(cd); + offset += local.length + compressed.length; + } + const cdStart = offset; + let cdLen = 0; + for (const c of central) cdLen += c.length; + const eocd = new Uint8Array(22); + const ev = new DataView(eocd.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, files.length, true); + ev.setUint16(10, files.length, true); + ev.setUint32(12, cdLen, true); + ev.setUint32(16, cdStart, true); + return concat([...parts, ...central, eocd]); +} diff --git a/src/git/store.ts b/src/git/store.ts @@ -0,0 +1,178 @@ +import { concat } from "./util"; +import { deflate, inflate } from "./zlib"; +import { ObjType } from "./objects"; +import { PackStore, ObjCache, ObjRec } from "./packstore"; + +const CHUNK = 1024 * 1024; // stay well under the DO SQLite per-row limit +// serving cache: Workers isolates have a hard 128MB total, so stay modest +// there; self-hosted celld nodes run with multi-GB heaps +const CACHE_BUDGET = typeof caches !== "undefined" ? 16 * 1024 * 1024 : 128 * 1024 * 1024; + +/** + * Git object database + refs, stored in a Durable Object's SQLite database. + * Small/legacy objects live loose (zlib-deflated, chunked across rows); + * pushed packs are kept verbatim and served through the PackStore index. + */ +export class GitStore { + readonly packs: PackStore; + readonly cache = new ObjCache(CACHE_BUDGET); + + constructor(private sql: SqlStorage) { + this.packs = new PackStore(sql, (oid) => this.getLoose(oid)); + sql.exec(` + CREATE TABLE IF NOT EXISTS objects ( + oid TEXT PRIMARY KEY, + type TEXT NOT NULL, + size INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS chunks ( + oid TEXT NOT NULL, + seq INTEGER NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (oid, seq) + ); + CREATE TABLE IF NOT EXISTS refs ( + name TEXT PRIMARY KEY, + target TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + } + + has(oid: string): boolean { + if (this.sql.exec("SELECT 1 FROM objects WHERE oid = ?", oid).toArray().length > 0) return true; + return this.packs.typeAndSize(oid) !== null; + } + + typeAndSize(oid: string): { type: ObjType; size: number } | null { + const rows = this.sql + .exec<{ type: ObjType; size: number }>("SELECT type, size FROM objects WHERE oid = ?", oid) + .toArray(); + return rows[0] ?? this.packs.typeAndSize(oid); + } + + private getLoose(oid: string): ObjRec | null { + const rows = this.sql + .exec<{ type: ObjType; size: number }>("SELECT type, size FROM objects WHERE oid = ?", oid) + .toArray(); + if (!rows.length) return null; + const chunks = this.sql + .exec<{ data: ArrayBuffer }>("SELECT data FROM chunks WHERE oid = ? ORDER BY seq", oid) + .toArray(); + const packed = concat(chunks.map((r) => new Uint8Array(r.data))); + return { type: rows[0].type, data: inflate(packed) }; + } + + get(oid: string): { type: ObjType; data: Uint8Array } | null { + return this.getLoose(oid) ?? this.packs.getObject(oid, this.cache); + } + + put(oid: string, type: ObjType, data: Uint8Array): void { + if (this.sql.exec("SELECT 1 FROM objects WHERE oid = ?", oid).toArray().length > 0) return; + const packed = deflate(data); + this.sql.exec("INSERT INTO objects (oid, type, size) VALUES (?, ?, ?)", oid, type, data.length); + for (let seq = 0, off = 0; off < packed.length || seq === 0; seq++, off += CHUNK) { + const slice = packed.slice(off, off + CHUNK); + this.sql.exec("INSERT INTO chunks (oid, seq, data) VALUES (?, ?, ?)", oid, seq, slice.buffer); + } + } + + objectCount(): number { + return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM objects").one().n + this.packs.countObjects(); + } + + /** Resolve an abbreviated oid; null if unknown or ambiguous. */ + findOid(prefix: string): string | null { + if (!/^[0-9a-f]{4,40}$/.test(prefix)) return null; + const loose = this.sql + .exec<{ oid: string }>("SELECT oid FROM objects WHERE oid LIKE ? LIMIT 2", prefix + "%") + .toArray() + .map((r) => r.oid); + const all = [...new Set([...loose, ...this.packs.findOidPrefix(prefix)])]; + return all.length === 1 ? all[0] : null; + } + + allOids(): string[] { + return this.sql.exec<{ oid: string }>("SELECT oid FROM objects").toArray().map((r) => r.oid); + } + + deleteObject(oid: string): void { + this.sql.exec("DELETE FROM objects WHERE oid = ?", oid); + this.sql.exec("DELETE FROM chunks WHERE oid = ?", oid); + } + + dbSize(): number { + return this.sql.databaseSize; + } + + wipe(): void { + for (const t of ["objects", "chunks", "refs", "meta"]) { + this.sql.exec(`DROP TABLE IF EXISTS ${t}`); + } + this.packs.wipe(); + } + + + /** All refs except HEAD, sorted by name. */ + refs(): { name: string; target: string }[] { + return this.sql + .exec<{ name: string; target: string }>( + "SELECT name, target FROM refs WHERE name != 'HEAD' ORDER BY name" + ) + .toArray(); + } + + getRef(name: string): string | null { + const rows = this.sql + .exec<{ target: string }>("SELECT target FROM refs WHERE name = ?", name) + .toArray(); + return rows[0]?.target ?? null; + } + + setRef(name: string, target: string): void { + this.sql.exec( + "INSERT INTO refs (name, target) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET target = excluded.target", + name, + target + ); + } + + delRef(name: string): void { + this.sql.exec("DELETE FROM refs WHERE name = ?", name); + } + + /** HEAD symref target, e.g. "refs/heads/main". */ + head(): string { + const raw = this.getRef("HEAD"); + if (raw?.startsWith("ref: ")) return raw.slice(5); + return raw ?? "refs/heads/main"; + } + + setHead(refName: string): void { + this.setRef("HEAD", `ref: ${refName}`); + } + + /** Resolve HEAD to an oid, or null for an empty/unborn repo. */ + resolveHead(): string | null { + return this.getRef(this.head()); + } + + + getMeta(key: string): string | null { + const rows = this.sql + .exec<{ value: string }>("SELECT value FROM meta WHERE key = ?", key) + .toArray(); + return rows[0]?.value ?? null; + } + + setMeta(key: string, value: string): void { + this.sql.exec( + "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + key, + value + ); + } +} diff --git a/src/git/util.ts b/src/git/util.ts @@ -0,0 +1,39 @@ +export const te = new TextEncoder(); +export const td = new TextDecoder(); + +export function concat(parts: Uint8Array[]): Uint8Array { + let len = 0; + for (const p of parts) len += p.length; + const out = new Uint8Array(len); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +export function toHex(b: Uint8Array): string { + let s = ""; + for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0"); + return s; +} + +export function fromHex(s: string): Uint8Array { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + return out; +} + +export const ZERO_OID = "0".repeat(40); + +export function isOid(s: string): boolean { + return /^[0-9a-f]{40}$/.test(s); +} + +export function sha1hex(data: Uint8Array): string { + return toHex(sha1(data)); +} + +import { sha1 } from "./sha1"; +export { sha1, Sha1 } from "./sha1"; diff --git a/src/git/zlib.ts b/src/git/zlib.ts @@ -0,0 +1,29 @@ +import pako from "pako"; + +/** Deflate with a zlib wrapper (what git uses for loose objects and pack entries). */ +export function deflate(data: Uint8Array): Uint8Array { + return pako.deflate(data); +} + +export function inflate(data: Uint8Array): Uint8Array { + return pako.inflate(data); +} + +export function gunzip(data: Uint8Array): Uint8Array { + return pako.ungzip(data); +} + +/** + * Inflate one zlib stream that starts at `start` inside `buf`, and report where + * it ended. Pack files concatenate per-object zlib streams with no length + * prefix, so the consumed byte count is the only way to find the next entry. + */ +export function inflateEntry(buf: Uint8Array, start: number): { data: Uint8Array; end: number } { + const inf = new pako.Inflate(); + inf.push(buf.subarray(start), true); + const anyInf = inf as unknown as { err: number; msg: string; ended: boolean; strm: { avail_in: number } }; + if (anyInf.err) throw new Error(`inflate failed at ${start}: ${anyInf.msg}`); + if (!anyInf.ended) throw new Error(`truncated zlib stream at ${start}`); + const consumed = buf.length - start - anyInf.strm.avail_in; + return { data: inf.result as Uint8Array, end: start + consumed }; +} diff --git a/src/index.ts b/src/index.ts @@ -0,0 +1,251 @@ +import type { Env } from "./env"; +import type { RepoInfo } from "./registry"; +import { esc, age, layout, htmlResponse, errorPage } from "./ui/html"; +import { CSS } from "./ui/style"; + +export { RepoCell } from "./repo"; +export { Registry } from "./registry"; + +const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const RESERVED = new Set(["cgit.css", "favicon.ico", "robots.txt", "info", "git-upload-pack", "git-receive-pack"]); +const CACHE_TTL = 60; + +function tokens(env: Env): string[] { + const list = [env.GIT_TOKEN ?? "", ...(env.GIT_TOKENS ?? "").split(",")]; + return list.map((t) => t.trim()).filter(Boolean); +} + +function unauthorized(): Response { + return new Response("auth required\n", { + status: 401, + headers: { "www-authenticate": 'Basic realm="dgit"' }, + }); +} + +/** HTTP Basic where the password (or username) is one of the configured tokens. */ +function checkAuth(req: Request, env: Env): Response | null { + const valid = tokens(env); + if (!valid.length) { + return new Response("access is disabled: set the GIT_TOKEN secret\n", { status: 403 }); + } + const header = req.headers.get("authorization") ?? ""; + if (!header.startsWith("Basic ")) return unauthorized(); + let user = "", pass = ""; + try { + const decoded = atob(header.slice(6)); + const colon = decoded.indexOf(":"); + user = colon === -1 ? decoded : decoded.slice(0, colon); + pass = colon === -1 ? "" : decoded.slice(colon + 1); + } catch { + return unauthorized(); + } + if (!valid.includes(pass) && !valid.includes(user)) return unauthorized(); + return null; +} + +function siteBase(env: Env) { + return { + site: env.SITE_NAME ?? "dgit", + siteDesc: env.SITE_DESC ?? "", + title: env.SITE_NAME ?? "dgit", + sub: env.SITE_DESC ?? "", + tab: "index", + }; +} + +async function indexPage(env: Env): Promise<Response> { + const registry = env.REGISTRY.getByName("registry"); + const repos = (await registry.list()).filter((r) => !r.priv); + let rows = ""; + let lastSection: string | null = null; + for (const r of repos) { + if (r.section !== lastSection) { + if (r.section) { + rows += `<tr class='nohover'><td class='reposection' colspan='4'>${esc(r.section)}</td></tr>`; + } + lastSection = r.section; + } + rows += + `<tr><td><a href='/${encodeURIComponent(r.name)}/'>${esc(r.name)}</a></td>` + + `<td>${esc(r.desc || "[no description]")}</td>` + + `<td>${esc(r.owner || env.SITE_OWNER || "")}</td>` + + `<td>${age(Math.floor(r.idle / 1000))}</td></tr>`; + } + const body = ` +<table class='list nowrap'> +<tr class='nohover'><th class='left'>Name</th><th class='left'>Description</th><th class='left'>Owner</th><th class='left'>Idle</th></tr> +${rows || "<tr class='nohover'><td colspan='4'>no repositories yet — create one by pushing: <code>git push https://<this-host>/myrepo.git main</code></td></tr>"} +</table>`; + return htmlResponse(layout({ ...siteBase(env), body })); +} + +/** + * Per-isolate memo of registry lookups. Without it every page view — cache + * hit or not — funnels through the single Registry DO, which becomes the + * global bottleneck under a traffic spike. Staleness window is small and + * only affects metadata (descriptions, versions, the private flag). + */ +const infoMemo = new Map<string, { at: number; info: RepoInfo | null }>(); +const INFO_TTL_MS = 20_000; + +async function repoInfo(env: Env, repo: string, fresh: boolean): Promise<RepoInfo | null> { + const hit = infoMemo.get(repo); + if (!fresh && hit && Date.now() - hit.at < INFO_TTL_MS) return hit.info; + const info = await env.REGISTRY.getByName("registry").get(repo); + infoMemo.set(repo, { at: Date.now(), info }); + return info; +} + +/** Cache API is a Cloudflare-only surface; celld has none, so feature-detect. */ +function pageCache(): Cache | null { + try { + // eslint-disable-next-line no-undef + return typeof caches !== "undefined" && (caches as unknown as { default?: Cache }).default + ? (caches as unknown as { default: Cache }).default + : null; + } catch { + return null; + } +} + +export default { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { + const url = new URL(req.url); + const path = url.pathname; + + if (path === "/" || path === "") { + // the index is the front door under load: cache it briefly at the edge + const cache = pageCache(); + if (cache) { + const key = new Request(`${url.origin}/?__index`, { method: "GET" }); + const hit = await cache.match(key); + if (hit) return hit; + const res = await indexPage(env); + const toCache = new Response(res.clone().body, res); + toCache.headers.set("cache-control", "public, max-age=30"); + ctx.waitUntil(cache.put(key, toCache)); + return res; + } + return indexPage(env); + } + if (path === "/cgit.css") { + return new Response(CSS, { headers: { "content-type": "text/css", "cache-control": "public, max-age=3600" } }); + } + if (path === "/robots.txt") { + return new Response( + "User-agent: *\nDisallow: /*/snapshot/\nDisallow: /*/blame/\nDisallow: /*/stats/\nDisallow: /*/diff/\nDisallow: /*/rawdiff/\nDisallow: /*/patch/\n", + { headers: { "content-type": "text/plain" } } + ); + } + if (path === "/favicon.ico") { + return new Response("not found\n", { status: 404 }); + } + + const m = path.match(/^\/([^/]+?)(\.git)?(\/.*)?$/); + if (!m) return errorPage(siteBase(env), "bad request", 400); + const repo = decodeURIComponent(m[1]); + const sub = m[3] ?? "/"; + if (!REPO_NAME.test(repo) || RESERVED.has(repo)) { + return errorPage(siteBase(env), `no such repository: ${repo}`, 404); + } + + const registry = env.REGISTRY.getByName("registry"); + + const isReceive = + sub === "/git-receive-pack" || + (sub === "/info/refs" && url.searchParams.get("service") === "git-receive-pack"); + const isProtocol = isReceive || sub === "/git-upload-pack" || sub === "/info/refs"; + const isAdmin = + ((sub === "/config" || sub === "/description") && req.method === "PUT") || + (sub === "/gc" && req.method === "POST") || + ((sub === "/" || sub === "") && req.method === "DELETE"); + + // mutations read the registry fresh; page views tolerate a short memo + const info: RepoInfo | null = await repoInfo(env, repo, isReceive || isAdmin); + + // pushes, admin operations, and everything on a private repo require auth + if (isReceive || isAdmin || info?.priv) { + const denied = checkAuth(req, env); + if (denied) return denied; + } + + // only pushes and admin ops may touch repos that don't exist yet + if (!info && !isReceive && !isAdmin) { + if (isProtocol) return new Response(`repository not found: ${repo}\n`, { status: 404 }); + return errorPage(siteBase(env), `no such repository: ${repo}`, 404); + } + + // page cache for public repo GET pages (versioned key; skipped on celld) + const cache = pageCache(); + const cacheable = + cache !== null && + req.method === "GET" && + !isProtocol && + info !== null && + !info.priv && + !req.headers.has("authorization"); + let cacheKey: Request | null = null; + if (cacheable) { + const keyUrl = new URL(url.toString()); + keyUrl.searchParams.set("__v", String(info!.ver)); + cacheKey = new Request(keyUrl.toString(), { method: "GET" }); + const hit = await cache!.match(cacheKey); + if (hit) return hit; + } + + const stub = env.REPO.getByName(repo); + const doUrl = new URL(req.url); + doUrl.pathname = sub; + // On celld, re-wrapping a bytes-backed request yields a stream-backed one + // whose consumption UTF-8-decodes the whole body (fatal for multi-hundred- + // MB binary packs). Forward explicit bytes there; stream on workerd. + let fwdBody: BodyInit | null = null; + if (req.method !== "GET" && req.method !== "HEAD" && req.body) { + fwdBody = pageCache() === null ? ((await req.arrayBuffer()) as ArrayBuffer) : req.body; + } + const fwd = new Request(doUrl.toString(), { + method: req.method, + headers: req.headers, + body: fwdBody, + }); + fwd.headers.set("x-repo", repo); + fwd.headers.set("x-host", url.host); + fwd.headers.set("x-proto", url.protocol.replace(":", "")); + const res = await stub.fetch(fwd); + + // bookkeeping after successful mutations + if (res.ok && sub === "/git-receive-pack" && res.headers.get("x-changed") === "1") { + await registry.upsert(repo, Date.now()); + } + if (res.ok && (sub === "/config" || sub === "/description") && req.method === "PUT") { + try { + const cfg = (await res.clone().json()) as { + description: string; + owner: string; + section: string; + private: boolean; + }; + if (!info) await registry.upsert(repo, Date.now()); + await registry.setConfig(repo, { + desc: cfg.description, + owner: cfg.owner, + section: cfg.section, + priv: cfg.private, + }); + } catch { + // non-JSON response; skip registry sync + } + } + if (res.ok && (sub === "/" || sub === "") && req.method === "DELETE") { + await registry.remove(repo); + } + if (isReceive || isAdmin) infoMemo.delete(repo); // this isolate sees its own mutations + + if (cacheable && cacheKey && res.ok && !res.headers.has("set-cookie")) { + const toCache = new Response(res.clone().body, res); + toCache.headers.set("cache-control", `public, max-age=${CACHE_TTL}`); + ctx.waitUntil(cache!.put(cacheKey, toCache)); + } + return res; + }, +} satisfies ExportedHandler<Env>; diff --git a/src/registry.ts b/src/registry.ts @@ -0,0 +1,88 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env } from "./env"; + +export type RepoInfo = { + name: string; + desc: string; + owner: string; + section: string; + priv: number; // 1 = requires auth for all access, hidden from index + /** unix millis of last push */ + idle: number; + /** bumped on every push/config change; used as the page-cache version */ + ver: number; +}; + +export type RepoConfig = { + desc?: string; + owner?: string; + section?: string; + priv?: boolean; +}; + +/** Site-wide list of repositories (one instance, name "registry"). */ +export class Registry extends DurableObject<Env> { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS repos ( + name TEXT PRIMARY KEY, + desc TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + section TEXT NOT NULL DEFAULT '', + priv INTEGER NOT NULL DEFAULT 0, + idle INTEGER NOT NULL DEFAULT 0, + ver INTEGER NOT NULL DEFAULT 1 + ); + `); + // upgrade path for databases created by earlier versions + for (const col of ["section TEXT NOT NULL DEFAULT ''", "priv INTEGER NOT NULL DEFAULT 0", "ver INTEGER NOT NULL DEFAULT 1"]) { + try { + ctx.storage.sql.exec(`ALTER TABLE repos ADD COLUMN ${col}`); + } catch { + // column already exists + } + } + } + + upsert(name: string, idle: number): void { + // seed ver from the clock so a deleted-and-recreated repo never reuses + // cache-key versions from its previous incarnation + this.ctx.storage.sql.exec( + "INSERT INTO repos (name, idle, ver) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET idle = excluded.idle, ver = ver + 1", + name, + idle, + Date.now() + ); + } + + setConfig(name: string, cfg: RepoConfig): void { + const cur = this.get(name); + if (!cur) return; + this.ctx.storage.sql.exec( + "UPDATE repos SET desc = ?, owner = ?, section = ?, priv = ?, ver = ver + 1 WHERE name = ?", + cfg.desc ?? cur.desc, + cfg.owner ?? cur.owner, + cfg.section ?? cur.section, + cfg.priv === undefined ? cur.priv : cfg.priv ? 1 : 0, + name + ); + } + + remove(name: string): void { + this.ctx.storage.sql.exec("DELETE FROM repos WHERE name = ?", name); + } + + get(name: string): RepoInfo | null { + const rows = this.ctx.storage.sql + .exec<RepoInfo>("SELECT name, desc, owner, section, priv, idle, ver FROM repos WHERE name = ?", name) + .toArray(); + return rows[0] ?? null; + } + + list(): RepoInfo[] { + return this.ctx.storage.sql + .exec<RepoInfo>("SELECT name, desc, owner, section, priv, idle, ver FROM repos ORDER BY section, name") + .toArray(); + } +} diff --git a/src/repo.ts b/src/repo.ts @@ -0,0 +1,1365 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env } from "./env"; +import { td, te, isOid, concat } from "./git/util"; +import { GitStore } from "./git/store"; +import { ObjCache } from "./git/packstore"; +import { gunzip } from "./git/zlib"; +import { + advertisement, + uploadPack, + parsePushCommands, + applyPushCommands, + renderStatus, + sidebandFrames, + Service, +} from "./git/protocol"; +import { + Commit, + parseCommit, + parseTag, + parseTree, + isTreeMode, + isGitlinkMode, + modeString, + TreeEntry, +} from "./git/objects"; +import { diffLines, toHunks, isBinary, Hunk, DiffOp } from "./git/diff"; +import { blame, BlameHistoryEntry } from "./git/blame"; +import { tarGz, zip, SnapshotFile } from "./git/snapshot"; +import { esc, age, fmtDate, fmtDate2822, layout, htmlResponse, errorPage, LayoutOpts } from "./ui/html"; +import { renderMarkdown } from "./ui/markdown"; +import { highlightLines } from "./ui/highlight"; + +const LOG_PAGE = 50; +const MAX_DIFF_FILES = 100; +const MAX_DIFF_BLOB = 512 * 1024; +const MAX_LOG_SCAN = 5000; +const MAX_STATS_SCAN = 2000; +const MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024; +// like cgit's about-file: a dedicated about page wins over the README +const README_NAMES = [ + "about.md", + "about.markdown", + "about", + "readme.md", + "readme.markdown", + "readme", + "readme.txt", + "readme.rst", +]; + +interface LogEntry { + oid: string; + commit: Commit; +} + +interface LogFilter { + path?: string[]; + qt?: string; + q?: string; +} + +interface FileDiff { + path: string; + o: { oid: string; mode: string } | null; + n: { oid: string; mode: string } | null; + kind: "text" | "binary" | "toolarge"; + ops: DiffOp[] | null; + hunks: Hunk[]; + add: number; + del: number; +} + +export class RepoCell extends DurableObject<Env> { + store: GitStore; + /** pushes are serialized: concurrent ingests would race pack-id allocation */ + private receiveChain: Promise<void> = Promise.resolve(); + /** concurrent clone walks contend on one event loop; bound them */ + private activeUploads = 0; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.store = new GitStore(ctx.storage.sql); + } + + async fetch(req: Request): Promise<Response> { + const url = new URL(req.url); + const repo = req.headers.get("x-repo") ?? "repo"; + const host = req.headers.get("x-host") ?? url.host; + const proto = req.headers.get("x-proto") ?? "https"; + const path = url.pathname; + + try { + if (path === "/info/refs" && req.method === "GET") { + const service = url.searchParams.get("service"); + if (service !== "git-upload-pack" && service !== "git-receive-pack") { + return new Response("smart HTTP only\n", { status: 400 }); + } + return new Response(advertisement(this.store, service as Service) as unknown as BodyInit, { + headers: { + "content-type": `application/x-${service}-advertisement`, + "cache-control": "no-cache", + }, + }); + } + if (path === "/git-upload-pack" && req.method === "POST") { + if (this.activeUploads >= 4) { + return new Response("busy: too many concurrent fetches, retry shortly\n", { + status: 503, + headers: { "retry-after": "15" }, + }); + } + this.activeUploads++; + try { + return await uploadPack(this.store, await this.readBody(req)); + } finally { + this.activeUploads--; + } + } + if (path === "/git-receive-pack" && req.method === "POST") { + return this.receive(req, repo); + } + + if (path === "/config" && req.method === "PUT") { + return this.handleConfig(await req.text()); + } + if (path === "/description" && req.method === "PUT") { + return this.handleConfig(JSON.stringify({ description: (await req.text()).trim() })); + } + if ((path === "/" || path === "") && req.method === "DELETE") { + // wipe() drops every table we own; deliberately NOT storage.deleteAll(): + // on celld, deleteAll sweeps the ltx replication control tables too and + // permanently breaks WAL capture for the cell (patch submitted upstream) + this.store.wipe(); + await this.ctx.storage.deleteAlarm(); + // this instance stays resident: bring the (empty) schema back so + // later requests — including a re-creating push — find their tables + this.store = new GitStore(this.ctx.storage.sql); + return new Response("deleted\n"); + } + if (path === "/gc" && req.method === "POST") { + const result = this.runGc(); + return Response.json(result); + } + + if (req.method !== "GET") return new Response("method not allowed\n", { status: 405 }); + return this.ui(repo, host, proto, path, url.searchParams); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return new Response(`error: ${msg}\n`, { status: 500 }); + } + } + + async alarm(): Promise<void> { + if (this.store.getMeta("gc-pending") === "1") { + this.runGc(); + this.store.setMeta("gc-pending", "0"); + } + } + + private handleConfig(body: string): Response { + let cfg: Record<string, unknown>; + try { + cfg = JSON.parse(body); + } catch { + return new Response("invalid JSON\n", { status: 400 }); + } + if (typeof cfg.description === "string") this.store.setMeta("description", cfg.description.slice(0, 200)); + if (typeof cfg.owner === "string") this.store.setMeta("owner", cfg.owner.slice(0, 100)); + if (typeof cfg.section === "string") this.store.setMeta("section", cfg.section.slice(0, 100)); + if (typeof cfg.private === "boolean") this.store.setMeta("private", cfg.private ? "1" : "0"); + return Response.json({ + description: this.store.getMeta("description") ?? "", + owner: this.store.getMeta("owner") ?? "", + section: this.store.getMeta("section") ?? "", + private: this.store.getMeta("private") === "1", + }); + } + + /** + * Delete loose objects unreachable from any ref. Objects inside stored + * packs are kept (deleting mid-pack is impossible without a repack); + * huge repos skip the sweep entirely — the walk isn't worth it. + */ + private runGc(): { removed: number; kept: number; skipped?: boolean } { + if (this.store.packs.countObjects() > 300_000) { + return { removed: 0, kept: this.store.objectCount(), skipped: true }; + } + const reachable = new Set<string>(); + const stack: string[] = this.store.refs().map((r) => r.target); + const head = this.store.resolveHead(); + if (head) stack.push(head); + while (stack.length) { + const oid = stack.pop()!; + if (reachable.has(oid)) continue; + const obj = this.store.get(oid); + if (!obj) continue; + reachable.add(oid); + if (obj.type === "commit") { + const c = parseCommit(obj.data); + stack.push(c.tree, ...c.parents); + } else if (obj.type === "tag") { + const t = parseTag(obj.data); + if (t.object) stack.push(t.object); + } else if (obj.type === "tree") { + for (const e of parseTree(obj.data)) { + if (!isGitlinkMode(e.mode)) stack.push(e.oid); + } + } + } + let removed = 0; + for (const oid of this.store.allOids()) { + if (!reachable.has(oid)) { + this.store.deleteObject(oid); + removed++; + } + } + // repack-by-migration: reachable pack objects move to loose storage, + // then the packs (including anything stranded inside them) are dropped + const packCount = this.store.packs.countObjects(); + if (packCount > 0) { + let migrated = 0; + for (const oid of reachable) { + if (this.store.packs.lookup(oid)) { + const obj = this.store.get(oid); + if (obj) { + this.store.put(oid, obj.type, obj.data); + migrated++; + } + } + } + removed += packCount - migrated; + this.store.packs.reset(); + } + return { removed, kept: reachable.size }; + } + + private async readBody(req: Request): Promise<Uint8Array> { + let body: Uint8Array = new Uint8Array(await req.arrayBuffer()); + if (req.headers.get("content-encoding")?.includes("gzip")) body = gunzip(body); + return body; + } + + /** + * Push handling. The pack is ingested chunk-by-chunk as it arrives (never + * fully buffered), but the RESPONSE is not returned until processing + * completes: returning a streaming response early makes the runtime treat + * the request as finished, and celld then idle-evicts the cell (and + * retires its isolate) out from under a long ingest. + */ + private async receive(req: Request, repo: string): Promise<Response> { + const maxBytes = (parseInt(this.env.MAX_PUSH_MB ?? "", 10) || 8192) * 1024 * 1024; + const chunks: Uint8Array[] = []; + // chain: a second push (e.g. a client retry of the same POST) must + // wait — two interleaved ingests would both claim the next pack id + // and sweep each other's in-progress rows as orphans + const run = this.receiveChain.then(() => + this.processReceive(req, repo, maxBytes, (c) => chunks.push(c)) + ); + this.receiveChain = run.then( + () => {}, + () => {} + ); + try { + await run; + } catch (err) { + console.log(`[receive ${repo}] FAILED: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); + return new Response(`error: ${err instanceof Error ? err.message : String(err)}\n`, { status: 500 }); + } + return new Response(concat(chunks) as unknown as BodyInit, { + headers: { + "content-type": "application/x-git-receive-pack-result", + "cache-control": "no-cache", + }, + }); + } + + private async processReceive( + req: Request, + repo: string, + maxBytes: number, + emit: (chunk: Uint8Array) => void + ): Promise<void> { + console.log(`[receive ${repo}] processing push request`); + let reader: ReadableStreamDefaultReader<Uint8Array> | null = null; + let buf: Uint8Array; + if (req.headers.get("content-encoding")?.includes("gzip")) { + buf = gunzip(new Uint8Array(await req.arrayBuffer())); // only small pushes arrive gzipped + } else { + reader = (req.body?.getReader() as ReadableStreamDefaultReader<Uint8Array>) ?? null; + buf = new Uint8Array(0); + } + + // read the pkt-line command section (small); everything after is the pack + let pos = 0; + const need = async (n: number): Promise<boolean> => { + while (buf.length - pos < n) { + if (!reader) return false; + const { done, value } = await reader.read(); + if (done) return false; + if (value?.length) buf = concat([buf, value]); + } + return true; + }; + for (;;) { + if (!(await need(4))) break; + const len = parseInt(td.decode(buf.subarray(pos, pos + 4)), 16); + if (Number.isNaN(len)) throw new Error("bad pkt-line in push request"); + if (len === 0) { + pos += 4; + break; + } + if (!(await need(len))) throw new Error("truncated push request"); + pos += len; + } + const { commands, caps } = parsePushCommands(buf.subarray(0, pos)); + const wantStatus = caps.includes("report-status"); + const sideband = caps.includes("side-band-64k"); + const progress = (msg: string) => { + if (!sideband) return; + for (const f of sidebandFrames(2, te.encode(msg))) emit(f); + }; + + let firstPackBytes = buf.subarray(pos); + let hasPack = firstPackBytes.length > 0; + if (!hasPack && reader) { + const { done, value } = await reader.read(); + if (!done && value?.length) { + firstPackBytes = value; + hasPack = true; + } + } + + let unpackError: string | null = null; + if (hasPack) { + try { + // a dedicated cache makes pack-adjacent delta bases nearly free; on + // real Workers the whole isolate has a hard 128MB, so stay small there + const budget = typeof caches !== "undefined" ? 16 * 1024 * 1024 : 512 * 1024 * 1024; + await this.store.packs.ingest(firstPackBytes, reader, { + maxBytes, + cache: new ObjCache(budget), + onProgress: progress, + flush: () => this.ctx.storage.sync(), + }); + } catch (err) { + unpackError = err instanceof Error ? err.message : String(err); + console.log(`[receive ${repo}] unpack error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); + try { + await reader?.cancel(); + } catch { + // client may already be gone + } + } + } + + const { results, changed, needsGc } = applyPushCommands(this.store, commands, unpackError); + if (changed) { + this.store.setMeta("created", "1"); + this.store.setMeta("last-push", String(Date.now())); + try { + // after a huge ingest, celld's output gate can refuse outbound calls + // until the burst is proven durable — registration must not take the + // whole (already applied) push down with it; the next push heals it + await this.env.REGISTRY.getByName("registry").upsert(repo, Date.now()); + } catch (err) { + console.log(`[receive ${repo}] registry upsert deferred: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (needsGc) { + this.store.setMeta("gc-pending", "1"); + try { + await this.ctx.storage.setAlarm(Date.now() + 5 * 60 * 1000); + } catch (err) { + console.log(`[receive ${repo}] gc alarm deferred: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (wantStatus) emit(renderStatus(results, unpackError, sideband)); + } + + + private base(repo: string, tab: string, ref?: string, formAction?: string): Omit<LayoutOpts, "body"> { + const branches = this.store + .refs() + .filter((r) => r.name.startsWith("refs/heads/")) + .map((r) => r.name.slice(11)) + .slice(0, 50); + const headBranch = this.store.head().replace("refs/heads/", ""); + return { + site: this.env.SITE_NAME ?? "dgit", + siteDesc: this.env.SITE_DESC ?? "", + title: `${repo} - ${tab}`, + repo, + sub: this.store.getMeta("description") || "[no description]", + tab, + ref: ref ?? headBranch, + hasAbout: this.findReadme() !== null, + branches, + formAction: formAction ?? `/${encodeURIComponent(repo)}/`, + }; + } + + private ui(repo: string, host: string, proto: string, path: string, q: URLSearchParams): Response { + const h = q.get("h") ?? undefined; + if (path === "/" || path === "") return this.summaryPage(repo, host, proto); + if (path === "/about/" || path === "/about") return this.aboutPage(repo, h); + if (path === "/log/" || path === "/log") + return this.logPage(repo, h, parseInt(q.get("ofs") ?? "0", 10) || 0, { + path: q.get("path") ? decodePath("/" + q.get("path")!) : undefined, + qt: q.get("qt") ?? undefined, + q: q.get("q") ?? undefined, + }); + if (path === "/refs/" || path === "/refs") return this.refsPage(repo); + if (path === "/commit/" || path === "/commit") return this.commitPage(repo, q.get("id") ?? undefined, h); + if (path === "/diff/" || path === "/diff") + return this.diffPage(repo, q.get("id") ?? undefined, q.get("id2") ?? undefined, h, false); + if (path === "/rawdiff/" || path === "/rawdiff") + return this.diffPage(repo, q.get("id") ?? undefined, q.get("id2") ?? undefined, h, true); + if (path === "/patch/" || path === "/patch") return this.patchPage(repo, q.get("id") ?? undefined, h); + if (path === "/tag/" || path === "/tag") return this.tagPage(repo, q.get("h") ?? q.get("id") ?? undefined); + if (path === "/atom/" || path === "/atom") return this.atomPage(repo, host, proto, h); + if (path === "/stats/" || path === "/stats") return this.statsPage(repo, h, q.get("period") ?? "m"); + if (path === "/blob/" || path === "/blob") return this.blobByIdPage(q.get("id") ?? ""); + if (path.startsWith("/tree")) return this.treePage(repo, h, decodePath(path.slice("/tree".length))); + if (path.startsWith("/plain")) return this.plainPage(h, decodePath(path.slice("/plain".length))); + if (path.startsWith("/blame")) return this.blamePage(repo, h, decodePath(path.slice("/blame".length))); + if (path.startsWith("/snapshot/")) return this.snapshotPage(repo, decodeURIComponent(path.slice("/snapshot/".length))); + return errorPage(this.base(repo, "summary"), `page not found: ${path}`); + } + + + /** Resolve ?h= (branch, tag, full ref, or oid) to an object id. */ + private resolveRef(h?: string): { refName: string | null; oid: string } | null { + if (!h) { + const oid = this.store.resolveHead(); + return oid ? { refName: this.store.head(), oid } : null; + } + for (const cand of [`refs/heads/${h}`, `refs/tags/${h}`, h]) { + const oid = this.store.getRef(cand); + if (oid) return { refName: cand, oid }; + } + const full = this.store.findOid(h); + if (full) return { refName: null, oid: full }; + return null; + } + + /** Follow tag objects until we reach a commit. */ + private peelToCommit(oid: string): { oid: string; commit: Commit } | null { + for (let i = 0; i < 10; i++) { + const obj = this.store.get(oid); + if (!obj) return null; + if (obj.type === "commit") return { oid, commit: parseCommit(obj.data) }; + if (obj.type === "tag") { + oid = parseTag(obj.data).object; + continue; + } + return null; + } + return null; + } + + private loadCommit(oid: string): Commit | null { + const obj = this.store.get(oid); + return obj?.type === "commit" ? parseCommit(obj.data) : null; + } + + /** oid of the entry at `path` in this commit's tree (any type), or null. */ + private pathOid(commit: Commit, path: string[]): string | null { + let oid = commit.tree; + for (const seg of path) { + const obj = this.store.get(oid); + if (obj?.type !== "tree") return null; + const entry = parseTree(obj.data).find((e) => e.name === seg); + if (!entry) return null; + oid = entry.oid; + } + return oid; + } + + private matchesFilter(e: LogEntry, filter: LogFilter): boolean { + if (filter.path && filter.path.length) { + const mine = this.pathOid(e.commit, filter.path); + const parent = e.commit.parents[0] ? this.loadCommit(e.commit.parents[0]) : null; + const theirs = parent ? this.pathOid(parent, filter.path) : null; + if (mine === theirs) return false; + } + if (filter.q) { + const q = filter.q.toLowerCase(); + const qt = filter.qt ?? "grep"; + if (qt === "author") { + if (!`${e.commit.author.name} ${e.commit.author.email}`.toLowerCase().includes(q)) return false; + } else if (qt === "committer") { + if (!`${e.commit.committer.name} ${e.commit.committer.email}`.toLowerCase().includes(q)) return false; + } else { + if (!e.commit.message.toLowerCase().includes(q)) return false; + } + } + return true; + } + + /** Date-ordered commit walk (newest first) with optional filtering. */ + private walkLog(tip: string, skip: number, limit: number, filter: LogFilter = {}): { entries: LogEntry[]; more: boolean } { + const first = this.peelToCommit(tip); + if (!first) return { entries: [], more: false }; + const seen = new Set<string>([first.oid]); + const frontier: LogEntry[] = [{ oid: first.oid, commit: first.commit }]; + const out: LogEntry[] = []; + let scanned = 0; + while (frontier.length && out.length < skip + limit + 1 && scanned++ < MAX_LOG_SCAN) { + frontier.sort((a, b) => b.commit.committer.time - a.commit.committer.time); + const cur = frontier.shift()!; + if (this.matchesFilter(cur, filter)) out.push(cur); + for (const p of cur.commit.parents) { + if (seen.has(p)) continue; + seen.add(p); + const c = this.loadCommit(p); + if (c) frontier.push({ oid: p, commit: c }); + } + } + return { entries: out.slice(skip, skip + limit), more: out.length > skip + limit }; + } + + /** First-parent history of a path (for blame), newest first, with blobs. */ + private pathHistory(tip: string, path: string[], cap: number): BlameHistoryEntry[] { + const out: BlameHistoryEntry[] = []; + let cur = this.peelToCommit(tip); + let steps = 0; + while (cur && steps++ < MAX_LOG_SCAN && out.length < cap) { + const myOid = this.pathOid(cur.commit, path); + const parent = cur.commit.parents[0] ? this.peelToCommit(cur.commit.parents[0]) : null; + const parentOid = parent ? this.pathOid(parent.commit, path) : null; + if (myOid !== parentOid) { + const blob = myOid ? this.store.get(myOid) : null; + out.push({ oid: cur.oid, commit: cur.commit, blob: blob?.type === "blob" ? blob.data : null }); + if (!parentOid) break; // file created here + } + cur = parent; + } + return out; + } + + /** Map oid -> decorations (branch/tag pointing at it). */ + private decorations(repo: string): Map<string, string> { + const map = new Map<string, string>(); + const r = `/${encodeURIComponent(repo)}`; + for (const ref of this.store.refs()) { + let html = ""; + let target = ref.target; + if (ref.name.startsWith("refs/heads/")) { + html = `<a class='branch-deco' href='${r}/log/?h=${encodeURIComponent(ref.name.slice(11))}'>${esc(ref.name.slice(11))}</a>`; + } else if (ref.name.startsWith("refs/tags/")) { + const peeled = this.peelToCommit(ref.target); + if (peeled) target = peeled.oid; + html = `<a class='tag-deco' href='${r}/tag/?h=${encodeURIComponent(ref.name.slice(10))}'>${esc(ref.name.slice(10))}</a>`; + } else continue; + map.set(target, (map.get(target) ?? "") + html); + } + return map; + } + + private lookupPath( + rootTree: string, + path: string[] + ): { kind: "tree"; entries: TreeEntry[] } | { kind: "blob"; entry: TreeEntry } | null { + let treeOid = rootTree; + for (let i = 0; i < path.length; i++) { + const obj = this.store.get(treeOid); + if (obj?.type !== "tree") return null; + const entry = parseTree(obj.data).find((e) => e.name === path[i]); + if (!entry) return null; + if (i === path.length - 1 && !isTreeMode(entry.mode)) { + return { kind: "blob", entry }; + } + if (!isTreeMode(entry.mode)) return null; + treeOid = entry.oid; + } + const obj = this.store.get(treeOid); + if (obj?.type !== "tree") return null; + return { kind: "tree", entries: parseTree(obj.data) }; + } + + private findReadme(): { name: string; oid: string } | null { + const head = this.store.resolveHead(); + if (!head) return null; + const c = this.peelToCommit(head); + if (!c) return null; + const root = this.store.get(c.commit.tree); + if (root?.type !== "tree") return null; + const entries = parseTree(root.data); + for (const want of README_NAMES) { + const e = entries.find((x) => x.name.toLowerCase() === want && !isTreeMode(x.mode)); + if (e) return { name: e.name, oid: e.oid }; + } + return null; + } + + private flattenTree(treeOid: string, prefix: string, out: Map<string, { oid: string; mode: string }>): void { + const obj = this.store.get(treeOid); + if (obj?.type !== "tree") return; + for (const e of parseTree(obj.data)) { + const p = prefix ? `${prefix}/${e.name}` : e.name; + if (isTreeMode(e.mode)) this.flattenTree(e.oid, p, out); + else if (!isGitlinkMode(e.mode)) out.set(p, { oid: e.oid, mode: e.mode }); + } + } + + + private computeDiff(oldTree: string | null, newTree: string): { files: FileDiff[]; truncated: boolean } { + const oldFiles = new Map<string, { oid: string; mode: string }>(); + const newFiles = new Map<string, { oid: string; mode: string }>(); + if (oldTree) this.flattenTree(oldTree, "", oldFiles); + this.flattenTree(newTree, "", newFiles); + const paths = [...new Set([...oldFiles.keys(), ...newFiles.keys()])].sort(); + const files: FileDiff[] = []; + let truncated = false; + for (const p of paths) { + const o = oldFiles.get(p) ?? null; + const n = newFiles.get(p) ?? null; + if (o && n && o.oid === n.oid && o.mode === n.mode) continue; + if (files.length >= MAX_DIFF_FILES) { + truncated = true; + break; + } + const oldData = o ? this.store.get(o.oid)?.data ?? new Uint8Array(0) : new Uint8Array(0); + const newData = n ? this.store.get(n.oid)?.data ?? new Uint8Array(0) : new Uint8Array(0); + const fd: FileDiff = { path: p, o, n, kind: "text", ops: null, hunks: [], add: 0, del: 0 }; + if (isBinary(oldData) || isBinary(newData)) { + fd.kind = "binary"; + } else if (oldData.length > MAX_DIFF_BLOB || newData.length > MAX_DIFF_BLOB) { + fd.kind = "toolarge"; + } else { + const ops = diffLines(td.decode(oldData), td.decode(newData)); + if (!ops) { + fd.kind = "toolarge"; + } else { + fd.ops = ops; + fd.hunks = toHunks(ops); + for (const op of ops) { + if (op.tag === "add") fd.add++; + if (op.tag === "del") fd.del++; + } + } + } + files.push(fd); + } + return { files, truncated }; + } + + private renderDiffHtml(repo: string, files: FileDiff[], truncated: boolean): string { + let statRows = ""; + let diffHtml = ""; + let totalAdd = 0, totalDel = 0; + for (const f of files) { + totalAdd += f.add; + totalDel += f.del; + const status = !f.o + ? " (new)" + : !f.n + ? " (deleted)" + : f.o.mode !== f.n.mode + ? ` <span class='modechange'>[mode ${f.o.mode} -> ${f.n.mode}]</span>` + : ""; + statRows += `<tr><td>${esc(f.path)}${status}</td><td class='add right'>+${f.add}</td><td class='del right'>-${f.del}</td></tr>`; + let bodyHtml: string; + if (f.kind === "binary") { + bodyHtml = `<div>Binary files differ</div>`; + } else if (f.kind === "toolarge") { + bodyHtml = `<div>Diff skipped: file too large</div>`; + } else { + bodyHtml = f.hunks.map((hk) => renderHunk(hk)).join(""); + } + diffHtml += `<div class='head'>diff --git a/${esc(f.path)} b/${esc(f.path)}</div>${bodyHtml}`; + } + return ` +<div class='diffstat-header'>Diffstat</div> +<table class='diffstat'> +${statRows || "<tr><td>(no changes)</td></tr>"} +</table> +<div class='diffstat-summary'>${files.length} file${files.length === 1 ? "" : "s"} changed, ${totalAdd} insertions(+), ${totalDel} deletions(-)${truncated ? " [diff truncated]" : ""}</div> +<table class='diff'><tr><td>${diffHtml}</td></tr></table>`; + } + + private renderRawDiff(files: FileDiff[]): string { + let out = ""; + for (const f of files) { + out += `diff --git a/${f.path} b/${f.path}\n`; + if (!f.o) { + out += `new file mode ${f.n!.mode.padStart(6, "0")}\n`; + out += `index 0000000..${f.n!.oid.slice(0, 7)}\n`; + } else if (!f.n) { + out += `deleted file mode ${f.o.mode.padStart(6, "0")}\n`; + out += `index ${f.o.oid.slice(0, 7)}..0000000\n`; + } else { + if (f.o.mode !== f.n.mode) { + out += `old mode ${f.o.mode.padStart(6, "0")}\nnew mode ${f.n.mode.padStart(6, "0")}\n`; + } + out += `index ${f.o.oid.slice(0, 7)}..${f.n.oid.slice(0, 7)}${f.o.mode === f.n.mode ? ` ${f.n.mode.padStart(6, "0")}` : ""}\n`; + } + if (f.kind === "binary") { + out += `Binary files a/${f.path} and b/${f.path} differ\n`; + continue; + } + if (f.kind === "toolarge") { + out += `--- diff skipped: file too large ---\n`; + continue; + } + out += f.o ? `--- a/${f.path}\n` : `--- /dev/null\n`; + out += f.n ? `+++ b/${f.path}\n` : `+++ /dev/null\n`; + for (const hk of f.hunks) { + out += `@@ -${hk.aStart},${hk.aLen} +${hk.bStart},${hk.bLen} @@\n`; + for (const op of hk.ops) { + out += (op.tag === "add" ? "+" : op.tag === "del" ? "-" : " ") + op.line + "\n"; + } + } + } + return out; + } + + + private aboutPage(repo: string, h: string | undefined): Response { + const base = this.base(repo, "about", h, `/${encodeURIComponent(repo)}/about/`); + const readme = this.findReadme(); + if (!readme) return errorPage(base, "no readme found"); + const obj = this.store.get(readme.oid); + if (!obj) return errorPage(base, "missing readme blob"); + const text = td.decode(obj.data); + const lower = readme.name.toLowerCase(); + const body = + lower.endsWith(".md") || lower.endsWith(".markdown") + ? `<div class='md'>${renderMarkdown(text)}</div>` + : `<pre>${esc(text)}</pre>`; + return htmlResponse(layout({ ...base, body })); + } + + private summaryPage(repo: string, host: string, proto: string): Response { + const base = this.base(repo, "summary"); + const branches = this.store.refs().filter((r) => r.name.startsWith("refs/heads/")); + const tags = this.store.refs().filter((r) => r.name.startsWith("refs/tags/")); + const r = `/${encodeURIComponent(repo)}`; + + if (!branches.length && !tags.length) { + return htmlResponse( + layout({ + ...base, + body: + `<div class='error'>empty repository</div>` + + `<p>push something to get started:</p>` + + `<pre>git remote add origin ${esc(proto)}://${esc(host)}/${esc(repo)}.git\ngit push -u origin main</pre>`, + }) + ); + } + + const branchRows = branches + .slice(0, 10) + .map((b) => { + const name = b.name.slice(11); + const c = this.peelToCommit(b.target); + if (!c) return ""; + return `<tr><td><a href='${r}/log/?h=${encodeURIComponent(name)}'>${esc(name)}</a></td>` + + `<td><a href='${r}/commit/?id=${c.oid}'>${esc(c.commit.subject)}</a></td>` + + `<td>${esc(c.commit.author.name)}</td><td>${age(c.commit.committer.time)}</td></tr>`; + }) + .join(""); + + const tagRows = tags + .slice(0, 10) + .map((t) => { + const name = t.name.slice(10); + const obj = this.store.get(t.target); + let when = 0; + let target = t.target; + if (obj?.type === "tag") { + const tag = parseTag(obj.data); + when = tag.tagger?.time ?? 0; + target = tag.object; + } + const c = this.peelToCommit(target); + if (c && !when) when = c.commit.committer.time; + const snap = `<a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.tar.gz'>tar.gz</a> ` + + `<a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.zip'>zip</a>`; + return `<tr><td><a href='${r}/tag/?h=${encodeURIComponent(name)}'>${esc(name)}</a></td>` + + `<td><a href='${r}/commit/?id=${target}'>${esc(c?.commit.subject ?? "")}</a></td>` + + `<td>${esc(c?.commit.author.name ?? "")}</td><td>${age(when)}</td><td class='snapshots'>${snap}</td></tr>`; + }) + .join(""); + + const headOid = this.store.resolveHead(); + const recent = headOid ? this.walkLog(headOid, 0, 10).entries : []; + const deco = this.decorations(repo); + const logRows = recent + .map( + (e) => + `<tr><td>${age(e.commit.committer.time)}</td>` + + `<td><a href='${r}/commit/?id=${e.oid}'>${esc(e.commit.subject)}</a>${deco.get(e.oid) ?? ""}</td>` + + `<td>${esc(e.commit.author.name)}</td></tr>` + ) + .join(""); + + const body = ` +<table class='list nowrap'> +<tr class='nohover'><th class='left'>Branch</th><th class='left'>Commit message</th><th class='left'>Author</th><th class='left'>Age</th><th></th></tr> +${branchRows} +${tags.length ? `<tr class='nohover'><td colspan='5'> </td></tr><tr class='nohover'><th class='left'>Tag</th><th class='left'>Commit message</th><th class='left'>Author</th><th class='left'>Age</th><th class='left'>Download</th></tr>${tagRows}` : ""} +<tr class='nohover'><td colspan='5'> </td></tr> +<tr class='nohover'><th class='left'>Age</th><th class='left'>Commit message</th><th class='left'>Author</th><th colspan='2'></th></tr> +${logRows} +<tr class='nohover'><td colspan='5'> </td></tr> +<tr class='nohover'><th class='left' colspan='5'>Clone</th></tr> +<tr class='nohover'><td colspan='5' class='clone-url'>${esc(proto)}://${esc(host)}/${esc(repo)}.git</td></tr> +</table>`; + return htmlResponse(layout({ ...base, body })); + } + + private logPage(repo: string, h: string | undefined, ofs: number, filter: LogFilter): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "log", h, `${r}/log/`); + let tip = h; + if (filter.qt === "range" && filter.q) { + tip = filter.q; + filter = {}; + } + const rr = this.resolveRef(tip); + if (!rr) return errorPage(base, tip ? `bad ref: ${tip}` : "empty repository"); + const { entries, more } = this.walkLog(rr.oid, ofs, LOG_PAGE, filter); + const deco = this.decorations(repo); + const rows = entries + .map( + (e) => + `<tr><td>${age(e.commit.committer.time)}</td>` + + `<td><a href='${r}/commit/?id=${e.oid}'>${esc(e.commit.subject)}</a>${deco.get(e.oid) ?? ""}</td>` + + `<td>${esc(e.commit.author.name)}</td></tr>` + ) + .join(""); + const params = new URLSearchParams(); + if (h) params.set("h", h); + if (filter.q) params.set("q", filter.q); + if (filter.qt) params.set("qt", filter.qt); + if (filter.path?.length) params.set("path", filter.path.join("/")); + const link = (o: number) => { + const p = new URLSearchParams(params); + if (o > 0) p.set("ofs", String(o)); + return `?${p.toString()}`; + }; + const nav = + `<div style='margin-top:1em'>` + + (ofs > 0 ? `<a href='${link(Math.max(0, ofs - LOG_PAGE))}'>[prev]</a> ` : "") + + (more ? `<a href='${link(ofs + LOG_PAGE)}'>[next]</a>` : "") + + `</div>`; + const qt = filter.qt ?? "grep"; + const searchForm = + `<form method='get' action='${r}/log/'>` + + (h ? `<input type='hidden' name='h' value='${esc(h)}'/>` : "") + + `<select name='qt'>` + + ["grep", "author", "committer", "range"] + .map((t) => `<option value='${t}'${t === qt ? " selected='selected'" : ""}>${t}</option>`) + .join("") + + `</select> <input type='text' name='q' size='30' value='${esc(filter.q ?? "")}'/> ` + + `<input type='submit' value='search'/></form>`; + const pathNote = filter.path?.length + ? `<div class='path'>path: ${esc(filter.path.join("/"))} (<a href='${r}/log/${h ? `?h=${encodeURIComponent(h)}` : ""}'>clear</a>)</div>` + : ""; + const body = ` +${searchForm} +${pathNote} +<table class='list nowrap'> +<tr class='nohover'><th class='left'>Age</th><th class='left'>Commit message</th><th class='left'>Author</th></tr> +${rows || "<tr class='nohover'><td colspan='3'>(no matching commits)</td></tr>"} +</table>${nav}`; + return htmlResponse(layout({ ...base, body })); + } + + private refsPage(repo: string): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "refs", undefined, `${r}/refs/`); + const refs = this.store.refs(); + const branches = refs.filter((x) => x.name.startsWith("refs/heads/")); + const tags = refs.filter((x) => x.name.startsWith("refs/tags/")); + const branchRows = branches + .map((b) => { + const name = b.name.slice(11); + const c = this.peelToCommit(b.target); + const snap = `<a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.tar.gz'>tar.gz</a> ` + + `<a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.zip'>zip</a>`; + return `<tr><td><a href='${r}/log/?h=${encodeURIComponent(name)}'>${esc(name)}</a></td>` + + `<td class='sha1'><a href='${r}/commit/?id=${b.target}'>${b.target.slice(0, 10)}</a></td>` + + `<td>${esc(c?.commit.author.name ?? "")}</td><td>${c ? age(c.commit.committer.time) : ""}</td><td class='snapshots'>${snap}</td></tr>`; + }) + .join(""); + const tagRows = tags + .map((t) => { + const name = t.name.slice(10); + const obj = this.store.get(t.target); + let when = 0; + let who = ""; + let target = t.target; + if (obj?.type === "tag") { + const tag = parseTag(obj.data); + when = tag.tagger?.time ?? 0; + who = tag.tagger?.name ?? ""; + target = tag.object; + } else if (obj?.type === "commit") { + const c = parseCommit(obj.data); + when = c.committer.time; + who = c.author.name; + } + const snap = `<a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.tar.gz'>tar.gz</a> ` + + `<a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.zip'>zip</a>`; + return `<tr><td><a href='${r}/tag/?h=${encodeURIComponent(name)}'>${esc(name)}</a></td>` + + `<td class='sha1'><a href='${r}/commit/?id=${target}'>${target.slice(0, 10)}</a></td>` + + `<td>${esc(who)}</td><td>${age(when)}</td><td class='snapshots'>${snap}</td></tr>`; + }) + .join(""); + const body = ` +<table class='list nowrap'> +<tr class='nohover'><th class='left' colspan='5'>Branch</th></tr> +${branchRows || "<tr class='nohover'><td colspan='5'>none</td></tr>"} +<tr class='nohover'><td colspan='5'> </td></tr> +<tr class='nohover'><th class='left' colspan='5'>Tag</th></tr> +${tagRows || "<tr class='nohover'><td colspan='5'>none</td></tr>"} +</table>`; + return htmlResponse(layout({ ...base, body })); + } + + private pathBar(repo: string, h: string | undefined, path: string[]): string { + const q = h ? `?h=${encodeURIComponent(h)}` : ""; + let html = `path: <a href='/${encodeURIComponent(repo)}/tree/${q}'>root</a>`; + let acc = ""; + for (const seg of path) { + acc += "/" + encodeURIComponent(seg); + html += `/<a href='/${encodeURIComponent(repo)}/tree${acc}${q}'>${esc(seg)}</a>`; + } + return html; + } + + private treePage(repo: string, h: string | undefined, path: string[]): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "tree", h, `${r}/tree/${path.map(encodeURIComponent).join("/")}`); + const rr = this.resolveRef(h); + if (!rr) return errorPage(base, h ? `bad ref: ${h}` : "empty repository"); + const head = this.peelToCommit(rr.oid); + if (!head) return errorPage(base, "no commit found"); + const found = this.lookupPath(head.commit.tree, path); + if (!found) return errorPage(base, `path not found: ${path.join("/")}`); + const withPath = { ...base, pathBar: this.pathBar(repo, h, path) }; + + if (found.kind === "blob") { + return this.blobPage(repo, h, path, found.entry, withPath); + } + + const q = h ? `?h=${encodeURIComponent(h)}` : ""; + const prefix = path.map(encodeURIComponent).join("/"); + const rows = found.entries + .map((e) => { + const href = `${prefix ? prefix + "/" : ""}${encodeURIComponent(e.name)}`; + const dir = isTreeMode(e.mode); + const gitlink = isGitlinkMode(e.mode); + const size = dir || gitlink ? "" : String(this.store.typeAndSize(e.oid)?.size ?? ""); + const link = gitlink + ? `${esc(e.name)} @ ${e.oid.slice(0, 10)}` + : `<a class='${dir ? "ls-dir" : "ls-blob"}' href='${r}/tree/${href}${q}'>${esc(e.name)}</a>`; + const pathParam = [...path, e.name].map(encodeURIComponent).join("/"); + const logLink = `<a href='${r}/log/?${h ? `h=${encodeURIComponent(h)}&` : ""}path=${pathParam}'>log</a>`; + const fileLinks = dir || gitlink ? "" : ` <a href='${r}/plain/${href}${q}'>plain</a> <a href='${r}/blame/${href}${q}'>blame</a>`; + return `<tr><td class='ls-mode'>${modeString(e.mode)}</td><td>${link}</td><td class='ls-size'>${size}</td>` + + `<td>${gitlink ? "" : logLink}${fileLinks}</td></tr>`; + }) + .join(""); + const body = ` +<table class='list'> +<tr class='nohover'><th class='left'>Mode</th><th class='left'>Name</th><th class='right'>Size</th><th></th></tr> +${rows} +</table>`; + return htmlResponse(layout({ ...withPath, body })); + } + + private blobPage( + repo: string, + h: string | undefined, + path: string[], + entry: TreeEntry, + base: Omit<LayoutOpts, "body"> + ): Response { + const obj = this.store.get(entry.oid); + if (!obj) return errorPage(base, "missing blob"); + const r = `/${encodeURIComponent(repo)}`; + const q = h ? `?h=${encodeURIComponent(h)}` : ""; + const href = path.map(encodeURIComponent).join("/"); + const plainHref = `${r}/plain/${href}${q}`; + if (isBinary(obj.data)) { + const body = `<div>Binary file (${obj.data.length} bytes) — <a href='${plainHref}'>download</a></div>`; + return htmlResponse(layout({ ...base, body })); + } + const name = path[path.length - 1] ?? ""; + const lines = highlightLines(td.decode(obj.data), name); + const nums = lines.map((_, i) => `<a id='n${i + 1}' href='#n${i + 1}'>${i + 1}</a>`).join("\n"); + const code = lines.map((l) => l || " ").join("\n"); + const body = ` +<div>blob: ${entry.oid} (<a href='${plainHref}'>plain</a>) (<a href='${r}/blame/${href}${q}'>blame</a>)</div> +<table class='blob'> +<tr><td class='linenumbers'><pre>${nums}</pre></td><td class='lines'><pre><code>${code}</code></pre></td></tr> +</table>`; + return htmlResponse(layout({ ...base, body })); + } + + private blamePage(repo: string, h: string | undefined, path: string[]): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "tree", h, `${r}/blame/${path.map(encodeURIComponent).join("/")}`); + const withPath = { ...base, pathBar: this.pathBar(repo, h, path) }; + const rr = this.resolveRef(h); + if (!rr) return errorPage(withPath, "empty repository"); + if (!path.length) return errorPage(withPath, "blame needs a file path"); + const history = this.pathHistory(rr.oid, path, 200); + if (!history.length || !history[0].blob) return errorPage(withPath, `no such file: ${path.join("/")}`); + if (isBinary(history[0].blob)) return errorPage(withPath, "cannot blame a binary file"); + const result = blame(history); + if (!result) return errorPage(withPath, "blame skipped: file too large"); + // group consecutive lines from the same commit + let rows = ""; + for (let i = 0; i < result.length; ) { + let j = i; + while (j < result.length && result[j].oid === result[i].oid) j++; + const b = result[i]; + const codeLines: string[] = []; + const numLines: string[] = []; + for (let k = i; k < j; k++) { + numLines.push(String(k + 1)); + codeLines.push(esc(result[k].line) || " "); + } + rows += `<tr><td class='sha1'><a href='${r}/commit/?id=${b.oid}'>${b.oid.slice(0, 8)}</a> ${esc(b.author)} ${age(b.time)}</td>` + + `<td class='linenumbers'><pre>${numLines.join("\n")}</pre></td>` + + `<td class='lines'><pre>${codeLines.join("\n")}</pre></td></tr>`; + i = j; + } + const body = `<table class='blame blob'>${rows}</table>`; + return htmlResponse(layout({ ...withPath, body })); + } + + private plainPage(h: string | undefined, path: string[]): Response { + const rr = this.resolveRef(h); + if (!rr) return new Response("not found\n", { status: 404 }); + const head = this.peelToCommit(rr.oid); + if (!head) return new Response("not found\n", { status: 404 }); + const found = this.lookupPath(head.commit.tree, path); + if (!found || found.kind !== "blob") return new Response("not found\n", { status: 404 }); + const obj = this.store.get(found.entry.oid); + if (!obj) return new Response("not found\n", { status: 404 }); + return rawBlobResponse(obj.data, path[path.length - 1] ?? ""); + } + + private blobByIdPage(id: string): Response { + const oid = this.store.findOid(id); + if (!oid) return new Response("not found\n", { status: 404 }); + const obj = this.store.get(oid); + if (!obj || obj.type !== "blob") return new Response("not a blob\n", { status: 404 }); + return rawBlobResponse(obj.data, ""); + } + + private commitPage(repo: string, id: string | undefined, h: string | undefined): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "commit", h, `${r}/commit/`); + const oid = this.resolveCommitId(id, h); + if (!oid) return errorPage(base, "commit not found"); + const commit = this.loadCommit(oid); + if (!commit) return errorPage(base, `commit not found: ${oid}`); + + const parent = commit.parents[0] ? this.loadCommit(commit.parents[0]) : null; + const { files, truncated } = this.computeDiff(parent?.tree ?? null, commit.tree); + const deco = this.decorations(repo); + + const body = ` +<table class='commit-info'> +<tr><th>author</th><td>${esc(commit.author.name)} <${esc(commit.author.email)}></td><td class='right'>${fmtDate(commit.author.time, commit.author.tz)}</td></tr> +<tr><th>committer</th><td>${esc(commit.committer.name)} <${esc(commit.committer.email)}></td><td class='right'>${fmtDate(commit.committer.time, commit.committer.tz)}</td></tr> +<tr><th>commit</th><td colspan='2' class='sha1'>${oid} (<a href='${r}/patch/?id=${oid}'>patch</a>)</td></tr> +<tr><th>tree</th><td colspan='2' class='sha1'><a href='${r}/tree/?h=${oid}'>${commit.tree}</a></td></tr> +${commit.parents.map((p) => `<tr><th>parent</th><td colspan='2' class='sha1'><a href='?id=${p}'>${p}</a> (<a href='${r}/diff/?id=${oid}&id2=${p}'>diff</a>)</td></tr>`).join("")} +<tr><th>download</th><td colspan='2' class='sha1'><a href='${r}/snapshot/${encodeURIComponent(repo)}-${oid.slice(0, 10)}.tar.gz'>${esc(repo)}-${oid.slice(0, 10)}.tar.gz</a> <a href='${r}/snapshot/${encodeURIComponent(repo)}-${oid.slice(0, 10)}.zip'>zip</a></td></tr> +</table> +<div class='commit-subject'>${esc(commit.subject)}${deco.get(oid) ?? ""}</div> +<div class='commit-msg'>${esc(commit.message.split("\n").slice(1).join("\n").trim())}</div> +${this.renderDiffHtml(repo, files, truncated)}`; + return htmlResponse(layout({ ...base, body })); + } + + private resolveCommitId(id: string | undefined, h: string | undefined): string | null { + if (id) { + const full = this.store.findOid(id); + return full ? this.peelToCommit(full)?.oid ?? null : null; + } + const rr = this.resolveRef(h); + return rr ? this.peelToCommit(rr.oid)?.oid ?? null : null; + } + + /** diff/rawdiff: changes id2..id (default id2 = first parent of id). */ + private diffPage(repo: string, id: string | undefined, id2: string | undefined, h: string | undefined, raw: boolean): Response { + const base = this.base(repo, "diff", h, `/${encodeURIComponent(repo)}/diff/`); + const newOid = this.resolveCommitId(id, h); + if (!newOid) return raw ? new Response("not found\n", { status: 404 }) : errorPage(base, "commit not found"); + const commit = this.loadCommit(newOid)!; + let oldOid: string | null = null; + if (id2) { + oldOid = this.resolveCommitId(id2, undefined); + if (!oldOid) return raw ? new Response("bad id2\n", { status: 404 }) : errorPage(base, `bad id2: ${id2}`); + } else { + oldOid = commit.parents[0] ?? null; + } + const oldCommit = oldOid ? this.loadCommit(oldOid) : null; + const { files, truncated } = this.computeDiff(oldCommit?.tree ?? null, commit.tree); + if (raw) { + return new Response(this.renderRawDiff(files), { headers: { "content-type": "text/plain; charset=utf-8" } }); + } + const body = ` +<div class='commit-subject'>diff: ${oldOid ? `<a href='../commit/?id=${oldOid}'>${oldOid.slice(0, 10)}</a>` : "(root)"} .. <a href='../commit/?id=${newOid}'>${newOid.slice(0, 10)}</a></div> +${this.renderDiffHtml(repo, files, truncated)}`; + return htmlResponse(layout({ ...base, body })); + } + + /** git-format-patch style output, applies with `git am`. */ + private patchPage(repo: string, id: string | undefined, h: string | undefined): Response { + const oid = this.resolveCommitId(id, h); + if (!oid) return new Response("not found\n", { status: 404 }); + const commit = this.loadCommit(oid)!; + const parent = commit.parents[0] ? this.loadCommit(commit.parents[0]) : null; + const { files } = this.computeDiff(parent?.tree ?? null, commit.tree); + const bodyText = commit.message.split("\n").slice(1).join("\n").trim(); + let statLines = ""; + let totalAdd = 0, totalDel = 0; + for (const f of files) { + statLines += ` ${f.path} | ${f.add + f.del} ${"+".repeat(Math.min(f.add, 30))}${"-".repeat(Math.min(f.del, 30))}\n`; + totalAdd += f.add; + totalDel += f.del; + } + const patch = + `From ${oid} Mon Sep 17 00:00:00 2001\n` + + `From: ${commit.author.name} <${commit.author.email}>\n` + + `Date: ${fmtDate2822(commit.author.time, commit.author.tz)}\n` + + `Subject: [PATCH] ${commit.subject}\n` + + `\n` + + (bodyText ? bodyText + "\n" : "") + + `---\n` + + statLines + + ` ${files.length} file${files.length === 1 ? "" : "s"} changed, ${totalAdd} insertions(+), ${totalDel} deletions(-)\n` + + `\n` + + this.renderRawDiff(files) + + `--\ndgit 0.2\n`; + return new Response(patch, { headers: { "content-type": "text/plain; charset=utf-8" } }); + } + + private tagPage(repo: string, name: string | undefined): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "refs", undefined, `${r}/refs/`); + if (!name) return errorPage(base, "no tag given"); + const target = this.store.getRef(`refs/tags/${name}`) ?? this.store.findOid(name); + if (!target) return errorPage(base, `tag not found: ${name}`); + const obj = this.store.get(target); + if (!obj) return errorPage(base, `missing object`); + let body: string; + if (obj.type === "tag") { + const tag = parseTag(obj.data); + body = ` +<table class='commit-info'> +<tr><th>tag name</th><td>${esc(tag.tag || name)}</td></tr> +${tag.tagger ? `<tr><th>tag date</th><td>${fmtDate(tag.tagger.time, tag.tagger.tz)}</td></tr><tr><th>tagged by</th><td>${esc(tag.tagger.name)} <${esc(tag.tagger.email)}></td></tr>` : ""} +<tr><th>tagged object</th><td class='sha1'><a href='${r}/commit/?id=${tag.object}'>${tag.object}</a> (${esc(tag.type)})</td></tr> +<tr><th>download</th><td class='sha1'><a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.tar.gz'>${esc(repo)}-${esc(name)}.tar.gz</a> <a href='${r}/snapshot/${encodeURIComponent(repo)}-${encodeURIComponent(name)}.zip'>zip</a></td></tr> +</table> +<div class='commit-msg'>${esc(tag.message.trim())}</div>`; + } else { + body = ` +<table class='commit-info'> +<tr><th>tag name</th><td>${esc(name)}</td></tr> +<tr><th>tagged object</th><td class='sha1'><a href='${r}/commit/?id=${target}'>${target}</a> (lightweight)</td></tr> +</table>`; + } + return htmlResponse(layout({ ...base, body })); + } + + private snapshotPage(repo: string, filename: string): Response { + let format: "tar.gz" | "zip"; + let stem: string; + if (filename.endsWith(".tar.gz")) { + format = "tar.gz"; + stem = filename.slice(0, -7); + } else if (filename.endsWith(".tgz")) { + format = "tar.gz"; + stem = filename.slice(0, -4); + } else if (filename.endsWith(".zip")) { + format = "zip"; + stem = filename.slice(0, -4); + } else { + return new Response("unsupported snapshot format (use .tar.gz or .zip)\n", { status: 400 }); + } + // cgit-style: reponame-ref.tar.gz; also accept a bare ref and a v-prefix + const candidates = [stem]; + if (stem.startsWith(`${repo}-`)) candidates.push(stem.slice(repo.length + 1)); + for (const c of [...candidates]) candidates.push(`v${c}`); + let commit: { oid: string; commit: Commit } | null = null; + for (const cand of candidates) { + const rr = this.resolveRef(cand); + if (rr) { + commit = this.peelToCommit(rr.oid); + if (commit) break; + } + } + if (!commit) return new Response(`no ref matches snapshot name: ${stem}\n`, { status: 404 }); + + const flat = new Map<string, { oid: string; mode: string }>(); + this.flattenTree(commit.commit.tree, "", flat); + const files: SnapshotFile[] = []; + let total = 0; + for (const [path, info] of flat) { + const obj = this.store.get(info.oid); + if (!obj) continue; + total += obj.data.length; + if (total > MAX_SNAPSHOT_BYTES) { + return new Response("snapshot too large\n", { status: 413 }); + } + const m = parseInt(info.mode, 8); + files.push({ + path, + mode: m & 0o100 ? 0o755 : 0o644, + symlink: (m & 0o170000) === 0o120000, + data: obj.data, + }); + } + const archive = format === "tar.gz" + ? tarGz(stem, files, commit.commit.committer.time) + : zip(stem, files, commit.commit.committer.time); + return new Response(archive as unknown as BodyInit, { + headers: { + "content-type": format === "tar.gz" ? "application/gzip" : "application/zip", + "content-disposition": `attachment; filename="${filename.replaceAll('"', "")}"`, + }, + }); + } + + private atomPage(repo: string, host: string, proto: string, h: string | undefined): Response { + const rr = this.resolveRef(h); + if (!rr) return new Response("empty repository\n", { status: 404 }); + const { entries } = this.walkLog(rr.oid, 0, 20); + const abs = `${proto}://${host}/${encodeURIComponent(repo)}`; + const iso = (t: number) => new Date(t * 1000).toISOString().replace(/\.\d+Z$/, "Z"); + const updated = entries[0] ? iso(entries[0].commit.committer.time) : iso(0); + const items = entries + .map( + (e) => `<entry> +<title>${esc(e.commit.subject)}</title> +<updated>${iso(e.commit.committer.time)}</updated> +<author><name>${esc(e.commit.author.name)}</name><email>${esc(e.commit.author.email)}</email></author> +<published>${iso(e.commit.author.time)}</published> +<link rel='alternate' type='text/html' href='${abs}/commit/?id=${e.oid}'/> +<id>urn:sha1:${e.oid}</id> +<content type='text'>${esc(e.commit.message)}</content> +</entry>` + ) + .join("\n"); + const xml = `<?xml version='1.0' encoding='UTF-8'?> +<feed xmlns='http://www.w3.org/2005/Atom'> +<title>${esc(repo)}</title> +<subtitle>${esc(this.store.getMeta("description") ?? "")}</subtitle> +<id>${abs}/</id> +<link rel='self' href='${abs}/atom/'/> +<link rel='alternate' type='text/html' href='${abs}/'/> +<updated>${updated}</updated> +${items} +</feed>`; + return new Response(xml, { headers: { "content-type": "application/atom+xml; charset=utf-8" } }); + } + + private statsPage(repo: string, h: string | undefined, period: string): Response { + const r = `/${encodeURIComponent(repo)}`; + const base = this.base(repo, "stats", h, `${r}/stats/`); + const rr = this.resolveRef(h); + if (!rr) return errorPage(base, "empty repository"); + const { entries } = this.walkLog(rr.oid, 0, MAX_STATS_SCAN); + + const keyOf = (t: number): string => { + const d = new Date(t * 1000); + if (period === "y") return String(d.getUTCFullYear()); + if (period === "w") { + const onejan = Date.UTC(d.getUTCFullYear(), 0, 1); + const week = Math.ceil(((d.getTime() - onejan) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; + } + if (period === "q") return `${d.getUTCFullYear()}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`; + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; + }; + const periodKeys: string[] = []; + const byAuthor = new Map<string, Map<string, number>>(); + const totals = new Map<string, number>(); + for (const e of entries) { + const key = keyOf(e.commit.committer.time); + if (!periodKeys.includes(key)) periodKeys.push(key); + const author = e.commit.author.name || "(unknown)"; + if (!byAuthor.has(author)) byAuthor.set(author, new Map()); + const m = byAuthor.get(author)!; + m.set(key, (m.get(key) ?? 0) + 1); + totals.set(key, (totals.get(key) ?? 0) + 1); + } + const cols = periodKeys.slice(0, 8); + const authors = [...byAuthor.entries()] + .map(([name, m]) => ({ name, m, total: [...m.values()].reduce((a, b) => a + b, 0) })) + .sort((a, b) => b.total - a.total) + .slice(0, 20); + const periodLinks = [ + ["w", "week"], + ["m", "month"], + ["q", "quarter"], + ["y", "year"], + ] + .map(([p, label]) => + p === period + ? `<strong>${label}</strong>` + : `<a href='?${h ? `h=${encodeURIComponent(h)}&` : ""}period=${p}'>${label}</a>` + ) + .join(" | "); + const rows = authors + .map( + (a) => + `<tr><td class='name'>${esc(a.name)}</td>` + + cols.map((k) => `<td>${a.m.get(k) ?? ""}</td>`).join("") + + `<td><strong>${a.total}</strong></td></tr>` + ) + .join(""); + const totalRow = + `<tr><td class='name'><strong>Total</strong></td>` + + cols.map((k) => `<td><strong>${totals.get(k) ?? 0}</strong></td>`).join("") + + `<td><strong>${entries.length}</strong></td></tr>`; + const body = ` +<div>Commits per author per ${period === "w" ? "week" : period === "y" ? "year" : period === "q" ? "quarter" : "month"} (${periodLinks})${entries.length >= MAX_STATS_SCAN ? ` — last ${MAX_STATS_SCAN} commits` : ""}</div> +<table class='stats'> +<tr><th>Author</th>${cols.map((k) => `<th>${esc(k)}</th>`).join("")}<th>Total</th></tr> +${rows} +${totalRow} +</table>`; + return htmlResponse(layout({ ...base, body })); + } +} + +function renderHunk(hk: Hunk): string { + let html = `<div class='hunk'>@@ -${hk.aStart},${hk.aLen} +${hk.bStart},${hk.bLen} @@</div>`; + for (const op of hk.ops) { + const cls = op.tag === "add" ? "add" : op.tag === "del" ? "del" : "ctx"; + const sign = op.tag === "add" ? "+" : op.tag === "del" ? "-" : " "; + html += `<div class='${cls}'>${sign}${esc(op.line) || " "}</div>`; + } + return html; +} + +function rawBlobResponse(data: Uint8Array, name: string): Response { + const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase(); + const types: Record<string, string> = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", + svg: "image/svg+xml", pdf: "application/pdf", html: "text/plain", + }; + const ct = types[ext] ?? (isBinary(data) ? "application/octet-stream" : "text/plain; charset=utf-8"); + return new Response(data as unknown as BodyInit, { headers: { "content-type": ct } }); +} + +function decodePath(p: string): string[] { + return p + .split("/") + .filter((s) => s.length > 0) + .map((s) => decodeURIComponent(s)) + .filter((s) => s !== "." && s !== ".."); +} diff --git a/src/ui/highlight.ts b/src/ui/highlight.ts @@ -0,0 +1,186 @@ +import { esc } from "./html"; + +/** + * Small regex-based syntax highlighter. Returns per-line HTML (tokens never + * cross line boundaries in the output, so callers can add line numbers). + */ + +interface Lang { + keywords: Set<string>; + lineComment?: string; + blockComment?: [string, string]; + strings: string[]; // quote delimiters + hashComment?: boolean; +} + +const KW = (s: string) => new Set(s.split(" ")); + +const C_LIKE = "if else for while do switch case default break continue return goto typedef struct union enum const static volatile extern register signed unsigned void char short int long float double sizeof inline restrict bool true false NULL nullptr class public private protected virtual override new delete namespace using template typename this try catch throw operator friend constexpr auto"; +const JS = "abstract any as async await boolean break case catch class const continue debugger declare default delete do else enum export extends false finally for from function get if implements import in instanceof interface is keyof let module namespace never new null number object of package private protected public readonly return set static string super switch symbol this throw true try type typeof undefined var void while with yield"; +const PY = "and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield True False None self match case"; +const GO = "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false iota make new len cap append copy delete panic recover error string int int8 int16 int32 int64 uint byte rune float32 float64 bool"; +const RS = "as break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while async await union box Some None Ok Err String Vec Option Result"; +const SH = "if then else elif fi for while until do done case esac function in select time coproc return exit break continue local export readonly declare unset shift source alias echo printf read cd test set"; +const RB = "BEGIN END alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield require require_relative attr_accessor puts"; +const JAVA = "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null var record sealed permits"; +const SQL = "select from where insert into values update delete create table index view drop alter add primary key foreign references not null unique default check constraint join left right inner outer on as order by group having limit offset union all distinct and or in exists between like is case when then else end begin commit rollback transaction"; + +const LANGS: Record<string, Lang> = { + js: { keywords: KW(JS), lineComment: "//", blockComment: ["/*", "*/"], strings: ['"', "'", "`"] }, + py: { keywords: KW(PY), hashComment: true, strings: ['"""', "'''", '"', "'"] }, + go: { keywords: KW(GO), lineComment: "//", blockComment: ["/*", "*/"], strings: ['"', "'", "`"] }, + rs: { keywords: KW(RS), lineComment: "//", blockComment: ["/*", "*/"], strings: ['"'] }, + c: { keywords: KW(C_LIKE), lineComment: "//", blockComment: ["/*", "*/"], strings: ['"', "'"] }, + sh: { keywords: KW(SH), hashComment: true, strings: ['"', "'"] }, + rb: { keywords: KW(RB), hashComment: true, strings: ['"', "'"] }, + java: { keywords: KW(JAVA), lineComment: "//", blockComment: ["/*", "*/"], strings: ['"', "'"] }, + sql: { keywords: KW(SQL), lineComment: "--", blockComment: ["/*", "*/"], strings: ["'"] }, + css: { keywords: new Set(), blockComment: ["/*", "*/"], strings: ['"', "'"] }, + json: { keywords: KW("true false null"), strings: ['"'] }, + toml: { keywords: KW("true false"), hashComment: true, strings: ['"', "'"] }, + yaml: { keywords: KW("true false null yes no"), hashComment: true, strings: ['"', "'"] }, +}; + +const EXT_LANG: Record<string, string> = { + js: "js", jsx: "js", ts: "js", tsx: "js", mjs: "js", cjs: "js", + py: "py", pyi: "py", + go: "go", + rs: "rs", + c: "c", h: "c", cc: "c", cpp: "c", cxx: "c", hpp: "c", hh: "c", m: "c", java: "java", kt: "java", scala: "java", cs: "java", swift: "java", + sh: "sh", bash: "sh", zsh: "sh", fish: "sh", makefile: "sh", + rb: "rb", pl: "rb", php: "rb", + sql: "sql", + css: "css", scss: "css", less: "css", + json: "json", jsonc: "json", + toml: "toml", ini: "toml", cfg: "toml", + yaml: "yaml", yml: "yaml", +}; + +interface Token { + cls: string | null; + text: string; +} + +function tokenize(src: string, lang: Lang): Token[] { + const tokens: Token[] = []; + const n = src.length; + let i = 0; + let plain = ""; + const flushPlain = () => { + if (plain) { + tokens.push({ cls: null, text: plain }); + plain = ""; + } + }; + const isWord = (c: string) => /[A-Za-z0-9_$]/.test(c); + + outer: while (i < n) { + const c = src[i]; + // comments + if (lang.lineComment && src.startsWith(lang.lineComment, i)) { + flushPlain(); + let j = src.indexOf("\n", i); + if (j === -1) j = n; + tokens.push({ cls: "hl-c", text: src.slice(i, j) }); + i = j; + continue; + } + if (lang.hashComment && c === "#") { + flushPlain(); + let j = src.indexOf("\n", i); + if (j === -1) j = n; + tokens.push({ cls: "hl-c", text: src.slice(i, j) }); + i = j; + continue; + } + if (lang.blockComment && src.startsWith(lang.blockComment[0], i)) { + flushPlain(); + let j = src.indexOf(lang.blockComment[1], i + lang.blockComment[0].length); + j = j === -1 ? n : j + lang.blockComment[1].length; + tokens.push({ cls: "hl-c", text: src.slice(i, j) }); + i = j; + continue; + } + // strings + for (const q of lang.strings) { + if (src.startsWith(q, i)) { + flushPlain(); + let j = i + q.length; + while (j < n) { + if (src[j] === "\\") { + j += 2; + continue; + } + if (src.startsWith(q, j)) { + j += q.length; + break; + } + // single-quote strings don't span lines (heuristic) + if (q.length === 1 && q !== "`" && src[j] === "\n") break; + j++; + } + tokens.push({ cls: "hl-s", text: src.slice(i, Math.min(j, n)) }); + i = Math.min(j, n); + continue outer; + } + } + // numbers + if (/[0-9]/.test(c) && (i === 0 || !isWord(src[i - 1]))) { + let j = i + 1; + while (j < n && /[0-9a-fA-FxXoObB._]/.test(src[j])) j++; + flushPlain(); + tokens.push({ cls: "hl-n", text: src.slice(i, j) }); + i = j; + continue; + } + // words + if (/[A-Za-z_$]/.test(c)) { + let j = i + 1; + while (j < n && isWord(src[j])) j++; + const word = src.slice(i, j); + if (lang.keywords.has(word)) { + flushPlain(); + tokens.push({ cls: "hl-k", text: word }); + } else { + plain += word; + } + i = j; + continue; + } + plain += c; + i++; + } + flushPlain(); + return tokens; +} + +/** Highlight source; returns one HTML string per line (already escaped). */ +export function highlightLines(src: string, filename: string): string[] { + const base = filename.toLowerCase(); + const ext = base.includes(".") ? base.slice(base.lastIndexOf(".") + 1) : base; + const langKey = EXT_LANG[ext]; + const plainLines = src.split("\n"); + if (plainLines[plainLines.length - 1] === "") plainLines.pop(); + if (!langKey || src.length > 512 * 1024) { + return plainLines.map((l) => esc(l)); + } + const tokens = tokenize(src, LANGS[langKey]); + const lines: string[] = []; + let cur = ""; + for (const t of tokens) { + const parts = t.text.split("\n"); + for (let i = 0; i < parts.length; i++) { + if (i > 0) { + lines.push(cur); + cur = ""; + } + if (parts[i]) { + cur += t.cls ? `<span class='${t.cls}'>${esc(parts[i])}</span>` : esc(parts[i]); + } + } + } + lines.push(cur); + if (lines[lines.length - 1] === "" && lines.length > plainLines.length) lines.pop(); + while (lines.length < plainLines.length) lines.push(""); + return lines.slice(0, plainLines.length || 1); +} diff --git a/src/ui/html.ts b/src/ui/html.ts @@ -0,0 +1,138 @@ +export function esc(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** cgit-style relative age with its color class, e.g. "3 days" / age-days. */ +export function age(unixSecs: number): string { + if (!unixSecs) return ""; + const secs = Math.max(0, Math.floor(Date.now() / 1000) - unixSecs); + const fmt = (n: number, unit: string, cls: string) => + `<span class='age-${cls}'>${n} ${unit}${n === 1 ? "" : unit.endsWith(".") ? "" : "s"}</span>`; + if (secs < 3600) return fmt(Math.max(1, Math.floor(secs / 60)), "min.", "mins"); + if (secs < 24 * 3600) return fmt(Math.floor(secs / 3600), "hour", "hours"); + if (secs < 7 * 24 * 3600) return fmt(Math.floor(secs / (24 * 3600)), "day", "days"); + if (secs < 31 * 24 * 3600) return fmt(Math.floor(secs / (7 * 24 * 3600)), "week", "weeks"); + if (secs < 365 * 24 * 3600) return fmt(Math.floor(secs / (30 * 24 * 3600)), "month", "months"); + return fmt(Math.floor(secs / (365 * 24 * 3600)), "year", "years"); +} + +export function fmtDate(unixSecs: number, tz: string): string { + const d = new Date(unixSecs * 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` + + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} ${tz}` + ); +} + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** RFC 2822 date in the person's own timezone (for patch/atom output). */ +export function fmtDate2822(unixSecs: number, tz: string): string { + const offMin = (parseInt(tz.slice(0, 3), 10) || 0) * 60 + (parseInt(tz.slice(0, 1) + tz.slice(3), 10) || 0); + const d = new Date((unixSecs + offMin * 60) * 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${DAYS[d.getUTCDay()]}, ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` + + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} ${tz}` + ); +} + +export interface LayoutOpts { + site: string; + siteDesc: string; + title: string; + /** repo name when rendering a repo page */ + repo?: string; + /** description line under the title */ + sub: string; + /** active tab id */ + tab?: string; + /** current ref for tab links */ + ref?: string; + /** breadcrumb path html (tree pages) */ + pathBar?: string; + /** show the about tab (repo has a README) */ + hasAbout?: boolean; + /** branch names for the switcher dropdown */ + branches?: string[]; + /** form action for the branch switcher (current page path) */ + formAction?: string; + body: string; +} + +export function layout(o: LayoutOpts): string { + const r = o.repo ? `/${encodeURIComponent(o.repo)}` : ""; + const q = o.ref ? `?h=${encodeURIComponent(o.ref)}` : ""; + const tabs: [string, string][] = o.repo + ? [ + ...(o.hasAbout ? [["about", `${r}/about/${q}`] as [string, string]] : []), + ["summary", `${r}/${q}`], + ["refs", `${r}/refs/${q}`], + ["log", `${r}/log/${q}`], + ["tree", `${r}/tree/${q}`], + ["commit", `${r}/commit/${q}`], + ["diff", `${r}/diff/${q}`], + ["stats", `${r}/stats/${q}`], + ] + : [["index", "/"]]; + const tabHtml = tabs + .map(([id, href]) => `<a${id === o.tab ? " class='active'" : ""} href='${href}'>${id}</a>`) + .join(""); + const crumb = o.repo ? ` : <a href='${r}/'>${esc(o.repo)}</a>` : ""; + const switcher = + o.repo && o.branches && o.branches.length + ? `<td class='form'><form method='get' action='${esc(o.formAction ?? `${r}/`)}'>` + + `<select name='h' onchange='this.form.submit();'>` + + o.branches + .map((b) => `<option value='${esc(b)}'${b === o.ref ? " selected='selected'" : ""}>${esc(b)}</option>`) + .join("") + + `</select> <input type='submit' value='switch'/></form></td>` + : ""; + const atom = o.repo + ? `<link rel='alternate' title='Atom feed' href='${r}/atom/${q}' type='application/atom+xml'/>` + : ""; + return `<!DOCTYPE html> +<html lang='en'> +<head> +<title>${esc(o.title)}</title> +<meta name='generator' content='dgit'/> +<meta name='viewport' content='width=device-width, initial-scale=1'/> +<link rel='stylesheet' type='text/css' href='/cgit.css'/> +${atom} +</head> +<body> +<div id='cgit'> +<table id='header'> +<tr> +<td class='main'><a href='/'>${esc(o.site)}</a>${crumb}</td> +</tr> +<tr><td class='sub'>${esc(o.sub)}</td></tr> +</table> +<table class='tabs'><tr><td>${tabHtml}</td>${switcher}</tr></table> +${o.pathBar ? `<div class='path'>${o.pathBar}</div>` : ""} +<div class='content'> +${o.body} +</div> +<div class='footer'>generated by dgit 0.2 (cgit on Cloudflare Workers / celld)</div> +</div> +</body> +</html> +`; +} + +export function htmlResponse(html: string, status = 200): Response { + return new Response(html, { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} + +export function errorPage(o: Omit<LayoutOpts, "body">, msg: string, status = 404): Response { + return htmlResponse(layout({ ...o, body: `<div class='error'>${esc(msg)}</div>` }), status); +} diff --git a/src/ui/markdown.ts b/src/ui/markdown.ts @@ -0,0 +1,143 @@ +import { esc } from "./html"; + +/** + * Compact markdown renderer for README/about pages. Supported: headings, + * fenced code, lists, blockquotes, hr, links, images, emphasis, inline + * code, autolinks. Not supported: tables, footnotes, raw HTML (everything + * is escaped first; only http(s)/relative/# link targets are allowed). + */ + +function safeUrl(url: string): string | null { + const u = url.trim(); + if (/^(https?:)?\/\//i.test(u) || u.startsWith("#") || /^[\w./-]/.test(u)) { + if (/^javascript:/i.test(u) || /^data:/i.test(u) || /^vbscript:/i.test(u)) return null; + return u; + } + return null; +} + +function inline(text: string): string { + let s = esc(text); + // inline code first (protects its content from other rules) + s = s.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`); + // images + s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_m, alt, url) => { + const u = safeUrl(url); + return u ? `<img src='${u}' alt='${alt}' style='max-width:100%'/>` : alt; + }); + // links + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_m, txt, url) => { + const u = safeUrl(url); + return u ? `<a href='${u}'>${txt}</a>` : txt; + }); + // bold, italics + s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); + s = s.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>"); + s = s.replace(/(^|[\s(])_([^_\s][^_]*)_/g, "$1<em>$2</em>"); + // autolink bare urls (not already inside an attribute) + s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, "$1<a href='$2'>$2</a>"); + return s; +} + +export function renderMarkdown(src: string): string { + const lines = src.replaceAll("\r\n", "\n").split("\n"); + const out: string[] = []; + let para: string[] = []; + let inCode = false; + let codeLines: string[] = []; + let listStack: ("ul" | "ol")[] = []; + let quote = false; + + const flushPara = () => { + if (para.length) { + out.push(`<p>${inline(para.join("\n"))}</p>`); + para = []; + } + }; + const closeLists = (depth: number) => { + while (listStack.length > depth) out.push(`</${listStack.pop()}>`); + }; + const closeQuote = () => { + if (quote) { + out.push("</blockquote>"); + quote = false; + } + }; + + for (const raw of lines) { + if (inCode) { + if (/^\s*(```|~~~)\s*$/.test(raw)) { + out.push(`<pre class='md-code'><code>${esc(codeLines.join("\n"))}</code></pre>`); + codeLines = []; + inCode = false; + } else { + codeLines.push(raw); + } + continue; + } + const fence = raw.match(/^\s*(```|~~~)\s*(\S*)\s*$/); + if (fence) { + flushPara(); + closeLists(0); + closeQuote(); + inCode = true; + continue; + } + const heading = raw.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + flushPara(); + closeLists(0); + closeQuote(); + const level = heading[1].length; + out.push(`<h${level}>${inline(heading[2].replace(/\s+#+\s*$/, ""))}</h${level}>`); + continue; + } + if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(raw)) { + flushPara(); + closeLists(0); + closeQuote(); + out.push("<hr/>"); + continue; + } + const bq = raw.match(/^\s*>\s?(.*)$/); + if (bq) { + flushPara(); + closeLists(0); + if (!quote) { + out.push("<blockquote>"); + quote = true; + } + out.push(inline(bq[1]) + "<br/>"); + continue; + } + const li = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); + if (li) { + flushPara(); + closeQuote(); + const depth = Math.min(Math.floor(li[1].length / 2), 3) + 1; + const kind: "ul" | "ol" = /^[-*+]$/.test(li[2]) ? "ul" : "ol"; + while (listStack.length < depth) { + out.push(`<${kind}>`); + listStack.push(kind); + } + closeLists(depth); + out.push(`<li>${inline(li[3])}</li>`); + continue; + } + if (/^\s*$/.test(raw)) { + flushPara(); + closeLists(0); + closeQuote(); + continue; + } + // setext-ish: plain text joins the current paragraph + closeLists(0); + closeQuote(); + para.push(raw); + } + if (inCode) out.push(`<pre class='md-code'><code>${esc(codeLines.join("\n"))}</code></pre>`); + flushPara(); + closeLists(0); + closeQuote(); + return out.join("\n"); +} diff --git a/src/ui/style.ts b/src/ui/style.ts @@ -0,0 +1,420 @@ +/** A faithful recreation of cgit's default stylesheet (cgit.css). */ +export const CSS = ` +div#cgit { + padding: 0em; + margin: 0em; + font-family: sans-serif; + font-size: 10pt; + color: #333; + background: white; + padding: 4px; +} +div#cgit a { + color: blue; + text-decoration: none; +} +div#cgit a:hover { + text-decoration: underline; +} +div#cgit table { + border-collapse: collapse; +} +div#cgit table#header { + width: 100%; + margin-bottom: 1em; +} +div#cgit table#header td.logo { + width: 96px; + vertical-align: top; +} +div#cgit table#header td.main { + font-size: 250%; + padding-left: 10px; + white-space: nowrap; +} +div#cgit table#header td.main a { + color: #000; +} +div#cgit table#header td.form { + text-align: right; + vertical-align: bottom; + padding-right: 1em; + padding-bottom: 2px; + white-space: nowrap; +} +div#cgit table#header td.sub { + color: #777; + border-top: solid 1px #ccc; + padding-left: 10px; +} +div#cgit table.tabs { + border-bottom: solid 3px #ccc; + border-collapse: collapse; + margin-top: 2em; + margin-bottom: 0px; + width: 100%; +} +div#cgit table.tabs td { + padding: 0px 1em; + vertical-align: bottom; +} +div#cgit table.tabs td a { + padding: 2px 0.75em; + color: #777; + font-size: 110%; +} +div#cgit table.tabs td a.active { + color: #000; + background-color: #ccc; +} +div#cgit table.tabs a[href]:hover { + text-decoration: none; +} +div#cgit table.tabs td.form { + text-align: right; +} +div#cgit table.tabs td.form form { + padding-bottom: 2px; + font-size: 90%; + white-space: nowrap; +} +div#cgit div.path { + margin: 0px; + padding: 5px 2em 2px 2em; + color: #000; + background-color: #eee; +} +div#cgit div.content { + margin: 0px; + padding: 2em; + border-bottom: solid 3px #ccc; +} +div#cgit table.list { + width: 100%; + border: none; + border-collapse: collapse; +} +div#cgit table.list tr { + background: white; +} +div#cgit table.list tr.logheader { + background: #eee; +} +div#cgit table.list tr:hover { + background: #eee; +} +div#cgit table.list tr.nohover, div#cgit table.list tr.nohover:hover { + background: white; +} +div#cgit table.list th { + font-weight: bold; + border-top: dashed 1px #888; + border-bottom: dashed 1px #888; + padding: 0.1em 0.5em 0.05em 0.5em; + vertical-align: baseline; + text-align: left; +} +div#cgit table.list td { + border: none; + padding: 0.1em 0.5em 0.1em 0.5em; +} +div#cgit table.list td.commitgraph { + font-family: monospace; + white-space: pre; +} +div#cgit table.list td.logsubject { + font-family: monospace; + font-weight: bold; +} +div#cgit table.list td.logmsg { + font-family: monospace; + white-space: pre; + padding: 0 0.5em; +} +div#cgit table.list td a { + color: black; +} +div#cgit table.list td a.ls-dir { + font-weight: bold; + color: #00f; +} +div#cgit table.list td a:hover { + color: #00f; +} +div#cgit td.ls-size { + text-align: right; + font-family: monospace; + width: 10em; +} +div#cgit td.ls-mode { + font-family: monospace; + width: 10em; +} +div#cgit table.blob { + margin-top: 0.5em; + border-top: solid 1px black; +} +div#cgit table.blob td.linenumbers { + margin: 0; + padding: 0 0 0 0.5em; + vertical-align: top; + text-align: right; + border-right: 1px solid gray; +} +div#cgit table.blob pre { + padding: 0; + margin: 0; +} +div#cgit table.blob td.linenumbers a { + color: gray; + text-align: right; + font-family: monospace; +} +div#cgit table.blob td.lines { + margin: 0; + padding: 0 0 0 0.5em; + vertical-align: top; +} +div#cgit table.blob td.lines pre, div#cgit td.lines code { + font-family: monospace; +} +div#cgit div.footer { + margin-top: 0.5em; + text-align: center; + font-size: 80%; + color: #ccc; +} +div#cgit div.footer a { + color: #ccc; + text-decoration: none; +} +div#cgit div.footer a:hover { + text-decoration: underline; +} +div#cgit table.commit-info { + border-collapse: collapse; + margin-top: 1.5em; +} +div#cgit table.commit-info th { + text-align: left; + font-weight: normal; + padding: 0.1em 1em 0.1em 0.1em; + vertical-align: top; +} +div#cgit table.commit-info td { + font-weight: normal; + padding: 0.1em 1em 0.1em 0.1em; +} +div#cgit div.commit-subject { + font-weight: bold; + font-size: 125%; + margin: 1.5em 0em 0.5em 0em; + padding: 0em; +} +div#cgit div.commit-msg { + white-space: pre; + font-family: monospace; +} +div#cgit div.diffstat-header { + font-weight: bold; + padding-top: 1.5em; +} +div#cgit table.diffstat { + border-collapse: collapse; + border: solid 1px #aaa; + background-color: #eee; +} +div#cgit table.diffstat th { + font-weight: normal; + text-align: left; + text-decoration: underline; + padding: 0.1em 1em 0.1em 0.1em; + font-size: 100%; +} +div#cgit table.diffstat td { + padding: 0.2em 0.2em 0.1em 0.1em; + font-size: 100%; + border: none; +} +div#cgit table.diffstat td span.modechange { + padding-left: 1em; + color: red; +} +div#cgit table.diffstat td.add a { + color: green; +} +div#cgit table.diffstat td.del a { + color: red; +} +div#cgit table.diffstat td.graph { + width: 500px; + vertical-align: middle; +} +div#cgit table.diffstat td.graph table { + border: none; +} +div#cgit table.diffstat td.graph td { + padding: 0px; + border: 0px; + height: 7pt; +} +div#cgit table.diffstat td.graph td.add { + background-color: #5c5; +} +div#cgit table.diffstat td.graph td.rem { + background-color: #c55; +} +div#cgit div.diffstat-summary { + color: #888; + padding-top: 0.5em; +} +div#cgit table.diff { + width: 100%; +} +div#cgit table.diff td { + font-family: monospace; + white-space: pre-wrap; +} +div#cgit table.diff td div.head { + font-weight: bold; + margin-top: 1em; + color: black; +} +div#cgit table.diff td div.hunk { + color: #009; +} +div#cgit table.diff td div.add { + color: green; +} +div#cgit table.diff td div.del { + color: red; +} +div#cgit .sha1 { + font-family: monospace; + font-size: 90%; +} +div#cgit .left { + text-align: left; +} +div#cgit .right { + text-align: right; +} +div#cgit table.list td.reposection { + font-style: italic; + color: #888; +} +div#cgit a.branch-deco { + color: #000; + margin: 0px 0.5em; + padding: 0px 0.25em; + background-color: #88ff88; + border: solid 1px #007700; +} +div#cgit a.tag-deco { + color: #000; + margin: 0px 0.5em; + padding: 0px 0.25em; + background-color: #ffff88; + border: solid 1px #777700; +} +div#cgit a.deco { + color: #000; + margin: 0px 0.5em; + padding: 0px 0.25em; + background-color: #ff8888; + border: solid 1px #770000; +} +div#cgit span.age-mins { + font-size: 90%; + color: #080; +} +div#cgit span.age-hours { + font-size: 90%; + color: #080; +} +div#cgit span.age-days { + font-size: 90%; + color: #040; +} +div#cgit span.age-weeks { + font-size: 90%; + color: #444; +} +div#cgit span.age-months { + font-size: 90%; + color: #888; +} +div#cgit span.age-years { + font-size: 90%; + color: #bbb; +} +div#cgit div.error { + color: red; + font-weight: bold; + margin: 1em 2em; +} +div#cgit table.repolist-clone td { + padding-right: 1em; +} +div#cgit .clone-url { + font-family: monospace; +} +div#cgit span.hl-c { color: #888; font-style: italic; } +div#cgit span.hl-s { color: #a11; } +div#cgit span.hl-k { color: #00d; } +div#cgit span.hl-n { color: #099; } +div#cgit table.blame td.sha1 { + white-space: nowrap; + vertical-align: top; + padding-right: 1em; +} +div#cgit table.blame td.lines { + vertical-align: top; +} +div#cgit table.blame tr:hover { + background: #eee; +} +div#cgit table.blame pre { + margin: 0; + padding: 0; +} +div#cgit div.md h1, div#cgit div.md h2 { + border-bottom: solid 1px #ccc; + padding-bottom: 0.2em; +} +div#cgit div.md pre.md-code { + background: #f4f4f4; + border: solid 1px #ddd; + padding: 0.5em; + overflow-x: auto; +} +div#cgit div.md code { + background: #f4f4f4; + padding: 0 0.2em; +} +div#cgit div.md blockquote { + border-left: solid 3px #ccc; + margin-left: 0; + padding-left: 1em; + color: #666; +} +div#cgit table.stats th { + text-align: left; + padding: 0.1em 0.5em; + border-top: dashed 1px #888; + border-bottom: dashed 1px #888; +} +div#cgit table.stats td { + text-align: right; + padding: 0.1em 0.5em; +} +div#cgit table.stats td.name { + text-align: left; +} +div#cgit table.list td.snapshots a { + margin-right: 0.5em; +} +div#cgit form select, div#cgit form input { + font-size: 90%; +} +`; diff --git a/tsconfig.json b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/wrangler.celld.jsonc b/wrangler.celld.jsonc @@ -0,0 +1,25 @@ +{ + // celld deployment config (local/self-hosted). Identical to wrangler.jsonc + // except GIT_TOKEN rides as a var: celld has string vars, not secrets. + // For a real fleet, change the token before deploying. + // NOTE: the script name stays "git-cells" — Durable Object data is bound + // to it, and renaming would orphan every hosted repository. + "name": "git-cells", + "main": "src/index.ts", + "compatibility_date": "2026-08-01", + "durable_objects": { + "bindings": [ + { "name": "REPO", "class_name": "RepoCell" }, + { "name": "REGISTRY", "class_name": "Registry" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["RepoCell", "Registry"] } + ], + "vars": { + "SITE_NAME": "dgit", + "SITE_DESC": "a fast webinterface for the git dscm, on celld", + "SITE_OWNER": "", + "GIT_TOKEN": "devtoken" + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc @@ -0,0 +1,31 @@ +{ + // dgit: a cgit-style git host that runs entirely on Cloudflare. + // Each repository lives in its own SQLite-backed Durable Object ("cell"). + // NOTE: the script name stays "git-cells" — Durable Object data is bound + // to it, and renaming would orphan every hosted repository. + "name": "git-cells", + "main": "src/index.ts", + "compatibility_date": "2026-08-01", + "routes": [ + { "pattern": "git.littledivy.com", "custom_domain": true } + ], + "limits": { + // pack indexing of a large push is CPU-bound; 5 minutes is the paid-plan max + "cpu_ms": 300000 + }, + "durable_objects": { + "bindings": [ + { "name": "REPO", "class_name": "RepoCell" }, + { "name": "REGISTRY", "class_name": "Registry" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["RepoCell", "Registry"] } + ], + "vars": { + "SITE_NAME": "git.littledivy.com", + "SITE_DESC": "a fast webinterface for the git dscm, on Cloudflare", + "SITE_OWNER": "divy" + } + // Push auth: run `wrangler secret put GIT_TOKEN` (use .dev.vars locally). +} |