| 1 | --- |
| 2 | title: Repository |
| 3 | description: One Durable Object per repository, behind the Git.RepoStore service. Provide it, call it from your own routes, compose operations through Git.Engine, or implement its shape over another store. |
| 4 | --- |
| 5 | |
| 6 | The examples use `Authentication` from |
| 7 | [Getting Started](/git/getting-started): the application's request middleware. |
| 8 | |
| 9 | `Git.ReposDurableObject` hosts one Durable Object per repository and |
| 10 | provides `Git.RepoStore`, the service every route reaches it through: |
| 11 | |
| 12 | ```typescript |
| 13 | const GitLive = Git.ApiLive.pipe( |
| 14 | Layer.provide(Git.ApiHandlersLive), |
| 15 | Layer.provide(Authentication.layer), |
| 16 | Layer.provide(Git.ReposDurableObject), |
| 17 | // ReposDurableObject requires: |
| 18 | Layer.provide(Git.HasherInline), // push verification |
| 19 | Layer.provide(Git.BlobStoreR2(GitObjects)), // packs, bundles, spilled pushes |
| 20 | Layer.provide(Git.RegistryDurableObject), // owner/name → repoId |
| 21 | ); |
| 22 | ``` |
| 23 | |
| 24 | The object is keyed by the repository's id, a ULID the |
| 25 | [Registry](/git/blocks/registry) hands out, so a rename never moves |
| 26 | data. Its SQLite holds everything that has to be consistent: refs, the |
| 27 | object index, hot object bytes, the commit graph, and pull requests. A |
| 28 | push is a compare-and-swap on a ref inside one transaction. |
| 29 | |
| 30 | ## The service |
| 31 | |
| 32 | ```typescript |
| 33 | const repos = yield* Git.RepoStore; |
| 34 | const repo = repos.getByName(repoId); // a typed RPC stub over the Durable Object |
| 35 | ``` |
| 36 | |
| 37 | The stub is `Git.GitRepoShape`. It asks no questions about who is |
| 38 | calling; that was decided in front of the route: |
| 39 | |
| 40 | ```typescript |
| 41 | interface GitRepoShape { |
| 42 | // lifecycle |
| 43 | initRepo(input): Effect<InitRepoResult, StoreError>; |
| 44 | startImport(input): Effect<InitRepoResult, StoreError>; |
| 45 | startFork(input): Effect<InitRepoResult, StoreError>; |
| 46 | startCompact(): Effect<void, RepoNotFound | StoreError>; |
| 47 | startPurge(): Effect<void, RepoNotFound | StoreError>; |
| 48 | |
| 49 | // metadata |
| 50 | getRepoMeta(): Effect<RepoMetaData, RepoNotFound | StoreError>; |
| 51 | readMeta(): Effect<RepoMetaData, RepoNotFound | StoreError>; |
| 52 | updateRepoMeta(input): Effect<RepoMetaData, RepoNotFound | RefNotFound | StoreError>; |
| 53 | |
| 54 | // refs |
| 55 | listRefs(input): Effect<RefsPage, RepoNotFound | StoreError>; |
| 56 | getRef(name): Effect<RefData, RepoNotFound | RefNotFound | StoreError>; |
| 57 | updateRef(input): Effect<RefData, RefConflict | ObjectNotFound | ReadOnlyRepo | …>; |
| 58 | removeRef(input): Effect<void, RefNotFound | RefConflict | ReadOnlyRepo | …>; |
| 59 | |
| 60 | // objects |
| 61 | readObject(input): Effect<ObjectData, ObjectNotFound | WrongObjectType | …>; |
| 62 | readCommitLog(input): Effect<CommitLogPage, RefNotFound | …>; |
| 63 | readCommitDiff(input): Effect<CommitDiffData, ObjectNotFound | …>; |
| 64 | ``` |
| 65 | |
| 66 | Every error is a tagged class the stub reconstructs across the RPC |
| 67 | boundary, so `Effect.catchTag("RefConflict", …)` works in the Worker. |
| 68 | |
| 69 | ## Call it from a route of your own |
| 70 | |
| 71 | Resolve the id through the registry, then talk to the object. A route |
| 72 | that answers the default branch's tip: |
| 73 | |
| 74 | ```typescript |
| 75 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 76 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 77 | import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; |
| 78 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 79 | |
| 80 | export const Tip = HttpApiEndpoint.get("tip", "/api/v1/repos/:owner/:repo/tip", { |
| 81 | params: Git.RepoPath, |
| 82 | success: Git.Ref, |
| 83 | error: [Git.RepoNotFound, Git.RefNotFound], |
| 84 | }); |
| 85 | |
| 86 | export class AppRoutes extends HttpApiGroup.make("app").add(Tip) {} |
| 87 | export class AppApi extends HttpApi.make("app").add(AppRoutes) {} |
| 88 | |
| 89 | export const AppRoutesLive = HttpApiBuilder.group(AppApi, "app", (h) => |
| 90 | Effect.gen(function* () { |
| 91 | const registry = yield* Git.RegistryStore; |
| 92 | const repos = yield* Git.RepoStore; |
| 93 | return h.handle("tip", Effect.fn(function* ({ params }) { |
| 94 | const entry = yield* registry |
| 95 | .resolve(params.owner, params.repo) |
| 96 | .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error))); |
| 97 | if (entry === undefined) return yield* new Git.RepoNotFound(params); |
| 98 | const repo = repos.getByName(entry.repoId); |
| 99 | const meta = yield* repo |
| 100 | .getRepoMeta() |
| 101 | .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error))); |
| 102 | const ref = yield* repo |
| 103 | .getRef(`refs/heads/${meta.defaultBranch}`) |
| 104 | .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error))); |
| 105 | return new Git.Ref({ name: ref.name, oid: ref.oid as Git.Oid }); |
| 106 | })); |
| 107 | }), |
| 108 | ); |
| 109 | ``` |
| 110 | |
| 111 | Provide `AppRoutesLive` to `HttpApiBuilder.layer(AppApi)`, then merge that route layer beside `Git.ApiLive`. |
| 112 | `Git.ApiLive` registers the Git groups. |
| 113 | [HTTP routes](/git/blocks/server#add-git-to-your-application) shows the full assembly. The registry |
| 114 | and repository clients are resolved when the group builds; the handler |
| 115 | runs once per request behind the route middleware. |
| 116 | |
| 117 | ## Compose application operations |
| 118 | |
| 119 | Use `Git.Engine` to work with repository data from your own handler: |
| 120 | |
| 121 | ```typescript |
| 122 | const git = yield* Git.Engine; |
| 123 | const repo = yield* git.repositories.get({ owner: "alice", repo: "demo" }); |
| 124 | const prepared = yield* git.prepareRefRemoval(repo, { ref: "refs/heads/feature" }); |
| 125 | yield* checkRefChanges(repo, prepared.updates); |
| 126 | yield* prepared.commit; |
| 127 | yield* Effect.log("branch removed"); |
| 128 | ``` |
| 129 | |
| 130 | Run this inside `Effect.scoped`. `checkRefChanges` is your application function; |
| 131 | it can use your request user and database. The prepared operation pins the ref |
| 132 | value and can commit only once before the scope closes. Use the same policy from |
| 133 | push and merge handlers. [Engine operations](/git/blocks/engine) describes the APIs. |
| 134 | |
| 135 | `RepoStore` remains the lower storage contract for implementing another backend. |
| 136 | Its raw RPC methods are infrastructure primitives, not an authorization boundary. |
| 137 | |
| 138 | ## Implement your own |
| 139 | |
| 140 | The contract is `Git.GitRepoShape` behind `Git.RepoStore`. A different |
| 141 | store provides the same service, and nothing else in the graph changes: |
| 142 | |
| 143 | ```typescript |
| 144 | export const ReposMine = Layer.effect( |
| 145 | Git.RepoStore, |
| 146 | Effect.gen(function* () { |
| 147 | // return { getByName: (repoId) => GitRepoShape } |
| 148 | }), |
| 149 | ); |
| 150 | ``` |
| 151 | |
| 152 | The registry and the blob store are the seams it can reuse. The wire protocol (`fetch`) and the push pipeline are the parts |
| 153 | that assume the object's transactional storage, so a store without a |
| 154 | per-repository serialization point starts there. |
| 155 | |
| 156 | ## Where the bytes are |
| 157 | |
| 158 | | Objects | Live in | Because | |
| 159 | | --- | --- | --- | |
| 160 | | commits, trees, tags | SQLite rows, deflated | the tree walk behind every fetch runs on SQLite | |
| 161 | | fresh blobs | SQLite rows, deflated | a push commits as one transaction | |
| 162 | | oversize blobs | the blob store, one index row here | never buffered in the object | |
| 163 | | compacted blobs | immutable packs in the blob store | a busy repository's object stays small | |
| 164 | | clone bundles | the blob store, keyed by a refs hash | the Worker streams them, no pack byte transits the object | |
| 165 | |
| 166 | A full clone whose refs have not moved is one object in the blob |
| 167 | store; the Worker pipes it. Otherwise the clone is assembled from rows |
| 168 | and packs, and a fresh bundle is scheduled. Objects are stored |
| 169 | deflated, so a pack is concatenation plus a checksum. |
| 170 | |
| 171 | ## Jobs |
| 172 | |
| 173 | Maintenance runs on the object's alarms, armed by the work itself. |
| 174 | Every job is idempotent and resumable: |
| 175 | |
| 176 | | Job | Armed by | Does | |
| 177 | | --- | --- | --- | |
| 178 | | compaction | a push crossing a size threshold, or `startCompact` | rewrites loose blobs into a pack | |
| 179 | | bundling | refs moving | cuts a fresh clone bundle | |
| 180 | | purge | `startPurge` | drains rows and blobs, then frees the name | |
| 181 | | fork, import | `startFork`, `startImport` | copies from a sibling object or fetches from a remote | |
| 182 | |
| 183 | ```sh |
| 184 | curl -u "x:$GIT_SECRET" -X POST "$HOST/api/v1/repos/acme/web/compact" |
| 185 | ``` |
| 186 | |
| 187 | ## What it reports |
| 188 | |
| 189 | ```typescript |
| 190 | const repo = yield* client.repos.get({ params: { owner: "acme", repo: "web" } }); |
| 191 | |
| 192 | repo.objects; // { loose, resident, packed, r2, bytes } |
| 193 | repo.lastPush; // { ingestMs, stageMs, connectivityMs, finalizeMs, totalMs } |
| 194 | ``` |
| 195 | |
| 196 | Listings report zeros for both, so listing a thousand repositories does |
| 197 | not wake a thousand objects. [Scaling](/git/recipes/scaling) has the |
| 198 | throughput numbers behind the layout above. |