git-fs-s3 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/chunk-4QPWSRYC.js +123 -0
- package/dist/chunk-4QPWSRYC.js.map +1 -0
- package/dist/chunk-T5NHPY7U.js +118 -0
- package/dist/chunk-T5NHPY7U.js.map +1 -0
- package/dist/http.cjs +692 -0
- package/dist/http.cjs.map +1 -0
- package/dist/http.d.cts +197 -0
- package/dist/http.d.ts +197 -0
- package/dist/http.js +594 -0
- package/dist/http.js.map +1 -0
- package/dist/index.cjs +801 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +373 -0
- package/dist/index.d.ts +373 -0
- package/dist/index.js +568 -0
- package/dist/index.js.map +1 -0
- package/dist/ops.cjs +1021 -0
- package/dist/ops.cjs.map +1 -0
- package/dist/ops.d.cts +290 -0
- package/dist/ops.d.ts +290 -0
- package/dist/ops.js +889 -0
- package/dist/ops.js.map +1 -0
- package/dist/s3.cjs +123 -0
- package/dist/s3.cjs.map +1 -0
- package/dist/s3.d.cts +36 -0
- package/dist/s3.d.ts +36 -0
- package/dist/s3.js +104 -0
- package/dist/s3.js.map +1 -0
- package/dist/types-BHoHOaQt.d.cts +53 -0
- package/dist/types-BHoHOaQt.d.ts +53 -0
- package/dist/types-QgIkUR_q.d.cts +121 -0
- package/dist/types-QgIkUR_q.d.ts +121 -0
- package/package.json +104 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nandan Varma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# git-fs-s3
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/git-fs-s3)
|
|
4
|
+
[](https://github.com/nandan-varma/git-fs-s3/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
An [isomorphic-git](https://isomorphic-git.org) filesystem backend for S3-compatible object storage — AWS S3, Cloudflare R2, MinIO, Backblaze B2. Run git repositories on serverless platforms with **no disk, no git binary, and no state between invocations**.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
isomorphic-git ──fs──► git-fs-s3 ──► ObjectStore ──► S3 / R2 / MinIO / memory
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Extracted from the git layer of a production git-hosting service, where it serves clones, pushes, and repo browsing directly against Cloudflare R2 from Vercel functions. Three layers, usable independently:
|
|
14
|
+
|
|
15
|
+
- **root export** — the fs backend itself (`createGitFs`, stores, caching).
|
|
16
|
+
- **`/http`** — a git smart-HTTP protocol handler (`upload-pack`/`receive-pack`) built on it.
|
|
17
|
+
- **`/ops`** — higher-level git-hosting operations (branches, commits, diffs, history, merge) built on top of that.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
isomorphic-git removed the need for a native `git` binary, but it still expects a filesystem. On Lambda, Vercel, or Cloudflare Workers you don't have a durable one. This package maps the fs contract git actually uses onto object-storage keys, so a bare repository lives entirely in a bucket:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
repos/alice/blog/HEAD
|
|
25
|
+
repos/alice/blog/refs/heads/main
|
|
26
|
+
repos/alice/blog/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use it to build git-backed CMSes, notes apps with real version history, lightweight code forges, or agent sandboxes with auditable file history.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install git-fs-s3 isomorphic-git
|
|
35
|
+
# for the S3 store (any S3-compatible provider):
|
|
36
|
+
npm install @aws-sdk/client-s3
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
### Cloudflare R2 / AWS S3 / MinIO
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
45
|
+
import git from "isomorphic-git";
|
|
46
|
+
import { createCachedStore, createGitFs } from "git-fs-s3";
|
|
47
|
+
import { S3ObjectStore } from "git-fs-s3/s3";
|
|
48
|
+
|
|
49
|
+
const store = new S3ObjectStore({
|
|
50
|
+
client: new S3Client({
|
|
51
|
+
region: "auto",
|
|
52
|
+
endpoint: process.env.R2_ENDPOINT, // omit for AWS S3
|
|
53
|
+
credentials: {
|
|
54
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
55
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
bucket: "my-git-repos",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const fs = createGitFs(createCachedStore(store), {
|
|
62
|
+
prefix: "repos/alice/blog",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The bucket now behaves like a directory containing a bare repo:
|
|
66
|
+
await git.init({ fs, gitdir: "/git", bare: true, defaultBranch: "main" });
|
|
67
|
+
const oid = await git.resolveRef({ fs, gitdir: "/git", ref: "main" });
|
|
68
|
+
const log = await git.log({ fs, gitdir: "/git", ref: "main" });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### In memory (tests, examples)
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import git from "isomorphic-git";
|
|
75
|
+
import { createGitFs, MemoryObjectStore } from "git-fs-s3";
|
|
76
|
+
|
|
77
|
+
const fs = createGitFs(new MemoryObjectStore());
|
|
78
|
+
await git.init({ fs, gitdir: "/repo.git", bare: true });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Any other storage
|
|
82
|
+
|
|
83
|
+
Implement the five-method `ObjectStore` interface (`get`, `put`, `delete`, `head`, `list`) and pass it to `createGitFs` — that's the whole contract. Google Cloud Storage, Azure Blob, a database, anything.
|
|
84
|
+
|
|
85
|
+
## Production stack
|
|
86
|
+
|
|
87
|
+
The stores compose as decorators. For a serving path that hits object storage hundreds of times per page, stack them in this order — retry closest to the network so the cache never stores transient failures and coalesced callers share one retried request:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import {
|
|
91
|
+
createCachedStore,
|
|
92
|
+
createGitFs,
|
|
93
|
+
createRetryStore,
|
|
94
|
+
} from "git-fs-s3";
|
|
95
|
+
import { S3ObjectStore } from "git-fs-s3/s3";
|
|
96
|
+
|
|
97
|
+
const store = createCachedStore(
|
|
98
|
+
createRetryStore(
|
|
99
|
+
new S3ObjectStore({
|
|
100
|
+
client,
|
|
101
|
+
bucket: "my-git-repos",
|
|
102
|
+
// cosmetic: keeps refs/config human-readable in bucket UIs
|
|
103
|
+
contentType: (key) =>
|
|
104
|
+
/\/(HEAD|config)$|\/refs\//.test(key) ? "text/plain" : undefined,
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
{
|
|
108
|
+
maxBytes: 256 * 1024 * 1024,
|
|
109
|
+
ttlMs: 3_600_000,
|
|
110
|
+
cacheMisses: true, // loose-object probes on packed repos are ~always misses
|
|
111
|
+
cacheLists: true, // caches readdir + existence probes
|
|
112
|
+
// refs and the objects/pack listing are the mutable parts of a gitdir —
|
|
113
|
+
// give them a short override instead of the long ttlMs above, or a warm
|
|
114
|
+
// process can keep serving a pre-push ref (or fail to notice a freshly
|
|
115
|
+
// pushed pack exists at all) for the rest of ttlMs. Cheap: both are one
|
|
116
|
+
// small object or a bounded listing.
|
|
117
|
+
ttlForKey: (key) =>
|
|
118
|
+
/\/(HEAD|refs(\/|$)|objects\/(pack\/)?$)/.test(key) ? 5_000 : undefined,
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const fs = createGitFs(store, {
|
|
123
|
+
looseObjectHints: true,
|
|
124
|
+
// paths git probes constantly but this backend never writes:
|
|
125
|
+
isStructurallyAbsent: (p) => /(^|\/)git\/(packed-refs|shallow)$/.test(p),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Before a full-history walk (commit log, reachability traversal):
|
|
129
|
+
await fs.detectLooseObjects(gitdir); // one bounded LIST per repo
|
|
130
|
+
await fs.prefetchPacks(gitdir); // parallel pack warm-up
|
|
131
|
+
|
|
132
|
+
// After anything writes to the bucket *around* this fs (a hydrate/sync
|
|
133
|
+
// pipeline, a bulk upload, another process): drop the affected state, or
|
|
134
|
+
// reads can stay stale for up to ttlMs.
|
|
135
|
+
fs.invalidate("repos/alice/blog");
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
### `createGitFs(store, options?)`
|
|
141
|
+
|
|
142
|
+
Returns a promise-based fs client for isomorphic-git's `fs` option, plus git-aware maintenance methods.
|
|
143
|
+
|
|
144
|
+
- `options.prefix` — key prefix all paths resolve under (e.g. `"repos/alice/blog"`). Paths that would traverse above it throw `EINVAL`.
|
|
145
|
+
- `options.looseObjectHints` — remember, per gitdir, whether any loose objects exist so fully packed repos skip every guaranteed-miss loose read. Hints are only created by `detectLooseObjects`; a loose write flips them back instantly, so they can't go stale mid-push. Default off.
|
|
146
|
+
- `options.isStructurallyAbsent(path)` — paths that are known never to exist answer `ENOENT` with zero store calls (e.g. `packed-refs`/`shallow` under your gitdir layout). Match precisely — `refs/heads/packed-refs` is a legal branch.
|
|
147
|
+
- `options.hintTtlMs` — loose-hint TTL, default 1 h. `options.onNote` — diagnostic sink.
|
|
148
|
+
|
|
149
|
+
The returned fs also exposes:
|
|
150
|
+
|
|
151
|
+
- `detectLooseObjects(gitdir)` — one bounded LIST that registers the loose-object hint; call before full-history walks.
|
|
152
|
+
- `prefetchPacks(gitdir, { maxPacks? })` — warm all pack files in parallel before a sequential walk (skipped past `maxPacks * 2` entries).
|
|
153
|
+
- `invalidate(pathPrefix)` — drop hints under the prefix and forward to the store's `invalidate` if it has one.
|
|
154
|
+
|
|
155
|
+
### `S3ObjectStore({ client, bucket, prefix?, contentType? })`
|
|
156
|
+
|
|
157
|
+
`ObjectStore` over `@aws-sdk/client-s3` (optional peer dependency, loaded only via the `/s3` subpath). Reuse one `S3Client` across stores so HTTP connections are pooled — a busy repo page can mean hundreds of object reads. `contentType(key)` optionally derives a `Content-Type` header for uploads.
|
|
158
|
+
|
|
159
|
+
### `MemoryObjectStore()`
|
|
160
|
+
|
|
161
|
+
In-memory store; also the reference implementation of list/delimiter semantics.
|
|
162
|
+
|
|
163
|
+
### `createCachedStore(store, options?)`
|
|
164
|
+
|
|
165
|
+
Wraps any store with an in-process LRU read cache (git objects are content-addressed, so they cache perfectly; refs and directory listings are mutable and should use a shorter override — see `ttlForKey`). Returns a `CachedObjectStore` with an `invalidate(prefix)` method.
|
|
166
|
+
|
|
167
|
+
- `maxBytes` — cache budget, default 50 MiB. `maxEntryBytes` — largest admissible entry, default a tenth of `maxBytes`, so one huge pack can't evict the working set.
|
|
168
|
+
- `ttlMs` — entry TTL, default 60 s
|
|
169
|
+
- `ttlForKey(key)` — per-key/prefix TTL override in ms (applies to `get`/`head`/`list`); return `undefined` to fall back to `ttlMs`. A single long `ttlMs` is right for content-addressed object keys (the same key's bytes never change) but wrong for mutable ones — a ref's value moves on every push, and a directory listing (`objects/pack/`) grows on every push, while the *key* naming them stays the same. This cache is in-process with no cross-instance invalidation, so without an override a warm instance that already cached a ref, or a pack directory's listing, can keep serving that pre-push view for the rest of `ttlMs` even though nothing is wrong with its own invalidation logic — it just never re-checked. See the [caching guide](https://nandan-varma.github.io/git-fs-s3/guides/caching/) for the full reasoning.
|
|
170
|
+
- `cacheMisses` — also cache "not found" results. Big win for loose-object probes on packed repos; only safe when this process is the sole writer.
|
|
171
|
+
- `cacheLists` — also cache `list()` results (readdir + existence probes). Writes through the store keep listings consistent, with one deliberate asymmetry: a non-empty `limit: 1` probe ("this directory exists") survives writes underneath it, since a write can't make a directory stop existing. After external writes, call `invalidate(prefix)`.
|
|
172
|
+
- `coalesce` — collapse concurrent `get`/`head`/`list` calls for the same key into one backend request. Default on.
|
|
173
|
+
- `onHit(key)` / `onMiss(key)` — instrumentation hooks.
|
|
174
|
+
|
|
175
|
+
**Staleness contract:** `cacheMisses`/`cacheLists` trade read traffic for a window (bounded by `ttlMs`) in which another process's writes are invisible. Fine when each repo has one serving process, or when every external write path calls `invalidate()`.
|
|
176
|
+
|
|
177
|
+
### `createRetryStore(store, options?)`
|
|
178
|
+
|
|
179
|
+
Wraps any store with exponential-backoff retries and a per-instance circuit breaker. Place it directly above the network store, under the cache.
|
|
180
|
+
|
|
181
|
+
- `retries` (3), `initialDelayMs` (100), `maxDelayMs` (5000), `jitter` (0.3)
|
|
182
|
+
- `isRetryable(error)` — default retries network faults, throttling, and HTTP 5xx/429
|
|
183
|
+
- `breaker` — `{ threshold: 5, resetMs: 30_000 }` by default, or `false` to disable. While open, calls fail fast with `CircuitOpenError` (`code: "EUNAVAILABLE"`).
|
|
184
|
+
- `onRetry(info)` — logging hook.
|
|
185
|
+
|
|
186
|
+
## Git smart-HTTP
|
|
187
|
+
|
|
188
|
+
`git-fs-s3/http` implements the git smart-HTTP protocol (`info/refs`, `upload-pack`, `receive-pack`) as plain functions over a `Repo` — no framework assumptions, Fetch-API-shaped inputs/outputs. Extracted from the same production git-hosting service's HTTP layer, so it's what actually serves `git clone`/`git push` over HTTPS: pkt-line framing, side-band-64k packfile chunking (required once a client like isomorphic-git negotiates it — it always demuxes the response), CAS-checked ref updates, and pack consolidation.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import {
|
|
192
|
+
handleInfoRefs,
|
|
193
|
+
handleUploadPack,
|
|
194
|
+
parseReceivePackBody,
|
|
195
|
+
applyReceivePack,
|
|
196
|
+
receivePackResponse,
|
|
197
|
+
} from "git-fs-s3/http";
|
|
198
|
+
|
|
199
|
+
// GET .../info/refs?service=git-upload-pack — auth/authz is the caller's job
|
|
200
|
+
export async function infoRefs(repo: Repo, service: "git-upload-pack" | "git-receive-pack") {
|
|
201
|
+
const { status, headers, body } = await handleInfoRefs(repo, { service });
|
|
202
|
+
return new Response(body, { status, headers });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// POST .../git-upload-pack (clone/fetch)
|
|
206
|
+
export async function uploadPack(repo: Repo, request: Request) {
|
|
207
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
208
|
+
const { status, headers, body: respBody } = await handleUploadPack(repo, body);
|
|
209
|
+
return new Response(respBody, { status, headers });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// POST .../git-receive-pack (push)
|
|
213
|
+
export async function receivePack(repo: Repo, request: Request) {
|
|
214
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
215
|
+
const { results, stalePackPaths } = await applyReceivePack(
|
|
216
|
+
repo,
|
|
217
|
+
parseReceivePackBody(body),
|
|
218
|
+
{ repack: { threshold: 4 } }, // or false to never auto-consolidate
|
|
219
|
+
);
|
|
220
|
+
// stalePackPaths were removed locally by the repack — delete them from any
|
|
221
|
+
// secondary storage this repo also lives in, same as you'd invalidate a cache.
|
|
222
|
+
const { status, headers, body: respBody } = receivePackResponse(results);
|
|
223
|
+
return new Response(respBody, { status, headers });
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Every client-supplied ref name in a push is validated with `isSafeFullRefName` (from the top-level export, see below) *inside* `applyRefUpdates`/`applyReceivePack` before it reaches any filesystem call — `git.commit`/`git.merge`/`git.deleteBranch` and the raw `git.resolveRef`/`git.deleteRef`/`git.writeRef` isomorphic-git calls this module uses internally do **not** validate ref names themselves (only `git.branch` and the top-level `git.writeRef` do), so an unvalidated `"../"`-laden ref name is a cross-repo path traversal on any shared-storage server. If you build additional git-touching endpoints on top of this package, run ref/branch names from request input through `isSafeFullRefName`/`isSafeBranchName`/`isSafeRefName`/`isSafeRepoPath` yourself first.
|
|
228
|
+
|
|
229
|
+
`HttpHooks` (`{ step?, onWarn? }`), accepted by every handler, is the instrumentation seam: `step(label, fn)` wraps a timed sub-step — drop in an app's own request-scoped timer — and `onWarn(message, error)` surfaces non-fatal problems (a missing object during a reachability walk, a failed repack). `handleUploadPack`'s `beforeWalk` option is where to wire loose-object detection (`GitFs.detectLooseObjects`, above) so a fully packed repo's reachability walk doesn't pay a doomed loose-object probe per object.
|
|
230
|
+
|
|
231
|
+
`repackRepository(repo, options?, hooks?)` — also exported standalone for out-of-band maintenance (clearing a backlog outside a live push) — consolidates all packs into one once `objects/pack/` crosses `options.threshold` (default `REPACK_PACK_COUNT_THRESHOLD = 4`) packs, verifying every object's SHA-1 independently before writing (isomorphic-git's *packed*-object read path never checks a resolved delta's content against the requested oid — only the loose-object branch does). Returns the gitdir-relative paths of the `.pack`/`.idx` files it removed locally; if this repo's storage is also synced elsewhere, delete those same paths there too, or a reader served by the stale copy re-fetches objects that are gone from the pack it expects them in.
|
|
232
|
+
|
|
233
|
+
## App-layer git operations
|
|
234
|
+
|
|
235
|
+
`git-fs-s3/ops` is a higher-level layer for building a git-hosting UI on top of `createGitFs` — the operations a repo browser / PR flow actually needs, each taking a `Repo` (`{ fs, gitdir, cache? }`) plus an optional `OpsHooks` (`{ resultCache?, step?, onNote?, prefetchPacks? }`) for the same timing/caching seam `GitFs` and `/http` use:
|
|
236
|
+
|
|
237
|
+
- **Branches** — `listBranches`, `createBranchFrom`, `deleteBranchByName`.
|
|
238
|
+
- **Commits** — `commitFilesToBare` (write one or more files as a single commit against a branch, creating it if new), `deleteFileFromBare`, `authorNow`.
|
|
239
|
+
- **Trees** — `upsertTree`/`deleteFromTree` (overlay blobs onto a tree, return the new root oid), `listTreeEntries`, `findTreeEntry`.
|
|
240
|
+
- **History** — `getCommitLog`/`getCommitHistory` (cached, resumable commit-chain walks — reuses a previously-walked prefix instead of re-walking from HEAD), `getFileContent`/`getFileFromRef`/`getTreeFromRef`, `getFileHistory` (per-file commit history), `getLastCommitsForTree` (the "last commit" column on a directory listing, batched two-phase prefetch-then-resolve).
|
|
241
|
+
- **Diff** — `getCommitDiff`/`getDiffBetweenRefs`, unified-diff patches via the `diff` package (optional peer dependency).
|
|
242
|
+
- **Merge** — `analyzeMerge` (fast-forward/diverged pre-check — not a real content-conflict check, see its doc comment) and `fastForwardMerge`.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { commitFilesToBare, authorNow, getCommitLog } from "git-fs-s3/ops";
|
|
246
|
+
|
|
247
|
+
await commitFilesToBare(repo, {
|
|
248
|
+
branch: "main",
|
|
249
|
+
message: "Update README",
|
|
250
|
+
author: authorNow("Ada", "ada@example.com"),
|
|
251
|
+
files: [{ path: "README.md", content: "# hello\n" }],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const { entries } = await getCommitLog(repo, { ref: "main", depth: 50 });
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`resultKeyPrefixes` (from `/ops`) lists the `ResultCache` key prefixes these functions write under — evict them after rewriting a repo's storage out of band (bulk sync, rename), or a cached walk result outlives the history it describes.
|
|
258
|
+
|
|
259
|
+
## Semantics & limitations
|
|
260
|
+
|
|
261
|
+
- **Bare repositories are the target.** Plumbing (`writeBlob`/`writeTree`/`writeCommit`/`readCommit`/`log`/refs/branches) is covered by the test suite. Worktree operations (`checkout`, `add`, `status`) want a real disk — hydrate to `/tmp` for those.
|
|
262
|
+
- Directories are implicit (`mkdir` no-op, a dir exists when keys live under it), like object storage itself.
|
|
263
|
+
- Symlinks are unsupported (`readlink`→`ENOENT`, `symlink`→`EPERM`); bare repos don't contain them.
|
|
264
|
+
- Object storage has no rename and no atomic multi-key transactions. Concurrent pushes to the same repo need external serialization (a lock or single-writer queue).
|
|
265
|
+
|
|
266
|
+
## Roadmap
|
|
267
|
+
|
|
268
|
+
- More stores out of the box (Google Cloud Storage, Azure Blob)
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// src/edge-utils.ts
|
|
2
|
+
var textEncoder = new TextEncoder();
|
|
3
|
+
var textDecoder = new TextDecoder();
|
|
4
|
+
function encodeUtf8(data) {
|
|
5
|
+
return textEncoder.encode(data);
|
|
6
|
+
}
|
|
7
|
+
function decodeUtf8(data) {
|
|
8
|
+
return textDecoder.decode(data);
|
|
9
|
+
}
|
|
10
|
+
function decodeAscii(data) {
|
|
11
|
+
let s = "";
|
|
12
|
+
for (let i = 0; i < data.length; i++)
|
|
13
|
+
s += String.fromCharCode(data[i]);
|
|
14
|
+
return s;
|
|
15
|
+
}
|
|
16
|
+
function concat(...parts) {
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const p of parts) total += p.length;
|
|
19
|
+
const out = new Uint8Array(total);
|
|
20
|
+
let offset = 0;
|
|
21
|
+
for (const p of parts) {
|
|
22
|
+
out.set(p, offset);
|
|
23
|
+
offset += p.length;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function toHex(data) {
|
|
28
|
+
let hex = "";
|
|
29
|
+
for (let i = 0; i < data.length; i++)
|
|
30
|
+
hex += data[i].toString(16).padStart(2, "0");
|
|
31
|
+
return hex;
|
|
32
|
+
}
|
|
33
|
+
function toBase64(data) {
|
|
34
|
+
let binary = "";
|
|
35
|
+
for (let i = 0; i < data.length; i++)
|
|
36
|
+
binary += String.fromCharCode(data[i]);
|
|
37
|
+
return btoa(binary);
|
|
38
|
+
}
|
|
39
|
+
function fromHex(hex) {
|
|
40
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
41
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
42
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
43
|
+
}
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
async function sha1(data) {
|
|
47
|
+
const bytes = typeof data === "string" ? encodeUtf8(data) : data;
|
|
48
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-1", bytes);
|
|
49
|
+
return toHex(new Uint8Array(hash));
|
|
50
|
+
}
|
|
51
|
+
async function deflate(data) {
|
|
52
|
+
const stream = new Blob([data]).stream().pipeThrough(new CompressionStream("deflate"));
|
|
53
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
54
|
+
}
|
|
55
|
+
function hasNullByte(data) {
|
|
56
|
+
return data.includes(0);
|
|
57
|
+
}
|
|
58
|
+
function readBlobContent(blob) {
|
|
59
|
+
const isBinary = hasNullByte(blob);
|
|
60
|
+
return {
|
|
61
|
+
isBinary,
|
|
62
|
+
text: isBinary ? "" : decodeUtf8(blob),
|
|
63
|
+
bytes: blob
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/refs.ts
|
|
68
|
+
var BAD_REF_COMPONENT = (
|
|
69
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control chars are exactly what git's own ref-name rules reject — this needs to match the same range.
|
|
70
|
+
/(^|[/.])([/.]|$)|^@$|@\{|[\x00-\x20\x7f~^:?*[\\]|\.lock(\/|$)/
|
|
71
|
+
);
|
|
72
|
+
var FULL_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
73
|
+
function isSafeFullRefName(ref) {
|
|
74
|
+
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return !BAD_REF_COMPONENT.test(ref);
|
|
78
|
+
}
|
|
79
|
+
function isSafeBranchName(name) {
|
|
80
|
+
if (!name || name.startsWith("refs/") || name === "HEAD") return false;
|
|
81
|
+
if (FULL_SHA_RE.test(name)) return false;
|
|
82
|
+
return !BAD_REF_COMPONENT.test(name);
|
|
83
|
+
}
|
|
84
|
+
function isFullSha(value) {
|
|
85
|
+
return FULL_SHA_RE.test(value);
|
|
86
|
+
}
|
|
87
|
+
function isSafeRefName(value) {
|
|
88
|
+
return isSafeBranchName(value) || isFullSha(value);
|
|
89
|
+
}
|
|
90
|
+
function isSafeRepoPath(p) {
|
|
91
|
+
if (p.startsWith("/")) return false;
|
|
92
|
+
if (p.split("/").some((segment) => segment === "..")) return false;
|
|
93
|
+
if (/^\.git(\/|$)/i.test(p)) return false;
|
|
94
|
+
if (p.includes("\0")) return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
function qualifyBranchRef(ref) {
|
|
98
|
+
if (ref.startsWith("refs/") || ref === "HEAD" || FULL_SHA_RE.test(ref)) {
|
|
99
|
+
return ref;
|
|
100
|
+
}
|
|
101
|
+
return `refs/heads/${ref}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
encodeUtf8,
|
|
106
|
+
decodeUtf8,
|
|
107
|
+
decodeAscii,
|
|
108
|
+
concat,
|
|
109
|
+
toHex,
|
|
110
|
+
toBase64,
|
|
111
|
+
fromHex,
|
|
112
|
+
sha1,
|
|
113
|
+
deflate,
|
|
114
|
+
hasNullByte,
|
|
115
|
+
readBlobContent,
|
|
116
|
+
isSafeFullRefName,
|
|
117
|
+
isSafeBranchName,
|
|
118
|
+
isFullSha,
|
|
119
|
+
isSafeRefName,
|
|
120
|
+
isSafeRepoPath,
|
|
121
|
+
qualifyBranchRef
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=chunk-4QPWSRYC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/edge-utils.ts","../src/refs.ts"],"sourcesContent":["/**\n * Edge-compatible utilities replacing node:crypto, node:zlib, and Buffer.\n *\n * Every function here uses only Web APIs (SubtleCrypto, CompressionStream,\n * TextEncoder/TextDecoder) — no Node built-ins. They work on Cloudflare\n * Workers, Vercel Edge, Deno Deploy, and Node >= 18.\n */\n\nconst textEncoder = new TextEncoder();\nconst textDecoder = new TextDecoder();\n\n// ---------------------------------------------------------------------------\n// Text\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a UTF-8 string to bytes.\n *\n * Return type pinned to `Uint8Array<ArrayBuffer>` (not the bare `Uint8Array`,\n * whose default type argument differs across TypeScript versions) so it's\n * always assignable to Fetch API `BodyInit` regardless of a consumer's own\n * TypeScript/lib version.\n */\nexport function encodeUtf8(data: string): Uint8Array<ArrayBuffer> {\n\treturn textEncoder.encode(data);\n}\n\n/** Decode bytes as UTF-8. */\nexport function decodeUtf8(data: Uint8Array): string {\n\treturn textDecoder.decode(data);\n}\n\n/** Decode bytes as ASCII. */\nexport function decodeAscii(data: Uint8Array): string {\n\tlet s = \"\";\n\tfor (let i = 0; i < data.length; i++)\n\t\ts += String.fromCharCode(data[i] as number);\n\treturn s;\n}\n\n// ---------------------------------------------------------------------------\n// Array manipulation\n// ---------------------------------------------------------------------------\n\n/** Concatenate any number of Uint8Arrays into one. */\nexport function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {\n\tlet total = 0;\n\tfor (const p of parts) total += p.length;\n\tconst out = new Uint8Array(total);\n\tlet offset = 0;\n\tfor (const p of parts) {\n\t\tout.set(p, offset);\n\t\toffset += p.length;\n\t}\n\treturn out;\n}\n\n/** Extract a subarray (alias for Uint8Array.subarray for readability). */\nexport function slice(\n\tdata: Uint8Array,\n\tstart: number,\n\tend?: number,\n): Uint8Array {\n\treturn data.subarray(start, end);\n}\n\n// ---------------------------------------------------------------------------\n// Encoding\n// ---------------------------------------------------------------------------\n\n/** Uint8Array → lowercase hex string. */\nexport function toHex(data: Uint8Array): string {\n\tlet hex = \"\";\n\tfor (let i = 0; i < data.length; i++)\n\t\thex += (data[i] as number).toString(16).padStart(2, \"0\");\n\treturn hex;\n}\n\n/** Uint8Array → base64 string. */\nexport function toBase64(data: Uint8Array): string {\n\tlet binary = \"\";\n\tfor (let i = 0; i < data.length; i++)\n\t\tbinary += String.fromCharCode(data[i] as number);\n\treturn btoa(binary);\n}\n\n/** Hex string → Uint8Array. */\nexport function fromHex(hex: string): Uint8Array<ArrayBuffer> {\n\tconst bytes = new Uint8Array(hex.length / 2);\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\tbytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n\t}\n\treturn bytes;\n}\n\n// ---------------------------------------------------------------------------\n// Crypto\n// ---------------------------------------------------------------------------\n\n/** SHA-1 hash via Web Crypto API. Returns a hex string. */\nexport async function sha1(data: Uint8Array | string): Promise<string> {\n\tconst bytes = typeof data === \"string\" ? encodeUtf8(data) : data;\n\tconst hash = await globalThis.crypto.subtle.digest(\"SHA-1\", bytes);\n\treturn toHex(new Uint8Array(hash));\n}\n\n// ---------------------------------------------------------------------------\n// Compression\n// ---------------------------------------------------------------------------\n\n/**\n * Deflate compress via the CompressionStream Web API.\n * Falls back to throwing if CompressionStream is unavailable (very old runtimes).\n */\nexport async function deflate(\n\tdata: Uint8Array,\n): Promise<Uint8Array<ArrayBuffer>> {\n\tconst stream = new Blob([data])\n\t\t.stream()\n\t\t.pipeThrough(new CompressionStream(\"deflate\"));\n\treturn new Uint8Array(await new Response(stream).arrayBuffer());\n}\n\n// ---------------------------------------------------------------------------\n// Binary detection (replaces Buffer.includes(0) pattern)\n// ---------------------------------------------------------------------------\n\n/** Check if a Uint8Array contains a null byte. */\nexport function hasNullByte(data: Uint8Array): boolean {\n\treturn data.includes(0);\n}\n\n/**\n * Read a blob as text or binary metadata — the edge-compatible replacement\n * for the `Buffer.from(blob)` pattern used throughout diff.ts and history.ts.\n */\nexport function readBlobContent(blob: Uint8Array): {\n\tisBinary: boolean;\n\ttext: string;\n\tbytes: Uint8Array;\n} {\n\tconst isBinary = hasNullByte(blob);\n\treturn {\n\t\tisBinary,\n\t\ttext: isBinary ? \"\" : decodeUtf8(blob),\n\t\tbytes: blob,\n\t};\n}\n","/**\n * Git ref-name validation, mirroring isomorphic-git's own internal `isValidRef`\n * character-class rules (the check `git.branch` and top-level `git.writeRef`\n * run before touching disk).\n *\n * Several of isomorphic-git's OTHER ref-touching primitives — `git.commit`,\n * `git.merge`, `git.deleteBranch`, and top-level `git.resolveRef`/\n * `git.deleteRef` — do NOT run this check internally: they resolve straight\n * through `fs.write`/`fs.rm(join(gitdir, ref))` with no jail to the gitdir.\n * On a shared-storage server (many repos under one prefix or base directory),\n * every branch/ref name that originates from request input must be validated\n * against these predicates before it reaches any of those primitives —\n * otherwise a `\"../\"`-laden name lets a caller with write access to any single\n * repo read, corrupt, or delete another repo's ref/object files.\n */\n\nconst BAD_REF_COMPONENT =\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: control chars are exactly what git's own ref-name rules reject — this needs to match the same range.\n\t/(^|[/.])([/.]|$)|^@$|@\\{|[\\x00-\\x20\\x7f~^:?*[\\\\]|\\.lock(\\/|$)/;\n\nconst FULL_SHA_RE = /^[0-9a-f]{40}$/i;\n\n/** Validates a fully-qualified ref (must start with refs/heads/ or refs/tags/). */\nexport function isSafeFullRefName(ref: string): boolean {\n\tif (!ref.startsWith(\"refs/heads/\") && !ref.startsWith(\"refs/tags/\")) {\n\t\treturn false;\n\t}\n\treturn !BAD_REF_COMPONENT.test(ref);\n}\n\n/**\n * Validates a bare branch name (no refs/ prefix). Rejects anything that looks\n * like a full ref path — a name of `\"refs/heads/x\"` would otherwise sail\n * through unprefixed at call sites that build `refs/heads/${name}` themselves\n * (doubling the prefix into something that still resolves), or be used as-is\n * at call sites that pass a name already containing `\"refs/\"` straight\n * through. Also rejects 40-hex SHA-shaped values so a stored branch name can\n * never be ambiguous with a commit SHA at write time; use\n * {@link isSafeRefName} on read paths that accept both shapes.\n */\nexport function isSafeBranchName(name: string): boolean {\n\tif (!name || name.startsWith(\"refs/\") || name === \"HEAD\") return false;\n\tif (FULL_SHA_RE.test(name)) return false;\n\treturn !BAD_REF_COMPONENT.test(name);\n}\n\n/** True for a full 40-hex-char commit SHA — the shape {@link isSafeBranchName} deliberately rejects. */\nexport function isFullSha(value: string): boolean {\n\treturn FULL_SHA_RE.test(value);\n}\n\n/**\n * Validates a \"ref\" field that may name either a branch or a commit SHA it's\n * pinned to — the shape read-path route params take (permalinks, raw links).\n * Both shapes still go through the traversal check.\n */\nexport function isSafeRefName(value: string): boolean {\n\treturn isSafeBranchName(value) || isFullSha(value);\n}\n\n/**\n * Validates a repo-relative file path from request input: relative, no `..`\n * segments, no `.git/` prefix, no null bytes. Use this anywhere a path\n * segment comes straight off a URL or form field rather than re-deriving the\n * checks ad hoc.\n */\nexport function isSafeRepoPath(p: string): boolean {\n\tif (p.startsWith(\"/\")) return false;\n\tif (p.split(\"/\").some((segment) => segment === \"..\")) return false;\n\tif (/^\\.git(\\/|$)/i.test(p)) return false;\n\tif (p.includes(\"\\0\")) return false;\n\treturn true;\n}\n\n/**\n * Qualify a bare branch name to `refs/heads/<name>` before handing it to\n * isomorphic-git. `resolveRef`/`expand` try several candidate paths in\n * sequence for a bare name — `ref`, `refs/ref`, `refs/tags/ref`,\n * `refs/heads/ref`, … — missing (and, against object storage, paying a real\n * round trip for) the first three every time. For a branch-only ref model,\n * skip straight to the winner. Left untouched: already-qualified refs,\n * `\"HEAD\"` (its own first candidate, already optimal), and 40-hex oids\n * (resolved locally by isomorphic-git with no I/O at all).\n */\nexport function qualifyBranchRef(ref: string): string {\n\tif (ref.startsWith(\"refs/\") || ref === \"HEAD\" || FULL_SHA_RE.test(ref)) {\n\t\treturn ref;\n\t}\n\treturn `refs/heads/${ref}`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/git-errors.ts
|
|
2
|
+
var GitError = class extends Error {
|
|
3
|
+
statusCode;
|
|
4
|
+
retryable;
|
|
5
|
+
constructor(message, statusCode = 500, retryable = false) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = this.constructor.name;
|
|
8
|
+
this.statusCode = statusCode;
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
Error.captureStackTrace?.(this, this.constructor);
|
|
11
|
+
}
|
|
12
|
+
toJSON() {
|
|
13
|
+
return {
|
|
14
|
+
error: this.name,
|
|
15
|
+
message: this.message,
|
|
16
|
+
statusCode: this.statusCode,
|
|
17
|
+
retryable: this.retryable
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var GitPathNotFoundError = class extends GitError {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message, 404, false);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var GitObjectNotFoundError = class extends GitError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message, 404, false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var GitRefNotFoundError = class extends GitError {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message, 404, false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var GitRepositoryNotFoundError = class extends GitError {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message, 404, false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var GitConflictError = class extends GitError {
|
|
42
|
+
conflicts;
|
|
43
|
+
constructor(message, conflicts = []) {
|
|
44
|
+
super(message, 409, false);
|
|
45
|
+
this.conflicts = conflicts;
|
|
46
|
+
}
|
|
47
|
+
toJSON() {
|
|
48
|
+
return { ...super.toJSON(), conflicts: this.conflicts };
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var GitAuthenticationError = class extends GitError {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message, 401, false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var GitAuthorizationError = class extends GitError {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message, 403, false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var GitRateLimitError = class extends GitError {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message, 429, false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var GitInvalidRequestError = class extends GitError {
|
|
67
|
+
constructor(message) {
|
|
68
|
+
super(message, 400, false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
var GitProtocolError = class extends GitError {
|
|
72
|
+
constructor(message) {
|
|
73
|
+
super(message, 400, false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
function formatErrorResponse(error) {
|
|
77
|
+
if (error instanceof GitError) {
|
|
78
|
+
return {
|
|
79
|
+
status: error.statusCode,
|
|
80
|
+
body: error.toJSON(),
|
|
81
|
+
headers: error.statusCode === 401 ? { "WWW-Authenticate": 'Basic realm="Git Repository"' } : void 0
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (error instanceof Error) {
|
|
85
|
+
return {
|
|
86
|
+
status: 500,
|
|
87
|
+
body: {
|
|
88
|
+
error: "InternalServerError",
|
|
89
|
+
message: "An internal error occurred",
|
|
90
|
+
retryable: true
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
status: 500,
|
|
96
|
+
body: {
|
|
97
|
+
error: "UnknownError",
|
|
98
|
+
message: "An unknown error occurred",
|
|
99
|
+
retryable: true
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
GitError,
|
|
106
|
+
GitPathNotFoundError,
|
|
107
|
+
GitObjectNotFoundError,
|
|
108
|
+
GitRefNotFoundError,
|
|
109
|
+
GitRepositoryNotFoundError,
|
|
110
|
+
GitConflictError,
|
|
111
|
+
GitAuthenticationError,
|
|
112
|
+
GitAuthorizationError,
|
|
113
|
+
GitRateLimitError,
|
|
114
|
+
GitInvalidRequestError,
|
|
115
|
+
GitProtocolError,
|
|
116
|
+
formatErrorResponse
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=chunk-T5NHPY7U.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/git-errors.ts"],"sourcesContent":["/**\n * Git-server error types carrying an HTTP status and a retryability flag, so\n * transport layers can map internal failures to responses without inspecting\n * messages. Extend {@link GitError} for app-specific cases (storage backends,\n * quota, …) and {@link formatErrorResponse} keeps working for them.\n */\nexport class GitError extends Error {\n\tstatusCode: number;\n\tretryable: boolean;\n\n\tconstructor(message: string, statusCode = 500, retryable = false) {\n\t\tsuper(message);\n\t\tthis.name = this.constructor.name;\n\t\tthis.statusCode = statusCode;\n\t\tthis.retryable = retryable;\n\t\tError.captureStackTrace?.(this, this.constructor);\n\t}\n\n\ttoJSON(): Record<string, unknown> {\n\t\treturn {\n\t\t\terror: this.name,\n\t\t\tmessage: this.message,\n\t\t\tstatusCode: this.statusCode,\n\t\t\tretryable: this.retryable,\n\t\t};\n\t}\n}\n\n/** A file/directory path not found within a tree (404). */\nexport class GitPathNotFoundError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 404, false);\n\t}\n}\n\n/** A git object not found (404). */\nexport class GitObjectNotFoundError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 404, false);\n\t}\n}\n\n/** A ref (branch/tag) not found (404). */\nexport class GitRefNotFoundError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 404, false);\n\t}\n}\n\n/** The repository itself not found (404). */\nexport class GitRepositoryNotFoundError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 404, false);\n\t}\n}\n\nexport interface MergeConflictDetail {\n\tfile: string;\n\tbaseLines?: string[];\n\tsourceLines?: string[];\n\ttargetLines?: string[];\n}\n\n/** A merge conflict (409), carrying per-file conflict detail. */\nexport class GitConflictError extends GitError {\n\tconflicts: MergeConflictDetail[];\n\n\tconstructor(message: string, conflicts: MergeConflictDetail[] = []) {\n\t\tsuper(message, 409, false);\n\t\tthis.conflicts = conflicts;\n\t}\n\n\toverride toJSON(): Record<string, unknown> {\n\t\treturn { ...super.toJSON(), conflicts: this.conflicts };\n\t}\n}\n\n/** Authentication failed (401). */\nexport class GitAuthenticationError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 401, false);\n\t}\n}\n\n/** Authorization failed (403). */\nexport class GitAuthorizationError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 403, false);\n\t}\n}\n\n/** Too many failed attempts (429). */\nexport class GitRateLimitError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 429, false);\n\t}\n}\n\n/** Malformed request (400). */\nexport class GitInvalidRequestError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 400, false);\n\t}\n}\n\n/** Git wire-protocol violation (400). */\nexport class GitProtocolError extends GitError {\n\tconstructor(message: string) {\n\t\tsuper(message, 400, false);\n\t}\n}\n\n/**\n * Map any error to an HTTP response shape. 401s carry the WWW-Authenticate\n * header git clients need before they will prompt for credentials. Non-GitError\n * failures are masked as opaque 500s — internal messages don't leak.\n */\nexport function formatErrorResponse(error: unknown): {\n\tstatus: number;\n\tbody: Record<string, unknown>;\n\theaders?: Record<string, string>;\n} {\n\tif (error instanceof GitError) {\n\t\treturn {\n\t\t\tstatus: error.statusCode,\n\t\t\tbody: error.toJSON(),\n\t\t\theaders:\n\t\t\t\terror.statusCode === 401\n\t\t\t\t\t? { \"WWW-Authenticate\": 'Basic realm=\"Git Repository\"' }\n\t\t\t\t\t: undefined,\n\t\t};\n\t}\n\n\tif (error instanceof Error) {\n\t\treturn {\n\t\t\tstatus: 500,\n\t\t\tbody: {\n\t\t\t\terror: \"InternalServerError\",\n\t\t\t\tmessage: \"An internal error occurred\",\n\t\t\t\tretryable: true,\n\t\t\t},\n\t\t};\n\t}\n\n\treturn {\n\t\tstatus: 500,\n\t\tbody: {\n\t\t\terror: \"UnknownError\",\n\t\t\tmessage: \"An unknown error occurred\",\n\t\t\tretryable: true,\n\t\t},\n\t};\n}\n"],"mappings":";AAMO,IAAM,WAAN,cAAuB,MAAM;AAAA,EACnC;AAAA,EACA;AAAA,EAEA,YAAY,SAAiB,aAAa,KAAK,YAAY,OAAO;AACjE,UAAM,OAAO;AACb,SAAK,OAAO,KAAK,YAAY;AAC7B,SAAK,aAAa;AAClB,SAAK,YAAY;AACjB,UAAM,oBAAoB,MAAM,KAAK,WAAW;AAAA,EACjD;AAAA,EAEA,SAAkC;AACjC,WAAO;AAAA,MACN,OAAO,KAAK;AAAA,MACZ,SAAS,KAAK;AAAA,MACd,YAAY,KAAK;AAAA,MACjB,WAAW,KAAK;AAAA,IACjB;AAAA,EACD;AACD;AAGO,IAAM,uBAAN,cAAmC,SAAS;AAAA,EAClD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,yBAAN,cAAqC,SAAS;AAAA,EACpD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,sBAAN,cAAkC,SAAS;AAAA,EACjD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,6BAAN,cAAyC,SAAS;AAAA,EACxD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAUO,IAAM,mBAAN,cAA+B,SAAS;AAAA,EAC9C;AAAA,EAEA,YAAY,SAAiB,YAAmC,CAAC,GAAG;AACnE,UAAM,SAAS,KAAK,KAAK;AACzB,SAAK,YAAY;AAAA,EAClB;AAAA,EAES,SAAkC;AAC1C,WAAO,EAAE,GAAG,MAAM,OAAO,GAAG,WAAW,KAAK,UAAU;AAAA,EACvD;AACD;AAGO,IAAM,yBAAN,cAAqC,SAAS;AAAA,EACpD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,wBAAN,cAAoC,SAAS;AAAA,EACnD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,oBAAN,cAAgC,SAAS;AAAA,EAC/C,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,yBAAN,cAAqC,SAAS;AAAA,EACpD,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAGO,IAAM,mBAAN,cAA+B,SAAS;AAAA,EAC9C,YAAY,SAAiB;AAC5B,UAAM,SAAS,KAAK,KAAK;AAAA,EAC1B;AACD;AAOO,SAAS,oBAAoB,OAIlC;AACD,MAAI,iBAAiB,UAAU;AAC9B,WAAO;AAAA,MACN,QAAQ,MAAM;AAAA,MACd,MAAM,MAAM,OAAO;AAAA,MACnB,SACC,MAAM,eAAe,MAClB,EAAE,oBAAoB,+BAA+B,IACrD;AAAA,IACL;AAAA,EACD;AAEA,MAAI,iBAAiB,OAAO;AAC3B,WAAO;AAAA,MACN,QAAQ;AAAA,MACR,MAAM;AAAA,QACL,OAAO;AAAA,QACP,SAAS;AAAA,QACT,WAAW;AAAA,MACZ;AAAA,IACD;AAAA,EACD;AAEA,SAAO;AAAA,IACN,QAAQ;AAAA,IACR,MAAM;AAAA,MACL,OAAO;AAAA,MACP,SAAS;AAAA,MACT,WAAW;AAAA,IACZ;AAAA,EACD;AACD;","names":[]}
|