Commitfc1817aunknown_key
feat: gluecron Week 1 — git hosting + web code browser
feat: gluecron Week 1 — git hosting + web code browser Full-stack git hosting platform with: - Git Smart HTTP protocol (clone, fetch, push via stateless RPC) - Web UI: file tree browser, blob viewer, commit log, unified diff viewer - Drizzle + Neon schema (users, repositories, ssh_keys) - REST API for repo CRUD and quick-setup endpoint - GateTest integration (POST scan on every push) - Crontech deploy trigger on push to main - 19 passing tests (git operations + API routes) Stack: Bun + Hono + Drizzle + Neon https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
23 files changed+2285−0fc1817ac248a2b4ed30a41ac6e11ba6fe13e3b65
23 changed files+2285−0
Added.env.example+5−0View fileUnifiedSplit
@@ -0,0 +1,5 @@
1DATABASE_URL=postgresql://user:password@host/gluecron
2GIT_REPOS_PATH=./repos
3PORT=3000
4GATETEST_URL=https://gatetest.ai/api/scan/run
5CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
Added.gitignore+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1node_modules/
2dist/
3.env
4.env.local
5*.log
6repos/
7drizzle/meta/
8.DS_Store
AddedCLAUDE.md+56−0View fileUnifiedSplit
@@ -0,0 +1,56 @@
1# gluecron
2
3AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement.
4
5## Stack
6
7- **Runtime:** Bun
8- **Framework:** Hono (with JSX for server-rendered views)
9- **Database:** Drizzle ORM + Neon (PostgreSQL)
10- **Git:** Smart HTTP protocol via git CLI subprocesses
11
12## Development
13
14```bash
15bun install # install dependencies
16bun dev # start dev server (hot reload)
17bun test # run tests
18bun run db:migrate # run database migrations
19```
20
21## Architecture
22
23```
24src/
25 index.ts Entry point (Bun server)
26 app.ts Hono app composition
27 lib/config.ts Environment config (getters, reads env at access time)
28 db/
29 schema.ts Drizzle schema (users, repositories, ssh_keys)
30 index.ts Lazy DB connection (proxy pattern)
31 migrate.ts Migration runner
32 git/
33 repository.ts Git operations (tree, blob, commits, diff, branches)
34 protocol.ts Smart HTTP protocol (pkt-line, service RPC)
35 hooks/
36 post-receive.ts GateTest + Crontech webhooks on push
37 routes/
38 git.ts Git HTTP endpoints (clone/push)
39 api.ts REST API (repo CRUD, setup)
40 web.tsx Web UI (file browser, commits, diffs)
41 views/
42 layout.tsx HTML shell + CSS
43 components.tsx UI components (file table, commit list, diff viewer)
44```
45
46## Integrations
47
48- **GateTest:** POST `https://gatetest.ai/api/scan/run` on every `git push`
49- **Crontech:** POST `https://crontech.ai/api/trpc/tenant.deploy` on push to main
50
51## Environment Variables
52
53See `.env.example` for required variables. Key ones:
54- `DATABASE_URL` — Neon PostgreSQL connection string
55- `GIT_REPOS_PATH` — directory for bare git repos (default: `./repos`)
56- `PORT` — HTTP port (default: 3000)
Addedbun.lock+196−0View fileUnifiedSplit
@@ -0,0 +1,196 @@
1{
2 "lockfileVersion": 1,
3 "configVersion": 1,
4 "workspaces": {
5 "": {
6 "name": "gluecron",
7 "dependencies": {
8 "@hono/node-server": "^1.13.0",
9 "@neondatabase/serverless": "^0.10.0",
10 "drizzle-orm": "^0.39.0",
11 "highlight.js": "^11.11.0",
12 "hono": "^4.7.0",
13 },
14 "devDependencies": {
15 "@types/bun": "^1.2.0",
16 "drizzle-kit": "^0.30.0",
17 "typescript": "^5.7.0",
18 },
19 },
20 },
21 "packages": {
22 "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
23
24 "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
25
26 "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
27
28 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
29
30 "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
31
32 "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
33
34 "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
35
36 "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
37
38 "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
39
40 "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
41
42 "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
43
44 "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
45
46 "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
47
48 "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
49
50 "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
51
52 "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
53
54 "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
55
56 "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
57
58 "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
59
60 "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
61
62 "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
63
64 "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
65
66 "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
67
68 "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
69
70 "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
71
72 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
73
74 "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
75
76 "@neondatabase/serverless": ["@neondatabase/serverless@0.10.4", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA=="],
77
78 "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
79
80 "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
81
82 "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
83
84 "@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="],
85
86 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
87
88 "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
89
90 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
91
92 "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
93
94 "drizzle-orm": ["drizzle-orm@0.39.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw=="],
95
96 "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
97
98 "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
99
100 "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
101
102 "gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
103
104 "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
105
106 "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
107
108 "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
109
110 "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
111
112 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
113
114 "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
115
116 "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
117
118 "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="],
119
120 "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
121
122 "pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="],
123
124 "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
125
126 "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
127
128 "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="],
129
130 "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="],
131
132 "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
133
134 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
135
136 "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
137
138 "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
139
140 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
141
142 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
143
144 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
145
146 "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
147
148 "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
149
150 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
151
152 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
153
154 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
155
156 "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
157
158 "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
159
160 "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
161
162 "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
163
164 "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
165
166 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
167
168 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
169
170 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
171
172 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
173
174 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
175
176 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
177
178 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
179
180 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
181
182 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
183
184 "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
185
186 "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
187
188 "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
189
190 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
191
192 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
193
194 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
195 }
196}
Addeddrizzle.config.ts+10−0View fileUnifiedSplit
@@ -0,0 +1,10 @@
1import { defineConfig } from "drizzle-kit";
2
3export default defineConfig({
4 schema: "./src/db/schema.ts",
5 out: "./drizzle",
6 dialect: "postgresql",
7 dbCredentials: {
8 url: process.env.DATABASE_URL!,
9 },
10});
Addedpackage.json+26−0View fileUnifiedSplit
@@ -0,0 +1,26 @@
1{
2 "name": "gluecron",
3 "version": "0.1.0",
4 "description": "AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement",
5 "type": "module",
6 "scripts": {
7 "dev": "bun run --hot src/index.ts",
8 "start": "bun run src/index.ts",
9 "db:generate": "drizzle-kit generate",
10 "db:migrate": "bun run src/db/migrate.ts",
11 "db:studio": "drizzle-kit studio",
12 "test": "bun test"
13 },
14 "dependencies": {
15 "hono": "^4.7.0",
16 "@hono/node-server": "^1.13.0",
17 "drizzle-orm": "^0.39.0",
18 "@neondatabase/serverless": "^0.10.0",
19 "highlight.js": "^11.11.0"
20 },
21 "devDependencies": {
22 "@types/bun": "^1.2.0",
23 "drizzle-kit": "^0.30.0",
24 "typescript": "^5.7.0"
25 }
26}
Addedsrc/__tests__/api.test.ts+56−0View fileUnifiedSplit
@@ -0,0 +1,56 @@
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import app from "../app";
5
6const TEST_REPOS = join(import.meta.dir, "../../.test-repos-api");
7
8beforeAll(async () => {
9 process.env.GIT_REPOS_PATH = TEST_REPOS;
10 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
11 await mkdir(TEST_REPOS, { recursive: true });
12});
13
14afterAll(async () => {
15 await rm(TEST_REPOS, { recursive: true, force: true });
16});
17
18describe("API routes", () => {
19 it("GET / returns home page", async () => {
20 const res = await app.request("/");
21 expect(res.status).toBe(200);
22 const text = await res.text();
23 expect(text).toContain("gluecron");
24 });
25
26 it("GET /api/repos/:owner/:name returns 404 for missing repo", async () => {
27 // This will fail without DB, but verifies route exists
28 const res = await app.request("/api/repos/nobody/nothing");
29 // Without DB connection, this returns 500 or 404
30 expect([404, 500]).toContain(res.status);
31 });
32
33 it("POST /api/repos returns 400 without required fields", async () => {
34 const res = await app.request("/api/repos", {
35 method: "POST",
36 headers: { "Content-Type": "application/json" },
37 body: JSON.stringify({}),
38 });
39 // Without DB, might be 400 or 500
40 expect([400, 500]).toContain(res.status);
41 });
42});
43
44describe("Git HTTP routes", () => {
45 it("returns 400 for invalid service", async () => {
46 const res = await app.request("/test/repo.git/info/refs?service=invalid");
47 expect(res.status).toBe(400);
48 });
49
50 it("returns 404 for non-existent repo", async () => {
51 const res = await app.request(
52 "/nobody/nothing.git/info/refs?service=git-upload-pack"
53 );
54 expect(res.status).toBe(404);
55 });
56});
Addedsrc/__tests__/git-repository.test.ts+159−0View fileUnifiedSplit
@@ -0,0 +1,159 @@
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import {
5 initBareRepo,
6 repoExists,
7 getRepoPath,
8 listBranches,
9 getDefaultBranch,
10 resolveRef,
11 getTree,
12 getBlob,
13 listCommits,
14 getCommit,
15 getDiff,
16 getReadme,
17} from "../git/repository";
18
19const TEST_REPOS = join(import.meta.dir, "../../.test-repos-" + Date.now());
20
21beforeAll(async () => {
22 // Clean slate
23 await rm(TEST_REPOS, { recursive: true, force: true });
24 await mkdir(TEST_REPOS, { recursive: true });
25 process.env.GIT_REPOS_PATH = TEST_REPOS;
26});
27
28afterAll(async () => {
29 await rm(TEST_REPOS, { recursive: true, force: true });
30});
31
32describe("git repository management", () => {
33 const owner = "testuser";
34 const repo = "testrepo";
35
36 it("should initialize a bare repository", async () => {
37 const path = await initBareRepo(owner, repo);
38 expect(path).toContain(`${owner}/${repo}.git`);
39 expect(await repoExists(owner, repo)).toBe(true);
40 });
41
42 it("should report non-existent repos", async () => {
43 expect(await repoExists("nobody", "nothing")).toBe(false);
44 });
45
46 it("should have main as default branch", async () => {
47 const branch = await getDefaultBranch(owner, repo);
48 expect(branch).toBe("main");
49 });
50
51 it("should return empty tree for fresh bare repo", async () => {
52 // Fresh bare repo has no commits, so listing "main" returns nothing
53 const tree = await getTree(owner, repo, "main");
54 expect(tree).toEqual([]);
55 });
56
57 it("should return empty commits for fresh bare repo", async () => {
58 const commits = await listCommits(owner, repo, "main");
59 expect(commits).toEqual([]);
60 });
61
62 describe("with commits", () => {
63 beforeAll(async () => {
64 const cloneDir = join(TEST_REPOS, "_clone_tmp");
65 await mkdir(cloneDir, { recursive: true });
66 const repoPath = getRepoPath(owner, repo);
67
68 const run = async (cmd: string[], cwd: string) => {
69 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
70 await proc.exited;
71 };
72
73 const workDir = join(cloneDir, "work");
74 await run(["git", "clone", repoPath, workDir], TEST_REPOS);
75 await run(["git", "config", "user.email", "test@gluecron.com"], workDir);
76 await run(["git", "config", "user.name", "Test User"], workDir);
77
78 // Create files
79 await mkdir(join(workDir, "src"), { recursive: true });
80 await Bun.write(
81 join(workDir, "README.md"),
82 "# Test Repo\nHello gluecron"
83 );
84 await Bun.write(join(workDir, "src/index.ts"), "console.log('hello');");
85
86 await run(["git", "add", "-A"], workDir);
87 await run(["git", "commit", "-m", "Initial commit"], workDir);
88 await run(["git", "branch", "-M", "main"], workDir);
89 await run(["git", "push", "-u", "origin", "main"], workDir);
90
91 // Second commit
92 await Bun.write(join(workDir, "src/util.ts"), "export const x = 1;");
93 await run(["git", "add", "-A"], workDir);
94 await run(["git", "commit", "-m", "Add util module"], workDir);
95 await run(["git", "push", "origin", "main"], workDir);
96
97 await rm(cloneDir, { recursive: true, force: true });
98 });
99
100 it("should list branches", async () => {
101 const branches = await listBranches(owner, repo);
102 expect(branches).toContain("main");
103 });
104
105 it("should resolve HEAD ref", async () => {
106 const sha = await resolveRef(owner, repo, "HEAD");
107 expect(sha).toBeTruthy();
108 expect(sha!.length).toBe(40);
109 });
110
111 it("should list root tree", async () => {
112 const tree = await getTree(owner, repo, "main");
113 expect(tree.length).toBeGreaterThan(0);
114 const names = tree.map((e) => e.name);
115 expect(names).toContain("README.md");
116 expect(names).toContain("src");
117 });
118
119 it("should list subtree", async () => {
120 const tree = await getTree(owner, repo, "main", "src");
121 const names = tree.map((e) => e.name);
122 expect(names).toContain("index.ts");
123 expect(names).toContain("util.ts");
124 });
125
126 it("should read blob content", async () => {
127 const blob = await getBlob(owner, repo, "main", "README.md");
128 expect(blob).not.toBeNull();
129 expect(blob!.content).toContain("Hello gluecron");
130 expect(blob!.isBinary).toBe(false);
131 });
132
133 it("should list commits", async () => {
134 const commits = await listCommits(owner, repo, "main");
135 expect(commits.length).toBe(2);
136 expect(commits[0].message).toBe("Add util module");
137 expect(commits[1].message).toBe("Initial commit");
138 });
139
140 it("should get single commit", async () => {
141 const commits = await listCommits(owner, repo, "main", 1);
142 const commit = await getCommit(owner, repo, commits[0].sha);
143 expect(commit).not.toBeNull();
144 expect(commit!.author).toBe("Test User");
145 });
146
147 it("should get diff for commit", async () => {
148 const commits = await listCommits(owner, repo, "main", 1);
149 const { files, raw } = await getDiff(owner, repo, commits[0].sha);
150 expect(files.length).toBeGreaterThan(0);
151 expect(raw).toContain("util.ts");
152 });
153
154 it("should find README", async () => {
155 const readme = await getReadme(owner, repo, "main");
156 expect(readme).toContain("Hello gluecron");
157 });
158 });
159});
Addedsrc/app.ts+23−0View fileUnifiedSplit
@@ -0,0 +1,23 @@
1import { Hono } from "hono";
2import { logger } from "hono/logger";
3import { cors } from "hono/cors";
4import gitRoutes from "./routes/git";
5import apiRoutes from "./routes/api";
6import webRoutes from "./routes/web";
7
8const app = new Hono();
9
10// Middleware
11app.use("*", logger());
12app.use("/api/*", cors());
13
14// Git Smart HTTP protocol routes (must be before web routes)
15app.route("/", gitRoutes);
16
17// REST API
18app.route("/", apiRoutes);
19
20// Web UI (catch-all, must be last)
21app.route("/", webRoutes);
22
23export default app;
Addedsrc/db/index.ts+26−0View fileUnifiedSplit
@@ -0,0 +1,26 @@
1import { neon } from "@neondatabase/serverless";
2import { drizzle, type NeonHttpDatabase } from "drizzle-orm/neon-http";
3import * as schema from "./schema";
4import { config } from "../lib/config";
5
6let _db: NeonHttpDatabase<typeof schema> | null = null;
7
8export function getDb(): NeonHttpDatabase<typeof schema> {
9 if (!_db) {
10 if (!config.databaseUrl) {
11 throw new Error(
12 "DATABASE_URL is not set. Set it in your environment or .env file."
13 );
14 }
15 const sql = neon(config.databaseUrl);
16 _db = drizzle(sql, { schema });
17 }
18 return _db;
19}
20
21// Re-export as `db` for convenience — will throw on access without DATABASE_URL
22export const db = new Proxy({} as NeonHttpDatabase<typeof schema>, {
23 get(_target, prop, receiver) {
24 return Reflect.get(getDb(), prop, receiver);
25 },
26});
Addedsrc/db/migrate.ts+17−0View fileUnifiedSplit
@@ -0,0 +1,17 @@
1import { neon } from "@neondatabase/serverless";
2import { drizzle } from "drizzle-orm/neon-http";
3import { migrate } from "drizzle-orm/neon-http/migrator";
4import { config } from "../lib/config";
5
6async function runMigrations() {
7 const sql = neon(config.databaseUrl);
8 const db = drizzle(sql);
9 console.log("Running migrations...");
10 await migrate(db, { migrationsFolder: "./drizzle" });
11 console.log("Migrations complete.");
12}
13
14runMigrations().catch((err) => {
15 console.error("Migration failed:", err);
16 process.exit(1);
17});
Addedsrc/db/schema.ts+59−0View fileUnifiedSplit
@@ -0,0 +1,59 @@
1import {
2 pgTable,
3 text,
4 timestamp,
5 uuid,
6 boolean,
7 integer,
8 uniqueIndex,
9} from "drizzle-orm/pg-core";
10
11export const users = pgTable("users", {
12 id: uuid("id").primaryKey().defaultRandom(),
13 username: text("username").notNull().unique(),
14 email: text("email").notNull().unique(),
15 displayName: text("display_name"),
16 passwordHash: text("password_hash").notNull(),
17 avatarUrl: text("avatar_url"),
18 bio: text("bio"),
19 createdAt: timestamp("created_at").defaultNow().notNull(),
20 updatedAt: timestamp("updated_at").defaultNow().notNull(),
21});
22
23export const repositories = pgTable(
24 "repositories",
25 {
26 id: uuid("id").primaryKey().defaultRandom(),
27 name: text("name").notNull(),
28 ownerId: uuid("owner_id")
29 .notNull()
30 .references(() => users.id),
31 description: text("description"),
32 isPrivate: boolean("is_private").default(false).notNull(),
33 defaultBranch: text("default_branch").default("main").notNull(),
34 diskPath: text("disk_path").notNull(),
35 createdAt: timestamp("created_at").defaultNow().notNull(),
36 updatedAt: timestamp("updated_at").defaultNow().notNull(),
37 pushedAt: timestamp("pushed_at"),
38 starCount: integer("star_count").default(0).notNull(),
39 forkCount: integer("fork_count").default(0).notNull(),
40 },
41 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
42);
43
44export const sshKeys = pgTable("ssh_keys", {
45 id: uuid("id").primaryKey().defaultRandom(),
46 userId: uuid("user_id")
47 .notNull()
48 .references(() => users.id),
49 title: text("title").notNull(),
50 fingerprint: text("fingerprint").notNull(),
51 publicKey: text("public_key").notNull(),
52 lastUsedAt: timestamp("last_used_at"),
53 createdAt: timestamp("created_at").defaultNow().notNull(),
54});
55
56export type User = typeof users.$inferSelect;
57export type NewUser = typeof users.$inferInsert;
58export type Repository = typeof repositories.$inferSelect;
59export type NewRepository = typeof repositories.$inferInsert;
Addedsrc/git/protocol.ts+84−0View fileUnifiedSplit
@@ -0,0 +1,84 @@
1/**
2 * Git Smart HTTP Protocol implementation.
3 *
4 * Handles the server side of:
5 * - GET /:owner/:repo.git/info/refs?service=git-upload-pack
6 * - GET /:owner/:repo.git/info/refs?service=git-receive-pack
7 * - POST /:owner/:repo.git/git-upload-pack
8 * - POST /:owner/:repo.git/git-receive-pack
9 *
10 * Reference: https://git-scm.com/docs/http-protocol
11 */
12
13import { getRepoPath } from "./repository";
14
15function pktLine(data: string): string {
16 const len = (data.length + 4).toString(16).padStart(4, "0");
17 return `${len}${data}`;
18}
19
20function pktFlush(): string {
21 return "0000";
22}
23
24export function infoRefsResponse(
25 service: string,
26 advertise: string
27): Response {
28 const body = pktLine(`# service=${service}\n`) + pktFlush() + advertise;
29
30 return new Response(body, {
31 headers: {
32 "Content-Type": `application/x-${service}-advertisement`,
33 "Cache-Control": "no-cache",
34 },
35 });
36}
37
38export async function getInfoRefs(
39 owner: string,
40 repo: string,
41 service: string
42): Promise<Response> {
43 const repoDir = getRepoPath(owner, repo);
44 const proc = Bun.spawn([service, "--stateless-rpc", "--advertise-refs", repoDir], {
45 stdout: "pipe",
46 stderr: "pipe",
47 });
48 const stdout = await new Response(proc.stdout).text();
49 await proc.exited;
50 return infoRefsResponse(service, stdout);
51}
52
53export async function serviceRpc(
54 owner: string,
55 repo: string,
56 service: string,
57 body: ReadableStream<Uint8Array> | ArrayBuffer | null
58): Promise<Response> {
59 const repoDir = getRepoPath(owner, repo);
60 const inputBytes = body
61 ? body instanceof ArrayBuffer
62 ? new Uint8Array(body)
63 : new Uint8Array(await new Response(body).arrayBuffer())
64 : new Uint8Array();
65
66 const proc = Bun.spawn([service, "--stateless-rpc", repoDir], {
67 stdin: "pipe",
68 stdout: "pipe",
69 stderr: "pipe",
70 });
71
72 proc.stdin.write(inputBytes);
73 proc.stdin.end();
74
75 const stdout = await new Response(proc.stdout).arrayBuffer();
76 await proc.exited;
77
78 return new Response(stdout, {
79 headers: {
80 "Content-Type": `application/x-${service}-result`,
81 "Cache-Control": "no-cache",
82 },
83 });
84}
Addedsrc/git/repository.ts+327−0View fileUnifiedSplit
@@ -0,0 +1,327 @@
1import { join } from "path";
2import { mkdir } from "fs/promises";
3import { config } from "../lib/config";
4
5export interface GitCommit {
6 sha: string;
7 message: string;
8 author: string;
9 authorEmail: string;
10 date: string;
11 parentShas: string[];
12}
13
14export interface GitTreeEntry {
15 mode: string;
16 type: "blob" | "tree" | "commit";
17 sha: string;
18 name: string;
19 size?: number;
20}
21
22export interface GitBlob {
23 content: string;
24 size: number;
25 isBinary: boolean;
26}
27
28export interface GitDiffFile {
29 path: string;
30 oldPath?: string;
31 status: string;
32 additions: number;
33 deletions: number;
34 patch: string;
35}
36
37function repoPath(owner: string, name: string): string {
38 return join(config.gitReposPath, owner, `${name}.git`);
39}
40
41async function exec(
42 cmd: string[],
43 opts?: { cwd?: string; env?: Record<string, string> }
44): Promise<{ stdout: string; stderr: string; exitCode: number }> {
45 const proc = Bun.spawn(cmd, {
46 cwd: opts?.cwd,
47 env: { ...process.env, ...opts?.env },
48 stdout: "pipe",
49 stderr: "pipe",
50 });
51 const [stdout, stderr] = await Promise.all([
52 new Response(proc.stdout).text(),
53 new Response(proc.stderr).text(),
54 ]);
55 const exitCode = await proc.exited;
56 return { stdout, stderr, exitCode };
57}
58
59export async function initBareRepo(
60 owner: string,
61 name: string
62): Promise<string> {
63 const path = repoPath(owner, name);
64 await mkdir(join(config.gitReposPath, owner), { recursive: true });
65 await exec(["git", "init", "--bare", path]);
66 // Set default branch to main
67 await exec(["git", "symbolic-ref", "HEAD", "refs/heads/main"], {
68 cwd: path,
69 });
70 return path;
71}
72
73export async function repoExists(
74 owner: string,
75 name: string
76): Promise<boolean> {
77 const path = repoPath(owner, name);
78 const file = Bun.file(join(path, "HEAD"));
79 return file.exists();
80}
81
82export function getRepoPath(owner: string, name: string): string {
83 return repoPath(owner, name);
84}
85
86export async function listBranches(
87 owner: string,
88 name: string
89): Promise<string[]> {
90 const path = repoPath(owner, name);
91 const { stdout, exitCode } = await exec(
92 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
93 { cwd: path }
94 );
95 if (exitCode !== 0) return [];
96 return stdout.trim().split("\n").filter(Boolean);
97}
98
99export async function getDefaultBranch(
100 owner: string,
101 name: string
102): Promise<string | null> {
103 const path = repoPath(owner, name);
104 const { stdout, exitCode } = await exec(
105 ["git", "symbolic-ref", "--short", "HEAD"],
106 { cwd: path }
107 );
108 if (exitCode !== 0) return null;
109 return stdout.trim() || null;
110}
111
112export async function resolveRef(
113 owner: string,
114 name: string,
115 ref: string
116): Promise<string | null> {
117 const path = repoPath(owner, name);
118 const { stdout, exitCode } = await exec(
119 ["git", "rev-parse", "--verify", ref],
120 { cwd: path }
121 );
122 if (exitCode !== 0) return null;
123 return stdout.trim();
124}
125
126export async function getCommit(
127 owner: string,
128 name: string,
129 sha: string
130): Promise<GitCommit | null> {
131 const path = repoPath(owner, name);
132 const format = "%H%n%s%n%an%n%ae%n%aI%n%P";
133 const { stdout, exitCode } = await exec(
134 ["git", "log", "-1", `--format=${format}`, sha],
135 { cwd: path }
136 );
137 if (exitCode !== 0) return null;
138 const lines = stdout.trim().split("\n");
139 if (lines.length < 5) return null;
140 return {
141 sha: lines[0],
142 message: lines[1],
143 author: lines[2],
144 authorEmail: lines[3],
145 date: lines[4],
146 parentShas: lines[5] ? lines[5].split(" ").filter(Boolean) : [],
147 };
148}
149
150export async function getCommitFullMessage(
151 owner: string,
152 name: string,
153 sha: string
154): Promise<string> {
155 const path = repoPath(owner, name);
156 const { stdout } = await exec(
157 ["git", "log", "-1", "--format=%B", sha],
158 { cwd: path }
159 );
160 return stdout.trim();
161}
162
163export async function listCommits(
164 owner: string,
165 name: string,
166 ref: string,
167 limit = 30,
168 offset = 0
169): Promise<GitCommit[]> {
170 const path = repoPath(owner, name);
171 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
172 const { stdout, exitCode } = await exec(
173 [
174 "git",
175 "log",
176 `--format=${format}`,
177 `--skip=${offset}`,
178 `-${limit}`,
179 ref,
180 ],
181 { cwd: path }
182 );
183 if (exitCode !== 0) return [];
184 return stdout
185 .trim()
186 .split("\n")
187 .filter(Boolean)
188 .map((line) => {
189 const [sha, message, author, authorEmail, date, parents] =
190 line.split("\0");
191 return {
192 sha,
193 message,
194 author,
195 authorEmail,
196 date,
197 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
198 };
199 });
200}
201
202export async function getTree(
203 owner: string,
204 name: string,
205 ref: string,
206 treePath = ""
207): Promise<GitTreeEntry[]> {
208 const path = repoPath(owner, name);
209 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
210 const { stdout, exitCode } = await exec(
211 ["git", "ls-tree", "-l", treeish],
212 { cwd: path }
213 );
214 if (exitCode !== 0) return [];
215 return stdout
216 .trim()
217 .split("\n")
218 .filter(Boolean)
219 .map((line) => {
220 // format: <mode> <type> <sha>\t<size>\t<name>
221 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
222 const match = line.match(
223 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
224 );
225 if (!match) return null;
226 return {
227 mode: match[1],
228 type: match[2] as "blob" | "tree" | "commit",
229 sha: match[3],
230 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
231 name: match[5],
232 };
233 })
234 .filter((e): e is GitTreeEntry => e !== null)
235 .sort((a, b) => {
236 // directories first, then files
237 if (a.type === "tree" && b.type !== "tree") return -1;
238 if (a.type !== "tree" && b.type === "tree") return 1;
239 return a.name.localeCompare(b.name);
240 });
241}
242
243export async function getBlob(
244 owner: string,
245 name: string,
246 ref: string,
247 filePath: string
248): Promise<GitBlob | null> {
249 const path = repoPath(owner, name);
250 const { stdout, exitCode } = await exec(
251 ["git", "cat-file", "-s", `${ref}:${filePath}`],
252 { cwd: path }
253 );
254 if (exitCode !== 0) return null;
255 const size = parseInt(stdout.trim(), 10);
256
257 // Check if binary
258 const { stdout: content, exitCode: catCode } = await exec(
259 ["git", "show", `${ref}:${filePath}`],
260 { cwd: path }
261 );
262 if (catCode !== 0) return null;
263
264 const isBinary = content.includes("\0");
265 return {
266 content: isBinary ? "" : content,
267 size,
268 isBinary,
269 };
270}
271
272export async function getDiff(
273 owner: string,
274 name: string,
275 sha: string
276): Promise<{ files: GitDiffFile[]; raw: string }> {
277 const path = repoPath(owner, name);
278 // For initial commits (no parent), diff against empty tree
279 const commit = await getCommit(owner, name, sha);
280 if (!commit) return { files: [], raw: "" };
281
282 let diffCmd: string[];
283 if (commit.parentShas.length === 0) {
284 const emptyTree = "4b825dc642cb6eb9a060e54bf899d15363da7b23";
285 diffCmd = ["git", "diff", emptyTree, sha];
286 } else {
287 diffCmd = ["git", "diff", `${commit.parentShas[0]}..${sha}`];
288 }
289
290 const { stdout: raw } = await exec(diffCmd, { cwd: path });
291
292 // Also get --stat for file-level summary
293 const { stdout: stat } = await exec([...diffCmd, "--numstat"], {
294 cwd: path,
295 });
296
297 const files = stat
298 .trim()
299 .split("\n")
300 .filter(Boolean)
301 .map((line) => {
302 const [add, del, filePath] = line.split("\t");
303 return {
304 path: filePath,
305 status: "modified",
306 additions: add === "-" ? 0 : parseInt(add, 10),
307 deletions: del === "-" ? 0 : parseInt(del, 10),
308 patch: "",
309 };
310 });
311
312 return { files, raw };
313}
314
315export async function getReadme(
316 owner: string,
317 name: string,
318 ref: string
319): Promise<string | null> {
320 const tree = await getTree(owner, name, ref);
321 const readme = tree.find((e) =>
322 /^readme(\.(md|txt|rst))?$/i.test(e.name)
323 );
324 if (!readme) return null;
325 const blob = await getBlob(owner, name, ref, readme.name);
326 return blob?.content || null;
327}
Addedsrc/hooks/post-receive.ts+85−0View fileUnifiedSplit
@@ -0,0 +1,85 @@
1/**
2 * Post-receive hook logic.
3 * Called after a successful git push to trigger GateTest scans
4 * and Crontech deploys.
5 */
6
7import { config } from "../lib/config";
8
9interface PushRef {
10 oldSha: string;
11 newSha: string;
12 refName: string;
13}
14
15export async function onPostReceive(
16 owner: string,
17 repo: string,
18 refs: PushRef[]
19): Promise<void> {
20 const promises: Promise<void>[] = [];
21
22 // GateTest scan on every push
23 promises.push(triggerGateTest(owner, repo, refs));
24
25 // Crontech deploy on push to main
26 const mainPush = refs.find(
27 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
28 );
29 if (mainPush) {
30 promises.push(triggerCrontechDeploy(owner, repo, mainPush.newSha));
31 }
32
33 await Promise.allSettled(promises);
34}
35
36async function triggerGateTest(
37 owner: string,
38 repo: string,
39 refs: PushRef[]
40): Promise<void> {
41 try {
42 const response = await fetch(config.gatetestUrl, {
43 method: "POST",
44 headers: { "Content-Type": "application/json" },
45 body: JSON.stringify({
46 repository: `${owner}/${repo}`,
47 refs: refs.map((r) => ({
48 ref: r.refName,
49 before: r.oldSha,
50 after: r.newSha,
51 })),
52 source: "gluecron",
53 }),
54 });
55 console.log(
56 `[gatetest] scan triggered for ${owner}/${repo}: ${response.status}`
57 );
58 } catch (err) {
59 console.error(`[gatetest] failed to trigger scan:`, err);
60 }
61}
62
63async function triggerCrontechDeploy(
64 owner: string,
65 repo: string,
66 sha: string
67): Promise<void> {
68 try {
69 const response = await fetch(config.crontechDeployUrl, {
70 method: "POST",
71 headers: { "Content-Type": "application/json" },
72 body: JSON.stringify({
73 repository: `${owner}/${repo}`,
74 sha,
75 branch: "main",
76 source: "gluecron",
77 }),
78 });
79 console.log(
80 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
81 );
82 } catch (err) {
83 console.error(`[crontech] failed to trigger deploy:`, err);
84 }
85}
Addedsrc/index.ts+18−0View fileUnifiedSplit
@@ -0,0 +1,18 @@
1import { mkdir } from "fs/promises";
2import app from "./app";
3import { config } from "./lib/config";
4
5// Ensure repos directory exists
6await mkdir(config.gitReposPath, { recursive: true });
7
8console.log(`
9 gluecron v0.1.0
10 ──────────────────────
11 http://localhost:${config.port}
12 repos: ${config.gitReposPath}
13`);
14
15export default {
16 port: config.port,
17 fetch: app.fetch,
18};
Addedsrc/lib/config.ts+22−0View fileUnifiedSplit
@@ -0,0 +1,22 @@
1import { join } from "path";
2
3export const config = {
4 get port() {
5 return Number(process.env.PORT || 3000);
6 },
7 get databaseUrl() {
8 return process.env.DATABASE_URL || "";
9 },
10 get gitReposPath() {
11 return process.env.GIT_REPOS_PATH || join(process.cwd(), "repos");
12 },
13 get gatetestUrl() {
14 return process.env.GATETEST_URL || "https://gatetest.ai/api/scan/run";
15 },
16 get crontechDeployUrl() {
17 return (
18 process.env.CRONTECH_DEPLOY_URL ||
19 "https://crontech.ai/api/trpc/tenant.deploy"
20 );
21 },
22};
Addedsrc/routes/api.ts+141−0View fileUnifiedSplit
@@ -0,0 +1,141 @@
1/**
2 * REST API routes for repository management.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { users, repositories } from "../db/schema";
9import { initBareRepo, repoExists } from "../git/repository";
10
11const api = new Hono().basePath("/api");
12
13// Create repository
14api.post("/repos", async (c) => {
15 const body = await c.req.json<{
16 name: string;
17 owner: string;
18 description?: string;
19 isPrivate?: boolean;
20 }>();
21
22 if (!body.name || !body.owner) {
23 return c.json({ error: "name and owner are required" }, 400);
24 }
25
26 // Validate repo name
27 if (!/^[a-zA-Z0-9._-]+$/.test(body.name)) {
28 return c.json({ error: "Invalid repository name" }, 400);
29 }
30
31 // Find owner
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, body.owner));
36
37 if (!owner) {
38 return c.json({ error: "Owner not found" }, 404);
39 }
40
41 // Check duplicate
42 if (await repoExists(body.owner, body.name)) {
43 return c.json({ error: "Repository already exists" }, 409);
44 }
45
46 // Init bare repo on disk
47 const diskPath = await initBareRepo(body.owner, body.name);
48
49 // Insert into DB
50 const [repo] = await db
51 .insert(repositories)
52 .values({
53 name: body.name,
54 ownerId: owner.id,
55 description: body.description || null,
56 isPrivate: body.isPrivate || false,
57 diskPath,
58 })
59 .returning();
60
61 return c.json(repo, 201);
62});
63
64// List user's repositories
65api.get("/users/:username/repos", async (c) => {
66 const { username } = c.req.param();
67 const [owner] = await db
68 .select()
69 .from(users)
70 .where(eq(users.username, username));
71 if (!owner) return c.json({ error: "User not found" }, 404);
72
73 const repos = await db
74 .select()
75 .from(repositories)
76 .where(eq(repositories.ownerId, owner.id));
77
78 return c.json(repos);
79});
80
81// Get single repository
82api.get("/repos/:owner/:name", async (c) => {
83 const { owner: ownerName, name } = c.req.param();
84 const [owner] = await db
85 .select()
86 .from(users)
87 .where(eq(users.username, ownerName));
88 if (!owner) return c.json({ error: "Not found" }, 404);
89
90 const [repo] = await db
91 .select()
92 .from(repositories)
93 .where(
94 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
95 );
96 if (!repo) return c.json({ error: "Not found" }, 404);
97
98 return c.json(repo);
99});
100
101// Quick-setup: create user + repo in one call (dev convenience)
102api.post("/setup", async (c) => {
103 const body = await c.req.json<{
104 username: string;
105 email: string;
106 repoName: string;
107 description?: string;
108 }>();
109
110 // Upsert user
111 let [user] = await db
112 .select()
113 .from(users)
114 .where(eq(users.username, body.username));
115
116 if (!user) {
117 [user] = await db
118 .insert(users)
119 .values({
120 username: body.username,
121 email: body.email,
122 passwordHash: "placeholder", // TODO: real auth
123 })
124 .returning();
125 }
126
127 // Create repo if not exists
128 if (!(await repoExists(body.username, body.repoName))) {
129 const diskPath = await initBareRepo(body.username, body.repoName);
130 await db.insert(repositories).values({
131 name: body.repoName,
132 ownerId: user.id,
133 description: body.description || null,
134 diskPath,
135 });
136 }
137
138 return c.json({ user: user.username, repo: body.repoName, status: "ready" });
139});
140
141export default api;
Addedsrc/routes/git.ts+117−0View fileUnifiedSplit
@@ -0,0 +1,117 @@
1/**
2 * Git Smart HTTP routes.
3 *
4 * Mounted at /:owner/:repo.git/ — handles clone, fetch, push.
5 */
6
7import { Hono } from "hono";
8import { getInfoRefs, serviceRpc } from "../git/protocol";
9import { repoExists } from "../git/repository";
10import { onPostReceive } from "../hooks/post-receive";
11
12const git = new Hono();
13
14// Discovery: GET /:owner/:repo.git/info/refs?service=...
15git.get("/:owner/:repo.git/info/refs", async (c) => {
16 const { owner, repo } = c.req.param();
17 const service = c.req.query("service");
18
19 if (!service || !["git-upload-pack", "git-receive-pack"].includes(service)) {
20 return c.text("Invalid service", 400);
21 }
22
23 if (!(await repoExists(owner, repo))) {
24 return c.text("Repository not found", 404);
25 }
26
27 return getInfoRefs(owner, repo, service);
28});
29
30// GET /:owner/:repo.git/HEAD
31git.get("/:owner/:repo.git/HEAD", async (c) => {
32 const { owner, repo } = c.req.param();
33 if (!(await repoExists(owner, repo))) {
34 return c.text("Repository not found", 404);
35 }
36 const path = `repos/${owner}/${repo}.git/HEAD`;
37 const file = Bun.file(path);
38 if (!(await file.exists())) return c.text("Not found", 404);
39 return c.text(await file.text());
40});
41
42// Upload pack (clone/fetch)
43git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
44 const { owner, repo } = c.req.param();
45 if (!(await repoExists(owner, repo))) {
46 return c.text("Repository not found", 404);
47 }
48 return serviceRpc(owner, repo, "git-upload-pack", c.req.raw.body);
49});
50
51// Receive pack (push)
52git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
53 const { owner, repo } = c.req.param();
54 if (!(await repoExists(owner, repo))) {
55 return c.text("Repository not found", 404);
56 }
57
58 // Parse the incoming refs from the request body before passing to git
59 const bodyBuffer = await c.req.arrayBuffer();
60 const response = await serviceRpc(
61 owner,
62 repo,
63 "git-receive-pack",
64 bodyBuffer
65 );
66
67 // Fire post-receive hooks asynchronously (don't block response)
68 // We parse updated refs from the pkt-line protocol in the request
69 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
70 if (refs.length > 0) {
71 onPostReceive(owner, repo, refs).catch((err) =>
72 console.error("[post-receive] hook error:", err)
73 );
74 }
75
76 return response;
77});
78
79/**
80 * Parse ref updates from git-receive-pack request body.
81 * Format: <old-sha> <new-sha> <ref-name>
82 */
83function parseReceivePackRefs(
84 data: Uint8Array
85): Array<{ oldSha: string; newSha: string; refName: string }> {
86 const text = new TextDecoder().decode(data);
87 const refs: Array<{ oldSha: string; newSha: string; refName: string }> = [];
88 // Pkt-line format: 4-hex-length followed by data
89 let offset = 0;
90 while (offset < text.length) {
91 const lenHex = text.slice(offset, offset + 4);
92 const len = parseInt(lenHex, 16);
93 if (len === 0) {
94 offset += 4;
95 break; // flush packet
96 }
97 if (len < 4) break;
98 const line = text.slice(offset + 4, offset + len);
99 offset += len;
100
101 // Match: <old-sha> <new-sha> <ref-name>\0<capabilities>
102 // or: <old-sha> <new-sha> <ref-name>
103 const match = line.match(
104 /^([0-9a-f]{40}) ([0-9a-f]{40}) ([^\0\n]+)/
105 );
106 if (match) {
107 refs.push({
108 oldSha: match[1],
109 newSha: match[2],
110 refName: match[3].trim(),
111 });
112 }
113 }
114 return refs;
115}
116
117export default git;
Addedsrc/routes/web.tsx+337−0View fileUnifiedSplit
@@ -0,0 +1,337 @@
1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import {
8 RepoHeader,
9 RepoNav,
10 Breadcrumb,
11 FileTable,
12 CommitList,
13 DiffView,
14} from "../views/components";
15import {
16 getTree,
17 getBlob,
18 listCommits,
19 getCommit,
20 getCommitFullMessage,
21 getDiff,
22 getReadme,
23 getDefaultBranch,
24 listBranches,
25 repoExists,
26} from "../git/repository";
27
28const web = new Hono();
29
30// Home page
31web.get("/", (c) => {
32 return c.html(
33 <Layout>
34 <div class="empty-state">
35 <h2>gluecron</h2>
36 <p>AI-native code intelligence platform</p>
37 <pre>{`# Quick start
38curl -X POST http://localhost:3000/api/setup \\
39 -H 'Content-Type: application/json' \\
40 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
41
42git remote add gluecron http://localhost:3000/you/hello.git
43git push gluecron main`}</pre>
44 </div>
45 </Layout>
46 );
47});
48
49// User profile (list repos) — placeholder
50web.get("/:owner", async (c) => {
51 const { owner } = c.req.param();
52 return c.html(
53 <Layout title={owner}>
54 <h2 style="margin-bottom: 16px">{owner}</h2>
55 <p style="color: var(--text-muted)">
56 Repository listing coming soon. Use the API to browse repos.
57 </p>
58 </Layout>
59 );
60});
61
62// Repository overview — file tree at HEAD
63web.get("/:owner/:repo", async (c) => {
64 const { owner, repo } = c.req.param();
65
66 if (!(await repoExists(owner, repo))) {
67 return c.html(
68 <Layout title="Not Found">
69 <div class="empty-state">
70 <h2>Repository not found</h2>
71 <p>
72 {owner}/{repo} does not exist.
73 </p>
74 </div>
75 </Layout>,
76 404
77 );
78 }
79
80 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
81 const tree = await getTree(owner, repo, defaultBranch);
82
83 if (tree.length === 0) {
84 return c.html(
85 <Layout title={`${owner}/${repo}`}>
86 <RepoHeader owner={owner} repo={repo} />
87 <RepoNav owner={owner} repo={repo} active="code" />
88 <div class="empty-state">
89 <h2>Empty repository</h2>
90 <p>Get started by pushing code:</p>
91 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
92git push -u gluecron main`}</pre>
93 </div>
94 </Layout>
95 );
96 }
97
98 const readme = await getReadme(owner, repo, defaultBranch);
99
100 return c.html(
101 <Layout title={`${owner}/${repo}`}>
102 <RepoHeader owner={owner} repo={repo} />
103 <RepoNav owner={owner} repo={repo} active="code" />
104 <div class="branch-selector">{defaultBranch}</div>
105 <FileTable
106 entries={tree}
107 owner={owner}
108 repo={repo}
109 ref={defaultBranch}
110 path=""
111 />
112 {readme && (
113 <div
114 class="blob-view"
115 style="margin-top: 20px"
116 >
117 <div class="blob-header">README.md</div>
118 <div style="padding: 16px; white-space: pre-wrap; font-size: 14px;">
119 {readme}
120 </div>
121 </div>
122 )}
123 </Layout>
124 );
125});
126
127// Browse tree at ref/path
128web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
129 const { owner, repo } = c.req.param();
130 const refAndPath = c.req.param("ref");
131
132 // Parse ref from path — try known branches first, fallback to first segment
133 const branches = await listBranches(owner, repo);
134 let ref = "";
135 let treePath = "";
136
137 for (const branch of branches) {
138 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
139 ref = branch;
140 treePath = refAndPath.slice(branch.length + 1);
141 break;
142 }
143 }
144
145 if (!ref) {
146 // Assume first path segment is the ref
147 const slashIdx = refAndPath.indexOf("/");
148 if (slashIdx === -1) {
149 ref = refAndPath;
150 } else {
151 ref = refAndPath.slice(0, slashIdx);
152 treePath = refAndPath.slice(slashIdx + 1);
153 }
154 }
155
156 const tree = await getTree(owner, repo, ref, treePath);
157
158 return c.html(
159 <Layout title={`${treePath || "/"} — ${owner}/${repo}`}>
160 <RepoHeader owner={owner} repo={repo} />
161 <RepoNav owner={owner} repo={repo} active="code" />
162 <div class="branch-selector">{ref}</div>
163 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
164 <FileTable
165 entries={tree}
166 owner={owner}
167 repo={repo}
168 ref={ref}
169 path={treePath}
170 />
171 </Layout>
172 );
173});
174
175// View file blob
176web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
177 const { owner, repo } = c.req.param();
178 const refAndPath = c.req.param("ref");
179
180 const branches = await listBranches(owner, repo);
181 let ref = "";
182 let filePath = "";
183
184 for (const branch of branches) {
185 if (refAndPath.startsWith(branch + "/")) {
186 ref = branch;
187 filePath = refAndPath.slice(branch.length + 1);
188 break;
189 }
190 }
191
192 if (!ref) {
193 const slashIdx = refAndPath.indexOf("/");
194 if (slashIdx === -1) return c.text("Not found", 404);
195 ref = refAndPath.slice(0, slashIdx);
196 filePath = refAndPath.slice(slashIdx + 1);
197 }
198
199 const blob = await getBlob(owner, repo, ref, filePath);
200 if (!blob) {
201 return c.html(
202 <Layout title="Not Found">
203 <div class="empty-state">
204 <h2>File not found</h2>
205 </div>
206 </Layout>,
207 404
208 );
209 }
210
211 const lines = blob.content.split("\n");
212 // Remove trailing empty line from split
213 if (lines[lines.length - 1] === "") lines.pop();
214
215 return c.html(
216 <Layout title={`${filePath} — ${owner}/${repo}`}>
217 <RepoHeader owner={owner} repo={repo} />
218 <RepoNav owner={owner} repo={repo} active="code" />
219 <div class="branch-selector">{ref}</div>
220 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
221 <div class="blob-view">
222 <div class="blob-header">
223 {filePath.split("/").pop()} — {blob.size} bytes
224 </div>
225 {blob.isBinary ? (
226 <div style="padding: 16px; color: var(--text-muted)">
227 Binary file not shown.
228 </div>
229 ) : (
230 <div class="blob-code">
231 <table>
232 <tbody>
233 {lines.map((line, i) => (
234 <tr>
235 <td class="line-num">{i + 1}</td>
236 <td class="line-content">{line}</td>
237 </tr>
238 ))}
239 </tbody>
240 </table>
241 </div>
242 )}
243 </div>
244 </Layout>
245 );
246});
247
248// Commit log
249web.get("/:owner/:repo/commits/:ref?", async (c) => {
250 const { owner, repo } = c.req.param();
251 const ref =
252 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
253
254 const commits = await listCommits(owner, repo, ref, 50);
255
256 return c.html(
257 <Layout title={`Commits — ${owner}/${repo}`}>
258 <RepoHeader owner={owner} repo={repo} />
259 <RepoNav owner={owner} repo={repo} active="commits" />
260 <div class="branch-selector">{ref}</div>
261 {commits.length === 0 ? (
262 <div class="empty-state">
263 <p>No commits yet.</p>
264 </div>
265 ) : (
266 <CommitList commits={commits} owner={owner} repo={repo} />
267 )}
268 </Layout>
269 );
270});
271
272// Single commit with diff
273web.get("/:owner/:repo/commit/:sha", async (c) => {
274 const { owner, repo, sha } = c.req.param();
275
276 const commit = await getCommit(owner, repo, sha);
277 if (!commit) {
278 return c.html(
279 <Layout title="Not Found">
280 <div class="empty-state">
281 <h2>Commit not found</h2>
282 </div>
283 </Layout>,
284 404
285 );
286 }
287
288 const fullMessage = await getCommitFullMessage(owner, repo, sha);
289 const { files, raw } = await getDiff(owner, repo, sha);
290
291 return c.html(
292 <Layout title={`${commit.message} — ${owner}/${repo}`}>
293 <RepoHeader owner={owner} repo={repo} />
294 <div
295 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
296 >
297 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
298 {commit.message}
299 </div>
300 {fullMessage !== commit.message && (
301 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
302 {fullMessage}
303 </div>
304 )}
305 <div style="font-size: 13px; color: var(--text-muted)">
306 <strong style="color: var(--text)">{commit.author}</strong>{" "}
307 committed on{" "}
308 {new Date(commit.date).toLocaleDateString("en-US", {
309 month: "long",
310 day: "numeric",
311 year: "numeric",
312 })}
313 </div>
314 <div style="margin-top: 8px">
315 <span class="commit-sha">{commit.sha}</span>
316 {commit.parentShas.length > 0 && (
317 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
318 Parent:{" "}
319 {commit.parentShas.map((p) => (
320 <a
321 href={`/${owner}/${repo}/commit/${p}`}
322 class="commit-sha"
323 style="margin-left: 4px"
324 >
325 {p.slice(0, 7)}
326 </a>
327 ))}
328 </span>
329 )}
330 </div>
331 </div>
332 <DiffView raw={raw} files={files} />
333 </Layout>
334 );
335});
336
337export default web;
Addedsrc/views/components.tsx+231−0View fileUnifiedSplit
@@ -0,0 +1,231 @@
1import type { FC } from "hono/jsx";
2import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
3
4export const RepoHeader: FC<{ owner: string; repo: string }> = ({
5 owner,
6 repo,
7}) => (
8 <div class="repo-header">
9 <a href={`/${owner}`} class="owner">
10 {owner}
11 </a>
12 <span class="separator">/</span>
13 <a href={`/${owner}/${repo}`} class="name">
14 {repo}
15 </a>
16 </div>
17);
18
19export const RepoNav: FC<{
20 owner: string;
21 repo: string;
22 active: "code" | "commits";
23}> = ({ owner, repo, active }) => (
24 <div class="repo-nav">
25 <a
26 href={`/${owner}/${repo}`}
27 class={active === "code" ? "active" : ""}
28 >
29 Code
30 </a>
31 <a
32 href={`/${owner}/${repo}/commits`}
33 class={active === "commits" ? "active" : ""}
34 >
35 Commits
36 </a>
37 </div>
38);
39
40export const Breadcrumb: FC<{
41 owner: string;
42 repo: string;
43 ref: string;
44 path: string;
45}> = ({ owner, repo, ref, path }) => {
46 const parts = path.split("/").filter(Boolean);
47 const crumbs: { name: string; href: string }[] = [
48 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
49 ];
50 let accumulated = "";
51 for (const part of parts) {
52 accumulated += (accumulated ? "/" : "") + part;
53 crumbs.push({
54 name: part,
55 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
56 });
57 }
58 return (
59 <div class="breadcrumb">
60 {crumbs.map((crumb, i) => (
61 <>
62 {i > 0 && <span>/</span>}
63 {i === crumbs.length - 1 ? (
64 <strong>{crumb.name}</strong>
65 ) : (
66 <a href={crumb.href}>{crumb.name}</a>
67 )}
68 </>
69 ))}
70 </div>
71 );
72};
73
74export const FileTable: FC<{
75 entries: GitTreeEntry[];
76 owner: string;
77 repo: string;
78 ref: string;
79 path: string;
80}> = ({ entries, owner, repo, ref, path }) => (
81 <table class="file-table">
82 <tbody>
83 {entries.map((entry) => {
84 const fullPath = path ? `${path}/${entry.name}` : entry.name;
85 const href =
86 entry.type === "tree"
87 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
88 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
89 return (
90 <tr>
91 <td class="file-icon">
92 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
93 </td>
94 <td class="file-name">
95 <a href={href}>{entry.name}</a>
96 </td>
97 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
98 {entry.size !== undefined ? formatSize(entry.size) : ""}
99 </td>
100 </tr>
101 );
102 })}
103 </tbody>
104 </table>
105);
106
107export const CommitList: FC<{
108 commits: GitCommit[];
109 owner: string;
110 repo: string;
111}> = ({ commits, owner, repo }) => (
112 <div class="commit-list">
113 {commits.map((commit) => (
114 <div class="commit-item">
115 <div>
116 <div class="commit-message">
117 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
118 {commit.message}
119 </a>
120 </div>
121 <div class="commit-meta">
122 {commit.author} committed{" "}
123 {formatRelativeDate(commit.date)}
124 </div>
125 </div>
126 <a
127 href={`/${owner}/${repo}/commit/${commit.sha}`}
128 class="commit-sha"
129 >
130 {commit.sha.slice(0, 7)}
131 </a>
132 </div>
133 ))}
134 </div>
135);
136
137export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
138 raw,
139 files,
140}) => {
141 // Parse unified diff into per-file sections
142 const sections = parseDiff(raw);
143
144 return (
145 <div class="diff-view">
146 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
147 Showing{" "}
148 <strong style="color: var(--text)">{files.length}</strong> changed
149 file{files.length !== 1 ? "s" : ""} with{" "}
150 <span class="stat-add">
151 +{files.reduce((s, f) => s + f.additions, 0)}
152 </span>{" "}
153 and{" "}
154 <span class="stat-del">
155 -{files.reduce((s, f) => s + f.deletions, 0)}
156 </span>
157 </div>
158 {sections.map((section) => (
159 <div class="diff-file">
160 <div class="diff-file-header">{section.path}</div>
161 <div class="diff-content">
162 {section.lines.map((line) => {
163 let cls = "line";
164 if (line.startsWith("+")) cls += " line-add";
165 else if (line.startsWith("-")) cls += " line-del";
166 else if (line.startsWith("@@")) cls += " line-hunk";
167 return <span class={cls}>{line + "\n"}</span>;
168 })}
169 </div>
170 </div>
171 ))}
172 </div>
173 );
174};
175
176function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
177 const sections: Array<{ path: string; lines: string[] }> = [];
178 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
179 let current: { path: string; lines: string[] } | null = null;
180
181 for (const line of raw.split("\n")) {
182 const match = line.match(diffRegex);
183 if (match) {
184 if (current) sections.push(current);
185 current = { path: match[1], lines: [] };
186 continue;
187 }
188 if (current && !line.startsWith("diff --git")) {
189 // Skip index/--- /+++ header lines from display
190 if (
191 line.startsWith("index ") ||
192 line.startsWith("--- ") ||
193 line.startsWith("+++ ") ||
194 line.startsWith("new file") ||
195 line.startsWith("deleted file") ||
196 line.startsWith("old mode") ||
197 line.startsWith("new mode")
198 ) {
199 continue;
200 }
201 current.lines.push(line);
202 }
203 }
204 if (current) sections.push(current);
205 return sections;
206}
207
208function formatSize(bytes: number): string {
209 if (bytes < 1024) return `${bytes} B`;
210 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
211 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
212}
213
214function formatRelativeDate(dateStr: string): string {
215 const date = new Date(dateStr);
216 const now = new Date();
217 const diffMs = now.getTime() - date.getTime();
218 const diffMins = Math.floor(diffMs / 60000);
219 if (diffMins < 1) return "just now";
220 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
221 const diffHours = Math.floor(diffMins / 60);
222 if (diffHours < 24)
223 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
224 const diffDays = Math.floor(diffHours / 24);
225 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
226 return date.toLocaleDateString("en-US", {
227 month: "short",
228 day: "numeric",
229 year: "numeric",
230 });
231}
Addedsrc/views/layout.tsx+257−0View fileUnifiedSplit
@@ -0,0 +1,257 @@
1import type { FC, PropsWithChildren } from "hono/jsx";
2
3export const Layout: FC<PropsWithChildren<{ title?: string }>> = ({
4 children,
5 title,
6}) => {
7 return (
8 <html lang="en">
9 <head>
10 <meta charset="UTF-8" />
11 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
12 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
13 <style>{css}</style>
14 </head>
15 <body>
16 <header>
17 <nav>
18 <a href="/" class="logo">
19 gluecron
20 </a>
21 </nav>
22 </header>
23 <main>{children}</main>
24 <footer>
25 <span>gluecron — AI-native code intelligence</span>
26 </footer>
27 </body>
28 </html>
29 );
30};
31
32const css = `
33 :root {
34 --bg: #0d1117;
35 --bg-secondary: #161b22;
36 --bg-tertiary: #21262d;
37 --border: #30363d;
38 --text: #e6edf3;
39 --text-muted: #8b949e;
40 --text-link: #58a6ff;
41 --accent: #1f6feb;
42 --accent-hover: #388bfd;
43 --green: #3fb950;
44 --red: #f85149;
45 --yellow: #d29922;
46 --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
47 --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
48 --radius: 6px;
49 }
50
51 * { margin: 0; padding: 0; box-sizing: border-box; }
52
53 body {
54 font-family: var(--font-sans);
55 background: var(--bg);
56 color: var(--text);
57 line-height: 1.5;
58 min-height: 100vh;
59 display: flex;
60 flex-direction: column;
61 }
62
63 a { color: var(--text-link); text-decoration: none; }
64 a:hover { text-decoration: underline; }
65
66 header {
67 border-bottom: 1px solid var(--border);
68 padding: 12px 24px;
69 background: var(--bg-secondary);
70 }
71
72 header nav { display: flex; align-items: center; gap: 20px; max-width: 1200px; margin: 0 auto; }
73 .logo { font-size: 20px; font-weight: 700; color: var(--text); }
74 .logo:hover { text-decoration: none; color: var(--text-link); }
75
76 main { max-width: 1200px; margin: 0 auto; padding: 24px; flex: 1; width: 100%; }
77
78 footer {
79 border-top: 1px solid var(--border);
80 padding: 16px 24px;
81 text-align: center;
82 color: var(--text-muted);
83 font-size: 13px;
84 }
85
86 .repo-header {
87 display: flex;
88 align-items: center;
89 gap: 8px;
90 margin-bottom: 16px;
91 font-size: 20px;
92 }
93 .repo-header .owner { color: var(--text-link); }
94 .repo-header .separator { color: var(--text-muted); }
95 .repo-header .name { color: var(--text-link); font-weight: 600; }
96
97 .repo-nav {
98 display: flex;
99 gap: 0;
100 border-bottom: 1px solid var(--border);
101 margin-bottom: 20px;
102 }
103 .repo-nav a {
104 padding: 8px 16px;
105 color: var(--text-muted);
106 border-bottom: 2px solid transparent;
107 font-size: 14px;
108 }
109 .repo-nav a:hover { text-decoration: none; color: var(--text); }
110 .repo-nav a.active { color: var(--text); border-bottom-color: var(--accent); }
111
112 .breadcrumb { display: flex; gap: 4px; align-items: center; margin-bottom: 16px; color: var(--text-muted); font-size: 14px; }
113 .breadcrumb a { color: var(--text-link); }
114
115 .file-table { width: 100%; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
116 .file-table tr { border-bottom: 1px solid var(--border); }
117 .file-table tr:last-child { border-bottom: none; }
118 .file-table td { padding: 8px 16px; font-size: 14px; }
119 .file-table tr:hover { background: var(--bg-secondary); }
120 .file-icon { width: 20px; color: var(--text-muted); }
121 .file-name a { color: var(--text); }
122 .file-name a:hover { color: var(--text-link); text-decoration: underline; }
123
124 .blob-view {
125 border: 1px solid var(--border);
126 border-radius: var(--radius);
127 overflow: hidden;
128 }
129 .blob-header {
130 background: var(--bg-secondary);
131 padding: 8px 16px;
132 border-bottom: 1px solid var(--border);
133 font-size: 13px;
134 color: var(--text-muted);
135 }
136 .blob-code {
137 overflow-x: auto;
138 font-family: var(--font-mono);
139 font-size: 13px;
140 line-height: 1.6;
141 }
142 .blob-code table { width: 100%; border-collapse: collapse; }
143 .blob-code .line-num {
144 width: 1%;
145 min-width: 50px;
146 padding: 0 12px;
147 text-align: right;
148 color: var(--text-muted);
149 user-select: none;
150 white-space: nowrap;
151 border-right: 1px solid var(--border);
152 }
153 .blob-code .line-content { padding: 0 12px; white-space: pre; }
154
155 .commit-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
156 .commit-item {
157 display: flex;
158 justify-content: space-between;
159 align-items: center;
160 padding: 12px 16px;
161 border-bottom: 1px solid var(--border);
162 }
163 .commit-item:last-child { border-bottom: none; }
164 .commit-item:hover { background: var(--bg-secondary); }
165 .commit-message { font-size: 14px; font-weight: 500; }
166 .commit-meta { font-size: 12px; color: var(--text-muted); }
167 .commit-sha {
168 font-family: var(--font-mono);
169 font-size: 12px;
170 padding: 2px 8px;
171 background: var(--bg-tertiary);
172 border: 1px solid var(--border);
173 border-radius: var(--radius);
174 color: var(--text-link);
175 }
176
177 .diff-view { margin-top: 16px; }
178 .diff-file {
179 border: 1px solid var(--border);
180 border-radius: var(--radius);
181 margin-bottom: 16px;
182 overflow: hidden;
183 }
184 .diff-file-header {
185 background: var(--bg-secondary);
186 padding: 8px 16px;
187 border-bottom: 1px solid var(--border);
188 font-family: var(--font-mono);
189 font-size: 13px;
190 }
191 .diff-content {
192 overflow-x: auto;
193 font-family: var(--font-mono);
194 font-size: 13px;
195 line-height: 1.6;
196 }
197 .diff-content .line-add { background: rgba(63, 185, 80, 0.15); color: var(--green); }
198 .diff-content .line-del { background: rgba(248, 81, 73, 0.1); color: var(--red); }
199 .diff-content .line-hunk { background: rgba(56, 139, 253, 0.1); color: var(--text-link); }
200 .diff-content .line { padding: 0 12px; white-space: pre; display: block; }
201
202 .stat-add { color: var(--green); font-weight: 600; }
203 .stat-del { color: var(--red); font-weight: 600; }
204
205 .empty-state {
206 text-align: center;
207 padding: 60px 20px;
208 color: var(--text-muted);
209 }
210 .empty-state h2 { font-size: 24px; margin-bottom: 8px; color: var(--text); }
211 .empty-state pre {
212 text-align: left;
213 display: inline-block;
214 background: var(--bg-secondary);
215 padding: 16px 24px;
216 border-radius: var(--radius);
217 border: 1px solid var(--border);
218 font-family: var(--font-mono);
219 font-size: 13px;
220 margin-top: 16px;
221 line-height: 1.8;
222 }
223
224 .badge {
225 display: inline-block;
226 padding: 2px 8px;
227 border-radius: 12px;
228 font-size: 12px;
229 font-weight: 500;
230 background: var(--bg-tertiary);
231 border: 1px solid var(--border);
232 color: var(--text-muted);
233 }
234
235 .branch-selector {
236 display: inline-flex;
237 align-items: center;
238 gap: 4px;
239 padding: 4px 12px;
240 background: var(--bg-tertiary);
241 border: 1px solid var(--border);
242 border-radius: var(--radius);
243 font-size: 13px;
244 color: var(--text);
245 margin-bottom: 12px;
246 }
247
248 .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
249 .card {
250 border: 1px solid var(--border);
251 border-radius: var(--radius);
252 padding: 16px;
253 background: var(--bg-secondary);
254 }
255 .card h3 { font-size: 16px; margin-bottom: 4px; }
256 .card p { font-size: 13px; color: var(--text-muted); }
257`;
Addedtsconfig.json+25−0View fileUnifiedSplit
@@ -0,0 +1,25 @@
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ES2022",
5 "moduleResolution": "bundler",
6 "jsx": "react-jsx",
7 "jsxImportSource": "hono/jsx",
8 "strict": true,
9 "esModuleInterop": true,
10 "skipLibCheck": true,
11 "forceConsistentCasingInFileNames": true,
12 "resolveJsonModule": true,
13 "declaration": true,
14 "declarationMap": true,
15 "sourceMap": true,
16 "outDir": "./dist",
17 "rootDir": "./src",
18 "paths": {
19 "@/*": ["./src/*"]
20 },
21 "types": ["bun-types"]
22 },
23 "include": ["src/**/*.ts", "src/**/*.tsx"],
24 "exclude": ["node_modules", "dist"]
25}
026