@cvr/repo 1.0.0

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,226 @@
1
+ import { Context, Effect, Layer } from "effect"
2
+ import { GitError } from "../types.js"
3
+
4
+ // Service interface
5
+ export class GitService extends Context.Tag("@repo/GitService")<
6
+ GitService,
7
+ {
8
+ readonly clone: (
9
+ url: string,
10
+ dest: string,
11
+ options?: { depth?: number; ref?: string }
12
+ ) => Effect.Effect<void, GitError>
13
+ readonly update: (path: string) => Effect.Effect<void, GitError>
14
+ readonly isGitRepo: (path: string) => Effect.Effect<boolean>
15
+ readonly getDefaultBranch: (url: string) => Effect.Effect<string, GitError>
16
+ readonly getCurrentRef: (path: string) => Effect.Effect<string, GitError>
17
+ }
18
+ >() {
19
+ // Live layer using real git
20
+ static readonly layer = Layer.succeed(
21
+ GitService,
22
+ GitService.of({
23
+ clone: (url, dest, options) =>
24
+ Effect.gen(function* () {
25
+ const args = ["clone"]
26
+
27
+ if (options?.depth) {
28
+ args.push("--depth", String(options.depth))
29
+ }
30
+
31
+ if (options?.ref) {
32
+ args.push("--branch", options.ref)
33
+ }
34
+
35
+ args.push(url, dest)
36
+
37
+ const proc = Bun.spawn(["git", ...args], {
38
+ stdout: "pipe",
39
+ stderr: "pipe",
40
+ })
41
+
42
+ const exitCode = yield* Effect.tryPromise({
43
+ try: () => proc.exited,
44
+ catch: (cause) =>
45
+ new GitError({ operation: "clone", repo: url, cause }),
46
+ })
47
+
48
+ if (exitCode !== 0) {
49
+ // If ref failed, try without it (fallback to default branch)
50
+ if (options?.ref) {
51
+ const fallbackArgs = ["clone"]
52
+ if (options.depth) {
53
+ fallbackArgs.push("--depth", String(options.depth))
54
+ }
55
+ fallbackArgs.push(url, dest)
56
+
57
+ const fallbackProc = Bun.spawn(["git", ...fallbackArgs], {
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ })
61
+
62
+ const fallbackResult = yield* Effect.tryPromise({
63
+ try: () => fallbackProc.exited,
64
+ catch: (cause) =>
65
+ new GitError({
66
+ operation: "clone-fallback",
67
+ repo: url,
68
+ cause,
69
+ }),
70
+ })
71
+
72
+ if (fallbackResult !== 0) {
73
+ return yield* Effect.fail(
74
+ new GitError({
75
+ operation: "clone",
76
+ repo: url,
77
+ cause: new Error(
78
+ `git clone failed with exit code ${fallbackResult}`
79
+ ),
80
+ })
81
+ )
82
+ }
83
+ } else {
84
+ return yield* Effect.fail(
85
+ new GitError({
86
+ operation: "clone",
87
+ repo: url,
88
+ cause: new Error(`git clone failed with exit code ${exitCode}`),
89
+ })
90
+ )
91
+ }
92
+ }
93
+ }),
94
+
95
+ update: (path) =>
96
+ Effect.gen(function* () {
97
+ // Fetch all updates
98
+ const fetchProc = Bun.spawn(["git", "-C", path, "fetch", "--all", "--prune"], {
99
+ stdout: "pipe",
100
+ stderr: "pipe",
101
+ })
102
+
103
+ const fetchExit = yield* Effect.tryPromise({
104
+ try: () => fetchProc.exited,
105
+ catch: (cause) =>
106
+ new GitError({ operation: "fetch", repo: path, cause }),
107
+ })
108
+
109
+ if (fetchExit !== 0) {
110
+ return yield* Effect.fail(
111
+ new GitError({
112
+ operation: "fetch",
113
+ repo: path,
114
+ cause: new Error(`git fetch failed with exit code ${fetchExit}`),
115
+ })
116
+ )
117
+ }
118
+
119
+ // Reset to origin/HEAD (or current branch's upstream)
120
+ const resetProc = Bun.spawn(
121
+ ["git", "-C", path, "reset", "--hard", "origin/HEAD"],
122
+ {
123
+ stdout: "pipe",
124
+ stderr: "pipe",
125
+ }
126
+ )
127
+
128
+ const resetExit = yield* Effect.tryPromise({
129
+ try: () => resetProc.exited,
130
+ catch: (cause) =>
131
+ new GitError({ operation: "reset", repo: path, cause }),
132
+ })
133
+
134
+ if (resetExit !== 0) {
135
+ // Try resetting to @{upstream} instead
136
+ const upstreamProc = Bun.spawn(
137
+ ["git", "-C", path, "reset", "--hard", "@{upstream}"],
138
+ {
139
+ stdout: "pipe",
140
+ stderr: "pipe",
141
+ }
142
+ )
143
+
144
+ const upstreamExit = yield* Effect.tryPromise({
145
+ try: () => upstreamProc.exited,
146
+ catch: (cause) =>
147
+ new GitError({ operation: "reset-upstream", repo: path, cause }),
148
+ })
149
+
150
+ if (upstreamExit !== 0) {
151
+ return yield* Effect.fail(
152
+ new GitError({
153
+ operation: "reset",
154
+ repo: path,
155
+ cause: new Error(`git reset failed with exit code ${upstreamExit}`),
156
+ })
157
+ )
158
+ }
159
+ }
160
+ }),
161
+
162
+ isGitRepo: (path) =>
163
+ Effect.gen(function* () {
164
+ const proc = Bun.spawn(["git", "-C", path, "rev-parse", "--git-dir"], {
165
+ stdout: "pipe",
166
+ stderr: "pipe",
167
+ })
168
+
169
+ const exitCode = yield* Effect.promise(() => proc.exited).pipe(
170
+ Effect.orElseSucceed(() => 1)
171
+ )
172
+
173
+ return exitCode === 0
174
+ }),
175
+
176
+ getDefaultBranch: (url) =>
177
+ Effect.gen(function* () {
178
+ const proc = Bun.spawn(["git", "ls-remote", "--symref", url, "HEAD"], {
179
+ stdout: "pipe",
180
+ stderr: "pipe",
181
+ })
182
+
183
+ const output = yield* Effect.tryPromise({
184
+ try: async () => {
185
+ const stdout = await new Response(proc.stdout).text()
186
+ await proc.exited
187
+ return stdout
188
+ },
189
+ catch: (cause) =>
190
+ new GitError({ operation: "getDefaultBranch", repo: url, cause }),
191
+ })
192
+
193
+ // Parse output like: ref: refs/heads/main\tHEAD
194
+ const match = output.match(/ref: refs\/heads\/(\S+)/)
195
+ if (match?.[1]) {
196
+ return match[1]
197
+ }
198
+ return "main" // fallback
199
+ }),
200
+
201
+ getCurrentRef: (path) =>
202
+ Effect.gen(function* () {
203
+ const proc = Bun.spawn(
204
+ ["git", "-C", path, "describe", "--tags", "--always"],
205
+ {
206
+ stdout: "pipe",
207
+ stderr: "pipe",
208
+ }
209
+ )
210
+
211
+ const output = yield* Effect.tryPromise({
212
+ try: async () => {
213
+ const stdout = await new Response(proc.stdout).text()
214
+ await proc.exited
215
+ return stdout.trim()
216
+ },
217
+ catch: (cause) =>
218
+ new GitError({ operation: "getCurrentRef", repo: path, cause }),
219
+ })
220
+
221
+ return output || "unknown"
222
+ }),
223
+ })
224
+ )
225
+
226
+ }
@@ -0,0 +1,141 @@
1
+ import { FileSystem, Path } from "@effect/platform"
2
+ import { Context, Effect, Layer, Option, Schema } from "effect"
3
+ import type { PackageSpec, RepoMetadata } from "../types.js"
4
+ import { MetadataIndex } from "../types.js"
5
+
6
+ // Create encode/decode functions for proper JSON serialization
7
+ const encodeMetadata = Schema.encodeSync(MetadataIndex)
8
+ const decodeMetadata = Schema.decodeUnknownSync(MetadataIndex)
9
+
10
+ // Service interface
11
+ export class MetadataService extends Context.Tag("@repo/MetadataService")<
12
+ MetadataService,
13
+ {
14
+ readonly load: () => Effect.Effect<MetadataIndex>
15
+ readonly save: (index: MetadataIndex) => Effect.Effect<void>
16
+ readonly add: (metadata: RepoMetadata) => Effect.Effect<void>
17
+ readonly remove: (spec: PackageSpec) => Effect.Effect<boolean>
18
+ readonly find: (spec: PackageSpec) => Effect.Effect<RepoMetadata | null>
19
+ readonly updateAccessTime: (spec: PackageSpec) => Effect.Effect<void>
20
+ readonly findOlderThan: (days: number) => Effect.Effect<readonly RepoMetadata[]>
21
+ readonly findLargerThan: (bytes: number) => Effect.Effect<readonly RepoMetadata[]>
22
+ readonly all: () => Effect.Effect<readonly RepoMetadata[]>
23
+ }
24
+ >() {
25
+ // Live layer using real filesystem
26
+ static readonly layer = Layer.effect(
27
+ MetadataService,
28
+ Effect.gen(function* () {
29
+ const fs = yield* FileSystem.FileSystem
30
+ const pathService = yield* Path.Path
31
+ const home = process.env.HOME ?? "~"
32
+ const cacheDir = pathService.join(home, ".cache", "repo")
33
+ const metadataPath = pathService.join(cacheDir, "metadata.json")
34
+
35
+ const specMatches = (a: PackageSpec, b: PackageSpec): boolean => {
36
+ if (a.registry !== b.registry || a.name !== b.name) return false
37
+ const aVersion = Option.getOrElse(a.version, () => "")
38
+ const bVersion = Option.getOrElse(b.version, () => "")
39
+ return aVersion === bVersion
40
+ }
41
+
42
+ const load = (): Effect.Effect<MetadataIndex> =>
43
+ Effect.gen(function* () {
44
+ const exists = yield* fs.exists(metadataPath)
45
+ if (!exists) {
46
+ return { version: 1, repos: [] }
47
+ }
48
+ const content = yield* fs.readFileString(metadataPath)
49
+ return decodeMetadata(JSON.parse(content))
50
+ }).pipe(Effect.orElse(() => Effect.succeed({ version: 1, repos: [] })))
51
+
52
+ const save = (index: MetadataIndex): Effect.Effect<void> =>
53
+ Effect.gen(function* () {
54
+ yield* fs.makeDirectory(cacheDir, { recursive: true })
55
+ const encoded = encodeMetadata(index)
56
+ yield* fs.writeFileString(metadataPath, JSON.stringify(encoded, null, 2))
57
+ }).pipe(Effect.ignore)
58
+
59
+ const add = (metadata: RepoMetadata): Effect.Effect<void> =>
60
+ Effect.gen(function* () {
61
+ const index = yield* load()
62
+ const filtered = index.repos.filter(
63
+ (r) => !specMatches(r.spec, metadata.spec)
64
+ )
65
+ const newIndex: MetadataIndex = {
66
+ ...index,
67
+ repos: [...filtered, metadata],
68
+ }
69
+ yield* save(newIndex)
70
+ })
71
+
72
+ const remove = (spec: PackageSpec): Effect.Effect<boolean> =>
73
+ Effect.gen(function* () {
74
+ const index = yield* load()
75
+ const originalLength = index.repos.length
76
+ const filtered = index.repos.filter((r) => !specMatches(r.spec, spec))
77
+ if (filtered.length === originalLength) {
78
+ return false
79
+ }
80
+ yield* save({ ...index, repos: filtered })
81
+ return true
82
+ })
83
+
84
+ const find = (spec: PackageSpec): Effect.Effect<RepoMetadata | null> =>
85
+ Effect.gen(function* () {
86
+ const index = yield* load()
87
+ return index.repos.find((r) => specMatches(r.spec, spec)) ?? null
88
+ })
89
+
90
+ const updateAccessTime = (spec: PackageSpec): Effect.Effect<void> =>
91
+ Effect.gen(function* () {
92
+ const index = yield* load()
93
+ const updated = index.repos.map((r) => {
94
+ if (specMatches(r.spec, spec)) {
95
+ return { ...r, lastAccessedAt: new Date().toISOString() }
96
+ }
97
+ return r
98
+ })
99
+ yield* save({ ...index, repos: updated })
100
+ })
101
+
102
+ const findOlderThan = (
103
+ days: number
104
+ ): Effect.Effect<readonly RepoMetadata[]> =>
105
+ Effect.gen(function* () {
106
+ const index = yield* load()
107
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
108
+ return index.repos.filter(
109
+ (r) => new Date(r.lastAccessedAt).getTime() < cutoff
110
+ )
111
+ })
112
+
113
+ const findLargerThan = (
114
+ bytes: number
115
+ ): Effect.Effect<readonly RepoMetadata[]> =>
116
+ Effect.gen(function* () {
117
+ const index = yield* load()
118
+ return index.repos.filter((r) => r.sizeBytes > bytes)
119
+ })
120
+
121
+ const all = (): Effect.Effect<readonly RepoMetadata[]> =>
122
+ Effect.gen(function* () {
123
+ const index = yield* load()
124
+ return index.repos
125
+ })
126
+
127
+ return MetadataService.of({
128
+ load,
129
+ save,
130
+ add,
131
+ remove,
132
+ find,
133
+ updateAccessTime,
134
+ findOlderThan,
135
+ findLargerThan,
136
+ all,
137
+ })
138
+ })
139
+ )
140
+
141
+ }