@springbrand/space 0.1.0-alpha.1

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * FileInfo adapter for the plain `FileSystem` interface.
3
+ *
4
+ * The SpaceDO overlay used to be a `Workspace`, whose listing methods returned
5
+ * rich `FileInfo` records (mimeType, updatedAt, size). The in-memory overlay
6
+ * only implements the minimal `FileSystem` contract (`glob` -> string[], `stat`
7
+ * -> `FsStat`), so these helpers rebuild `FileInfo` from a path + `FsStat`:
8
+ * MIME is inferred from the extension and timestamps come from `mtime`.
9
+ */
10
+ import type { FileSystem, FileInfo, FsStat } from "@cloudflare/shell"
11
+
12
+ const MIME_BY_EXT: Record<string, string> = {
13
+ html: "text/html",
14
+ htm: "text/html",
15
+ css: "text/css",
16
+ js: "text/javascript",
17
+ mjs: "text/javascript",
18
+ cjs: "text/javascript",
19
+ jsx: "text/javascript",
20
+ ts: "text/typescript",
21
+ tsx: "text/typescript",
22
+ json: "application/json",
23
+ jsonc: "application/json",
24
+ map: "application/json",
25
+ txt: "text/plain",
26
+ md: "text/markdown",
27
+ csv: "text/csv",
28
+ xml: "application/xml",
29
+ svg: "image/svg+xml",
30
+ png: "image/png",
31
+ jpg: "image/jpeg",
32
+ jpeg: "image/jpeg",
33
+ gif: "image/gif",
34
+ webp: "image/webp",
35
+ avif: "image/avif",
36
+ ico: "image/x-icon",
37
+ bmp: "image/bmp",
38
+ woff: "font/woff",
39
+ woff2: "font/woff2",
40
+ ttf: "font/ttf",
41
+ otf: "font/otf",
42
+ eot: "application/vnd.ms-fontobject",
43
+ wasm: "application/wasm",
44
+ pdf: "application/pdf",
45
+ zip: "application/zip",
46
+ webmanifest: "application/manifest+json",
47
+ mp3: "audio/mpeg",
48
+ mp4: "video/mp4",
49
+ webm: "video/webm",
50
+ ogg: "audio/ogg",
51
+ }
52
+
53
+ const DEFAULT_MIME = "application/octet-stream"
54
+ const DIR_MIME = "inode/directory"
55
+
56
+ function basename(path: string): string {
57
+ const i = path.lastIndexOf("/")
58
+ return i >= 0 ? path.slice(i + 1) : path
59
+ }
60
+
61
+ /** Infer a MIME type from a path's extension, defaulting to octet-stream. */
62
+ export function inferMimeType(path: string): string {
63
+ const name = basename(path)
64
+ const dot = name.lastIndexOf(".")
65
+ if (dot <= 0) return DEFAULT_MIME
66
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? DEFAULT_MIME
67
+ }
68
+
69
+ /** Build a `FileInfo` from a path and its `FsStat`. */
70
+ export function toFileInfo(path: string, stat: FsStat): FileInfo {
71
+ const ms = stat.mtime.getTime()
72
+ return {
73
+ path,
74
+ name: basename(path),
75
+ type: stat.type,
76
+ mimeType: stat.type === "file" ? inferMimeType(path) : DIR_MIME,
77
+ size: stat.size,
78
+ createdAt: ms,
79
+ updatedAt: ms,
80
+ }
81
+ }
82
+
83
+ /** `glob` + `stat` each match into a `FileInfo[]` (skips paths that vanish). */
84
+ export async function globInfos(fs: FileSystem, pattern: string): Promise<FileInfo[]> {
85
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`
86
+ const paths = await fs.glob(normalizedPattern)
87
+ const infos: FileInfo[] = []
88
+ for (const p of paths) {
89
+ try {
90
+ infos.push(toFileInfo(p, await fs.stat(p)))
91
+ } catch {
92
+ // Raced against a concurrent delete, or unreadable — skip.
93
+ }
94
+ }
95
+ return infos
96
+ }
97
+
98
+ /** List a directory's immediate children as `FileInfo[]`, with offset/limit. */
99
+ export async function readDirInfos(
100
+ fs: FileSystem,
101
+ dir?: string,
102
+ opts?: { limit?: number; offset?: number },
103
+ ): Promise<FileInfo[]> {
104
+ const base = !dir || dir === "/" ? "/" : dir.startsWith("/") ? dir : "/" + dir
105
+ const entries = await fs.readdirWithFileTypes(base)
106
+ const infos: FileInfo[] = []
107
+ for (const e of entries) {
108
+ const full = base === "/" ? "/" + e.name : base + "/" + e.name
109
+ try {
110
+ infos.push(toFileInfo(full, await fs.stat(full)))
111
+ } catch {
112
+ // skip unreadable entry
113
+ }
114
+ }
115
+ const offset = opts?.offset ?? 0
116
+ return opts?.limit !== undefined ? infos.slice(offset, offset + opts.limit) : infos.slice(offset)
117
+ }
@@ -0,0 +1,235 @@
1
+ import {
2
+ createWorkspaceStateBackend,
3
+ FileSystemStateBackend,
4
+ InMemoryFs,
5
+ Workspace,
6
+ WorkspaceFileSystem,
7
+ type FileSystem,
8
+ } from "@cloudflare/shell"
9
+ import { createGit, type Git } from "@cloudflare/shell/git"
10
+ import type { Env } from "../env"
11
+ import { ArtifactsFileSystem } from "./artifacts-fs"
12
+ import { ArtifactsSync, type ArtifactsRemoteStore } from "./artifacts-sync"
13
+ import { CheckpointStore } from "./checkpoint"
14
+ import { createArtifactsBaseSource } from "./git-objects"
15
+
16
+ export const ARTIFACTS_REMOTE_URL_KEY = "artifacts:remoteUrl"
17
+ export const SPACE_FS_BACKEND_KEY = "space:fsBackend"
18
+ const ARTIFACTS_BASE_BRANCH = "main"
19
+ const ARTIFACTS_INIT_TIMEOUT_MS = 10_000
20
+ const CHECKPOINT_DEBOUNCE_MS = 2_000
21
+
22
+ export type SpaceFsBackendMode = "artifacts" | "sql"
23
+
24
+ export async function resolveSpaceFsBackendMode(
25
+ storage: Pick<DurableObjectStorage, "get" | "put" | "sql">,
26
+ env: Pick<Env, "ENABLE_ARTIFACTS" | "ARTIFACTS">,
27
+ ): Promise<SpaceFsBackendMode> {
28
+ let mode = await storage.get<SpaceFsBackendMode>(SPACE_FS_BACKEND_KEY)
29
+ if (mode) return mode
30
+
31
+ const priorArtifacts = (await storage.get<string>(ARTIFACTS_REMOTE_URL_KEY)) !== undefined
32
+ let hasCheckpoint = false
33
+ try {
34
+ hasCheckpoint = storage.sql.exec("SELECT 1 FROM space_checkpoint LIMIT 1").toArray().length > 0
35
+ } catch {}
36
+ const artifactsEnabled = env.ENABLE_ARTIFACTS === "true" && !!env.ARTIFACTS
37
+ mode = priorArtifacts || hasCheckpoint || artifactsEnabled ? "artifacts" : "sql"
38
+ await storage.put(SPACE_FS_BACKEND_KEY, mode)
39
+ return mode
40
+ }
41
+
42
+ export interface SpaceFsBackend {
43
+ readonly fs: FileSystem
44
+ readonly overlay: FileSystem
45
+ readonly git: Git
46
+ readonly stateBackend: FileSystemStateBackend
47
+ ready(): Promise<void>
48
+ hydrate(path: string): Promise<void>
49
+ materializeAll(): Promise<void>
50
+ flushCheckpoint(): Promise<void>
51
+ push(branch: string): Promise<boolean>
52
+ fetch(branch: string): Promise<void>
53
+ }
54
+
55
+ function isReservedPath(path: string): boolean {
56
+ return path === "/.git" || path.startsWith("/.git/") || path === "/.afs" || path.startsWith("/.afs/")
57
+ }
58
+
59
+ async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
60
+ let timeout: ReturnType<typeof setTimeout> | undefined
61
+ try {
62
+ return await Promise.race([
63
+ operation,
64
+ new Promise<never>((_, reject) => {
65
+ timeout = setTimeout(() => reject(new Error("Operation timed out")), timeoutMs)
66
+ }),
67
+ ])
68
+ } finally {
69
+ if (timeout) clearTimeout(timeout)
70
+ }
71
+ }
72
+
73
+ export class ArtifactsBackend implements SpaceFsBackend {
74
+ readonly overlay: FileSystem
75
+ readonly fs: FileSystem
76
+ readonly git: Git
77
+ readonly stateBackend: FileSystemStateBackend
78
+ private readonly remoteStore: ArtifactsRemoteStore
79
+ private readonly checkpointStore: CheckpointStore
80
+ private readonly checkpointDirty = new Set<string>()
81
+ private checkpointTimer: ReturnType<typeof setTimeout> | null = null
82
+ private artifactsSync?: ArtifactsSync
83
+
84
+ constructor(
85
+ ctx: DurableObjectState,
86
+ private readonly env: Env,
87
+ private readonly repoName: string,
88
+ ) {
89
+ if (!env.ARTIFACTS) throw new Error("SpaceDO is pinned to Artifacts but the ARTIFACTS binding is unavailable")
90
+
91
+ const overlay = new InMemoryFs()
92
+ this.overlay = overlay
93
+ this.remoteStore = {
94
+ read: () => ctx.storage.get<string>(ARTIFACTS_REMOTE_URL_KEY).then((value) => value ?? null),
95
+ write: (url) => ctx.storage.put(ARTIFACTS_REMOTE_URL_KEY, url).then(() => undefined),
96
+ }
97
+ const fetchSync = new ArtifactsSync(env.ARTIFACTS, createGit(overlay), repoName, this.remoteStore)
98
+ const source = createArtifactsBaseSource({
99
+ overlay,
100
+ branch: ARTIFACTS_BASE_BRANCH,
101
+ hasRemote: async () => (await this.remoteStore.read()) !== null,
102
+ fetchBranch: async () => {
103
+ try {
104
+ return await withTimeout(fetchSync.fetch(ARTIFACTS_BASE_BRANCH), ARTIFACTS_INIT_TIMEOUT_MS)
105
+ } catch {
106
+ return false
107
+ }
108
+ },
109
+ })
110
+ this.checkpointStore = new CheckpointStore(ctx.storage)
111
+ this.fs = new ArtifactsFileSystem(overlay, {
112
+ source,
113
+ branch: ARTIFACTS_BASE_BRANCH,
114
+ checkpoint: this.checkpointStore,
115
+ onChange: (path) => this.noteCheckpointDirty(path),
116
+ })
117
+ this.git = createGit(this.fs)
118
+ this.stateBackend = new FileSystemStateBackend(overlay)
119
+ }
120
+
121
+ async ready(): Promise<void> {
122
+ try {
123
+ await (this.fs as ArtifactsFileSystem).ready()
124
+ } catch {
125
+ // Base fetch failed this time — proceed with whatever is local.
126
+ }
127
+ }
128
+
129
+ async hydrate(path: string): Promise<void> {
130
+ await (this.fs as ArtifactsFileSystem).hydrate(path)
131
+ }
132
+
133
+ async materializeAll(): Promise<void> {
134
+ await (this.fs as ArtifactsFileSystem).whenFullyMaterialized()
135
+ }
136
+
137
+ async flushCheckpoint(): Promise<void> {
138
+ if (this.checkpointDirty.size === 0) return
139
+ const paths = [...this.checkpointDirty]
140
+ this.checkpointDirty.clear()
141
+ for (const path of paths) {
142
+ try {
143
+ if (await this.overlay.exists(path)) {
144
+ const stat = await this.overlay.stat(path)
145
+ if (stat.type !== "directory") this.checkpointStore.save(path, await this.overlay.readFileBytes(path))
146
+ } else {
147
+ this.checkpointStore.save(path, null)
148
+ }
149
+ } catch (error) {
150
+ console.warn(`Checkpoint flush failed for ${path}`, error)
151
+ }
152
+ }
153
+ }
154
+
155
+ async push(branch: string): Promise<boolean> {
156
+ const pushed = await this.getArtifactsSync().push(branch)
157
+ if (pushed) this.resetCheckpointAfterPush()
158
+ return pushed
159
+ }
160
+
161
+ async fetch(branch: string): Promise<void> {
162
+ await this.getArtifactsSync().fetch(branch)
163
+ }
164
+
165
+ private noteCheckpointDirty(path: string): void {
166
+ if (isReservedPath(path)) return
167
+ this.checkpointDirty.add(path)
168
+ if (this.checkpointTimer !== null) return
169
+ this.checkpointTimer = setTimeout(() => {
170
+ this.checkpointTimer = null
171
+ void this.flushCheckpoint()
172
+ }, CHECKPOINT_DEBOUNCE_MS)
173
+ }
174
+
175
+ private resetCheckpointAfterPush(): void {
176
+ try {
177
+ this.checkpointStore.clear()
178
+ void this.flushCheckpoint()
179
+ } catch (error) {
180
+ console.warn("Checkpoint reset after push failed", error)
181
+ }
182
+ }
183
+
184
+ private getArtifactsSync(): ArtifactsSync {
185
+ if (this.artifactsSync) return this.artifactsSync
186
+ this.artifactsSync = new ArtifactsSync(this.requireArtifacts(), this.git, this.repoName, this.remoteStore)
187
+ return this.artifactsSync
188
+ }
189
+
190
+ private requireArtifacts(): Artifacts {
191
+ const artifacts = this.env.ARTIFACTS
192
+ if (!artifacts) throw new Error("SpaceDO is pinned to Artifacts but the ARTIFACTS binding is unavailable")
193
+ return artifacts
194
+ }
195
+ }
196
+
197
+ /** Where a Space's spilled bytes live, kept apart from Attachment Content. */
198
+ export function spaceR2Prefix(spaceId: string): string {
199
+ return `spaces/v1/${spaceId}`
200
+ }
201
+
202
+ export class SqlBackend implements SpaceFsBackend {
203
+ readonly overlay: FileSystem
204
+ readonly fs: FileSystem
205
+ readonly git: Git
206
+ readonly stateBackend: FileSystemStateBackend
207
+ readonly workspace: Workspace
208
+
209
+ /**
210
+ * Large files spill to R2 rather than to the DO's own SQLite. A Space holds
211
+ * spreadsheets, PDFs and images, any one of which can exceed what a SQLite
212
+ * row will hold — and a Space that fills its DO storage stops accepting
213
+ * writes for every other file too.
214
+ */
215
+ constructor(ctx: DurableObjectState, repoName: string, r2?: R2Bucket) {
216
+ const workspace = new Workspace({
217
+ sql: ctx.storage.sql,
218
+ name: () => repoName,
219
+ ...(r2 ? { r2, r2Prefix: spaceR2Prefix(repoName) } : {}),
220
+ })
221
+ this.workspace = workspace
222
+ const fs = new WorkspaceFileSystem(workspace)
223
+ this.overlay = fs
224
+ this.fs = fs
225
+ this.git = createGit(fs)
226
+ this.stateBackend = createWorkspaceStateBackend(workspace)
227
+ }
228
+
229
+ async ready(): Promise<void> {}
230
+ async hydrate(_path: string): Promise<void> {}
231
+ async materializeAll(): Promise<void> {}
232
+ async flushCheckpoint(): Promise<void> {}
233
+ async push(_branch: string): Promise<boolean> { return false }
234
+ async fetch(_branch: string): Promise<void> {}
235
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Object-level git plumbing for `ArtifactsFileSystem`.
3
+ *
4
+ * The shell's `createGit` only exposes porcelain (commit/log/checkout/…), not
5
+ * object reads. `ArtifactsFileSystem` needs to (a) walk a fetched commit tree
6
+ * to build its base snapshot and (b) read individual blobs by oid to hydrate
7
+ * files on demand. This module wraps isomorphic-git's object APIs over a shell
8
+ * `FileSystem`, reusing the same fs adapter shape the shell uses so behavior
9
+ * (ENOENT dispatch, utf8/binary reads) matches `createGit`.
10
+ */
11
+ import * as git from "isomorphic-git"
12
+ import type { FileSystem } from "@cloudflare/shell"
13
+
14
+ /** Metadata for one file in the Artifacts base tree. */
15
+ export interface BaseEntry {
16
+ oid: string
17
+ mode: number
18
+ /** Byte size, or `undefined` until first computed (lazily, on demand). */
19
+ size?: number
20
+ }
21
+
22
+ /** A loaded base tree: the commit it came from plus its files. */
23
+ export interface BaseSnapshot {
24
+ head: string
25
+ files: Map<string, BaseEntry>
26
+ }
27
+
28
+ export interface BaseSnapshotSource {
29
+ /**
30
+ * The base tree (head commit + absolute-path -> entry map), or `null` when no
31
+ * base is available (the FileSystem then behaves as a plain overlay).
32
+ * Implementations must ensure blobs are locally readable via `readBlob`.
33
+ *
34
+ * `null` means "legitimately empty" (no remote repo/branch yet). A transient
35
+ * failure (network blip, fetch timeout) must THROW instead, so callers can
36
+ * retry on a later access rather than caching an empty base forever.
37
+ */
38
+ loadSnapshot(): Promise<BaseSnapshot | null>
39
+ /** Raw bytes of a blob by oid (from the local object store). */
40
+ readBlob(oid: string): Promise<Uint8Array>
41
+ }
42
+
43
+ /** Node `fs.Stats`-shaped object that isomorphic-git expects from stat/lstat. */
44
+ class GitStat {
45
+ private readonly type: "file" | "directory" | "symlink"
46
+ readonly size: number
47
+ readonly mtimeMs: number
48
+ readonly ctimeMs: number
49
+ readonly ino = 0
50
+ readonly uid = 0
51
+ readonly gid = 0
52
+ readonly dev = 0
53
+ readonly mode: number
54
+ constructor(stat: { type: "file" | "directory" | "symlink"; size: number; mtime: Date; mode?: number }) {
55
+ this.type = stat.type
56
+ this.size = stat.size
57
+ this.mtimeMs = stat.mtime.getTime()
58
+ this.ctimeMs = this.mtimeMs
59
+ this.mode =
60
+ stat.mode ?? (this.type === "directory" ? 16877 : this.type === "symlink" ? 40960 : 33188)
61
+ }
62
+ isFile() {
63
+ return this.type === "file"
64
+ }
65
+ isDirectory() {
66
+ return this.type === "directory"
67
+ }
68
+ isSymbolicLink() {
69
+ return this.type === "symlink"
70
+ }
71
+ }
72
+
73
+ interface CodedError extends Error {
74
+ code: string
75
+ }
76
+
77
+ /** Ensure a thrown error carries a `.code` isomorphic-git can dispatch on. */
78
+ function fsError(path: string, cause: unknown): CodedError {
79
+ if (cause instanceof Error && "code" in cause && typeof (cause as CodedError).code === "string") {
80
+ return cause as CodedError
81
+ }
82
+ const err = new Error(cause instanceof Error ? cause.message : `ENOENT: ${path}`) as CodedError
83
+ err.code = "ENOENT"
84
+ return err
85
+ }
86
+
87
+ /**
88
+ * Build an isomorphic-git compatible fs (`{ promises: { … } }`) from a shell
89
+ * `FileSystem`. Mirrors `@cloudflare/shell`'s internal adapter so object reads
90
+ * behave exactly like `createGit`'s.
91
+ */
92
+ export function createGitFs(fs: FileSystem): git.FsClient {
93
+ return {
94
+ promises: {
95
+ async readFile(path: string, options?: string | { encoding?: string }) {
96
+ const encoding = typeof options === "string" ? options : options?.encoding
97
+ try {
98
+ if (encoding === "utf8" || encoding === "utf-8") return await fs.readFile(path)
99
+ return await fs.readFileBytes(path)
100
+ } catch (err) {
101
+ throw fsError(path, err)
102
+ }
103
+ },
104
+ async writeFile(path: string, data: string | Uint8Array) {
105
+ const parent = path.replace(/\/[^/]+$/, "")
106
+ if (parent && parent !== "/" && parent !== path) {
107
+ try {
108
+ await fs.mkdir(parent, { recursive: true })
109
+ } catch {
110
+ // parent may already exist
111
+ }
112
+ }
113
+ if (typeof data === "string") await fs.writeFile(path, data)
114
+ else await fs.writeFileBytes(path, data)
115
+ },
116
+ async unlink(path: string) {
117
+ try {
118
+ await fs.rm(path)
119
+ } catch (err) {
120
+ throw fsError(path, err)
121
+ }
122
+ },
123
+ async readdir(path: string) {
124
+ return fs.readdir(path)
125
+ },
126
+ async mkdir(path: string, mode?: { recursive?: boolean }) {
127
+ const recursive = typeof mode === "object" ? Boolean(mode.recursive) : false
128
+ await fs.mkdir(path, { recursive })
129
+ },
130
+ async rmdir(path: string) {
131
+ await fs.rm(path)
132
+ },
133
+ async stat(path: string) {
134
+ try {
135
+ return new GitStat(await fs.stat(path))
136
+ } catch (err) {
137
+ throw fsError(path, err)
138
+ }
139
+ },
140
+ async lstat(path: string) {
141
+ try {
142
+ return new GitStat(await fs.lstat(path))
143
+ } catch (err) {
144
+ throw fsError(path, err)
145
+ }
146
+ },
147
+ async readlink(path: string) {
148
+ try {
149
+ return await fs.readlink(path)
150
+ } catch (err) {
151
+ throw fsError(path, err)
152
+ }
153
+ },
154
+ async symlink(target: string, path: string) {
155
+ await fs.symlink(target, path)
156
+ },
157
+ async chmod() {
158
+ // no-op: the Workspace FS does not track unix modes
159
+ },
160
+ },
161
+ } as git.FsClient
162
+ }
163
+
164
+ /** Resolve a ref (branch, remote-tracking ref, or oid) to a commit oid, or null. */
165
+ export async function resolveHead(fs: FileSystem, ref: string): Promise<string | null> {
166
+ try {
167
+ return await git.resolveRef({ fs: createGitFs(fs), dir: "/", ref })
168
+ } catch {
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Walk the tree of `commitOid`, returning every file as an absolute-path ->
175
+ * `BaseEntry` map. Does not read blob contents (fast, index-only); `size` is
176
+ * left undefined and computed lazily on demand.
177
+ */
178
+ export async function walkTreeFiles(
179
+ fs: FileSystem,
180
+ commitOid: string,
181
+ ): Promise<Map<string, BaseEntry>> {
182
+ const gitFs = createGitFs(fs)
183
+ const entries = (await git.walk({
184
+ fs: gitFs,
185
+ dir: "/",
186
+ trees: [git.TREE({ ref: commitOid })],
187
+ map: async (filepath, walkerEntries) => {
188
+ if (filepath === ".") return undefined
189
+ const entry = walkerEntries?.[0]
190
+ if (!entry) return undefined
191
+ if ((await entry.type()) !== "blob") return undefined
192
+ return {
193
+ path: "/" + filepath,
194
+ oid: await entry.oid(),
195
+ mode: await entry.mode(),
196
+ }
197
+ },
198
+ })) as Array<{ path: string; oid: string; mode: number }>
199
+
200
+ const map = new Map<string, BaseEntry>()
201
+ for (const e of entries) {
202
+ if (!e) continue
203
+ map.set(e.path, { oid: e.oid, mode: e.mode })
204
+ }
205
+ return map
206
+ }
207
+
208
+ /** Read a blob's bytes by oid from the local object store. */
209
+ export async function readBlobBytes(fs: FileSystem, oid: string): Promise<Uint8Array> {
210
+ const { blob } = await git.readBlob({ fs: createGitFs(fs), dir: "/", oid })
211
+ return blob
212
+ }
213
+
214
+ /**
215
+ * Point the local branch at the fetched head when it is unborn (the cold-start
216
+ * case). isomorphic-git's `fetch` only updates the remote-tracking ref, so
217
+ * without this the next commit after a restart would be a ROOT commit with no
218
+ * ancestry — and the mirrored `push(force)` would replace the remote branch,
219
+ * silently destroying all earlier history/restore points. An existing local
220
+ * branch is never clobbered: local commits must not be lost.
221
+ */
222
+ async function reconcileLocalBranch(
223
+ fs: FileSystem,
224
+ branch: string,
225
+ head: string,
226
+ ): Promise<void> {
227
+ if (await resolveHead(fs, `refs/heads/${branch}`)) return
228
+ await git.writeRef({
229
+ fs: createGitFs(fs),
230
+ dir: "/",
231
+ ref: `refs/heads/${branch}`,
232
+ value: head,
233
+ force: true,
234
+ })
235
+ }
236
+
237
+ /**
238
+ * Artifacts-backed `BaseSnapshotSource`: fetches the branch into the overlay's
239
+ * `.git` (via the injected `fetchBranch`, e.g. `ArtifactsSync.fetch`), then
240
+ * walks the fetched commit tree and serves blobs from the local object store.
241
+ */
242
+ export function createArtifactsBaseSource(opts: {
243
+ overlay: FileSystem
244
+ branch: string
245
+ /** True when a remote repo is known to exist (e.g. a persisted remote URL). */
246
+ hasRemote: () => Promise<boolean>
247
+ /** Populate the overlay `.git` with the branch's objects. Returns success. */
248
+ fetchBranch: () => Promise<boolean>
249
+ }): BaseSnapshotSource {
250
+ const { overlay, branch, hasRemote, fetchBranch } = opts
251
+ return {
252
+ async loadSnapshot() {
253
+ // No remote repo yet — a legitimately empty base (fresh app).
254
+ if (!(await hasRemote())) return null
255
+ // The repo exists but the fetch failed — transient. Throw so the caller
256
+ // retries on a later access instead of caching an empty base (which
257
+ // would hide every previously pushed file for this DO's lifetime).
258
+ if (!(await fetchBranch())) {
259
+ throw new Error(`Artifacts base fetch failed for branch "${branch}"`)
260
+ }
261
+ const head =
262
+ (await resolveHead(overlay, `refs/remotes/artifacts/${branch}`)) ??
263
+ (await resolveHead(overlay, branch))
264
+ // Repo exists but this branch has no commits yet — legitimately empty.
265
+ if (!head) return null
266
+ await reconcileLocalBranch(overlay, branch, head)
267
+ return { head, files: await walkTreeFiles(overlay, head) }
268
+ },
269
+ readBlob(oid) {
270
+ return readBlobBytes(overlay, oid)
271
+ },
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Drop the git index so the next status walk re-reads file contents.
277
+ *
278
+ * Git decides a file is unchanged when its size and modification time still
279
+ * match the index, and only hashes it otherwise. On a real filesystem that is
280
+ * a safe shortcut; here it is not. A Space's files live in memory or SQLite,
281
+ * where an agent can rewrite a file to different content of the same length
282
+ * within the same millisecond — and git would then see nothing to commit. The
283
+ * work would be in the Space but absent from every version of it.
284
+ *
285
+ * The index is pure cache: deleting it costs one re-hash of the tree and
286
+ * cannot lose anything.
287
+ */
288
+ async function discardIndexStatCache(fs: FileSystem): Promise<void> {
289
+ try {
290
+ await fs.rm("/.git/index", { force: true })
291
+ } catch {
292
+ // No index yet, or the backend refused; the status walk still works.
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Stage the whole working tree (like the shell's `git add .`, which also
298
+ * stages deletions via `statusMatrix`) while skipping reserved paths — the
299
+ * `.afs` bookkeeping file must never enter a commit, or it lands in the
300
+ * Artifacts base tree and pollutes every snapshot.
301
+ */
302
+ export async function stageWorkdir(
303
+ fs: FileSystem,
304
+ skip: (absPath: string) => boolean,
305
+ ): Promise<void> {
306
+ const gitFs = createGitFs(fs)
307
+ await discardIndexStatCache(fs)
308
+ const matrix = await git.statusMatrix({ fs: gitFs, dir: "/" })
309
+ for (const [filepath, head, workdir, stage] of matrix) {
310
+ if (skip(`/${filepath}`)) continue
311
+ if (`${head}${workdir}${stage}` === "111") continue
312
+ if (workdir === 0) await git.remove({ fs: gitFs, dir: "/", filepath })
313
+ else await git.add({ fs: gitFs, dir: "/", filepath })
314
+ }
315
+ }