opencode-repos 0.1.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.
- package/.sisyphus/boulder.json +8 -0
- package/.sisyphus/notepads/opencode-repos/decisions.md +15 -0
- package/.sisyphus/notepads/opencode-repos/learnings.md +384 -0
- package/.sisyphus/plans/opencode-repos.md +987 -0
- package/.tmux-sessionizer +8 -0
- package/CLAUDE.md +111 -0
- package/README.md +395 -0
- package/bun.lock +119 -0
- package/index.ts +806 -0
- package/package.json +32 -0
- package/src/__tests__/git.test.ts +141 -0
- package/src/__tests__/manifest.test.ts +249 -0
- package/src/__tests__/setup.test.ts +5 -0
- package/src/agents/repo-explorer.ts +52 -0
- package/src/git.ts +90 -0
- package/src/manifest.ts +116 -0
- package/src/scanner.ts +126 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-repos",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Repository cache, registry, and cross-codebase intelligence for OpenCode agents",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"opencode",
|
|
9
|
+
"opencode-plugin",
|
|
10
|
+
"repos",
|
|
11
|
+
"repository",
|
|
12
|
+
"cache",
|
|
13
|
+
"codebase"
|
|
14
|
+
],
|
|
15
|
+
"author": "Liam Vinberg",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/liamjv1/opencode-repos"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@opencode-ai/plugin": "*"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"glob": "^10.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@opencode-ai/plugin": "^1.1.25",
|
|
29
|
+
"@types/bun": "^1.2.0",
|
|
30
|
+
"typescript": "^5.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test"
|
|
2
|
+
import { rm, mkdir } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import {
|
|
6
|
+
parseRepoSpec,
|
|
7
|
+
buildGitUrl,
|
|
8
|
+
cloneRepo,
|
|
9
|
+
updateRepo,
|
|
10
|
+
getRepoInfo,
|
|
11
|
+
} from "../git"
|
|
12
|
+
|
|
13
|
+
describe("parseRepoSpec", () => {
|
|
14
|
+
test("parses owner/repo without branch", () => {
|
|
15
|
+
const result = parseRepoSpec("vercel/next.js")
|
|
16
|
+
expect(result).toEqual({
|
|
17
|
+
owner: "vercel",
|
|
18
|
+
repo: "next.js",
|
|
19
|
+
branch: null,
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("parses owner/repo@branch", () => {
|
|
24
|
+
const result = parseRepoSpec("vercel/next.js@canary")
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
owner: "vercel",
|
|
27
|
+
repo: "next.js",
|
|
28
|
+
branch: "canary",
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("handles branch with special characters", () => {
|
|
33
|
+
const result = parseRepoSpec("owner/repo@feature/my-branch")
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
owner: "owner",
|
|
36
|
+
repo: "repo",
|
|
37
|
+
branch: "feature/my-branch",
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("throws on missing slash", () => {
|
|
42
|
+
expect(() => parseRepoSpec("invalid")).toThrow(
|
|
43
|
+
'Invalid repo spec: must be in format "owner/repo"'
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("throws on empty owner", () => {
|
|
48
|
+
expect(() => parseRepoSpec("/repo")).toThrow(
|
|
49
|
+
"Invalid repo spec: owner and repo cannot be empty"
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("throws on empty repo", () => {
|
|
54
|
+
expect(() => parseRepoSpec("owner/")).toThrow(
|
|
55
|
+
"Invalid repo spec: owner and repo cannot be empty"
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("throws on empty branch after @", () => {
|
|
60
|
+
expect(() => parseRepoSpec("owner/repo@")).toThrow(
|
|
61
|
+
"Invalid repo spec: branch cannot be empty after @"
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("throws on repo with extra slash", () => {
|
|
66
|
+
expect(() => parseRepoSpec("owner/repo/extra")).toThrow(
|
|
67
|
+
'Invalid repo spec: repo name cannot contain "/"'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("buildGitUrl", () => {
|
|
73
|
+
test("builds SSH URL correctly", () => {
|
|
74
|
+
const url = buildGitUrl("vercel", "next.js")
|
|
75
|
+
expect(url).toBe("git@github.com:vercel/next.js.git")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("handles various owner/repo names", () => {
|
|
79
|
+
expect(buildGitUrl("facebook", "react")).toBe(
|
|
80
|
+
"git@github.com:facebook/react.git"
|
|
81
|
+
)
|
|
82
|
+
expect(buildGitUrl("microsoft", "TypeScript")).toBe(
|
|
83
|
+
"git@github.com:microsoft/TypeScript.git"
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("git operations (integration)", () => {
|
|
89
|
+
const testDir = join(tmpdir(), `opencode-repos-test-${Date.now()}`)
|
|
90
|
+
const repoPath = join(testDir, "test-repo")
|
|
91
|
+
|
|
92
|
+
beforeAll(async () => {
|
|
93
|
+
await mkdir(testDir, { recursive: true })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await rm(testDir, { recursive: true, force: true })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("cloneRepo clones a public repository", async () => {
|
|
101
|
+
await cloneRepo(
|
|
102
|
+
"https://github.com/octocat/Hello-World.git",
|
|
103
|
+
repoPath,
|
|
104
|
+
{ branch: "master" }
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const exists = await Bun.file(join(repoPath, ".git/config")).exists()
|
|
108
|
+
expect(exists).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("getRepoInfo returns correct information", async () => {
|
|
112
|
+
const info = await getRepoInfo(repoPath)
|
|
113
|
+
|
|
114
|
+
expect(info.remote).toBe("https://github.com/octocat/Hello-World.git")
|
|
115
|
+
expect(info.branch).toBe("master")
|
|
116
|
+
expect(info.commit).toMatch(/^[a-f0-9]{40}$/)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("updateRepo fetches and resets", async () => {
|
|
120
|
+
await updateRepo(repoPath, "master")
|
|
121
|
+
const infoAfter = await getRepoInfo(repoPath)
|
|
122
|
+
|
|
123
|
+
expect(infoAfter.branch).toBe("master")
|
|
124
|
+
expect(infoAfter.commit).toMatch(/^[a-f0-9]{40}$/)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("cloneRepo cleans up on failure", async () => {
|
|
128
|
+
const badPath = join(testDir, "bad-clone")
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await cloneRepo(
|
|
132
|
+
"https://github.com/nonexistent-user-12345/nonexistent-repo-67890.git",
|
|
133
|
+
badPath
|
|
134
|
+
)
|
|
135
|
+
expect(true).toBe(false)
|
|
136
|
+
} catch {
|
|
137
|
+
const exists = await Bun.file(badPath).exists()
|
|
138
|
+
expect(exists).toBe(false)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach, describe } from "bun:test"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
5
|
+
import { loadManifest, saveManifest, withManifestLock, type Manifest } from "../manifest"
|
|
6
|
+
|
|
7
|
+
const originalCacheDir = join(homedir(), ".cache", "opencode-repos")
|
|
8
|
+
|
|
9
|
+
describe("loadManifest", () => {
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("creates empty manifest if file does not exist", async () => {
|
|
19
|
+
const manifest = await loadManifest()
|
|
20
|
+
|
|
21
|
+
expect(manifest).toEqual({
|
|
22
|
+
version: 1,
|
|
23
|
+
repos: {},
|
|
24
|
+
localIndex: {},
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("returns existing manifest when file exists", async () => {
|
|
29
|
+
await mkdir(originalCacheDir, { recursive: true })
|
|
30
|
+
|
|
31
|
+
const existingManifest: Manifest = {
|
|
32
|
+
version: 1,
|
|
33
|
+
repos: {
|
|
34
|
+
"owner/repo@main": {
|
|
35
|
+
type: "cached",
|
|
36
|
+
path: "/some/path",
|
|
37
|
+
lastAccessed: "2024-01-01T00:00:00.000Z",
|
|
38
|
+
defaultBranch: "main",
|
|
39
|
+
shallow: true,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
localIndex: {
|
|
43
|
+
"https://github.com/owner/repo.git": "/local/path",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await Bun.write(
|
|
48
|
+
join(originalCacheDir, "manifest.json"),
|
|
49
|
+
JSON.stringify(existingManifest, null, 2)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const manifest = await loadManifest()
|
|
53
|
+
|
|
54
|
+
expect(manifest).toEqual(existingManifest)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("handles corrupted JSON gracefully", async () => {
|
|
58
|
+
await mkdir(originalCacheDir, { recursive: true })
|
|
59
|
+
await Bun.write(join(originalCacheDir, "manifest.json"), "{ invalid json }")
|
|
60
|
+
|
|
61
|
+
const manifest = await loadManifest()
|
|
62
|
+
|
|
63
|
+
expect(manifest).toEqual({
|
|
64
|
+
version: 1,
|
|
65
|
+
repos: {},
|
|
66
|
+
localIndex: {},
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("saveManifest", () => {
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(async () => {
|
|
77
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("creates directory if it does not exist", async () => {
|
|
81
|
+
const manifest: Manifest = {
|
|
82
|
+
version: 1,
|
|
83
|
+
repos: {},
|
|
84
|
+
localIndex: {},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await saveManifest(manifest)
|
|
88
|
+
|
|
89
|
+
const file = Bun.file(join(originalCacheDir, "manifest.json"))
|
|
90
|
+
expect(await file.exists()).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("writes manifest atomically using tmp file", async () => {
|
|
94
|
+
const manifest: Manifest = {
|
|
95
|
+
version: 1,
|
|
96
|
+
repos: {
|
|
97
|
+
"test/repo@main": {
|
|
98
|
+
type: "cached",
|
|
99
|
+
path: "/test/path",
|
|
100
|
+
lastAccessed: "2024-01-01T00:00:00.000Z",
|
|
101
|
+
defaultBranch: "main",
|
|
102
|
+
shallow: false,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
localIndex: {},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await saveManifest(manifest)
|
|
109
|
+
|
|
110
|
+
const savedContent = await Bun.file(join(originalCacheDir, "manifest.json")).text()
|
|
111
|
+
const savedManifest = JSON.parse(savedContent)
|
|
112
|
+
|
|
113
|
+
expect(savedManifest).toEqual(manifest)
|
|
114
|
+
|
|
115
|
+
const tmpFile = Bun.file(join(originalCacheDir, "manifest.json.tmp"))
|
|
116
|
+
expect(await tmpFile.exists()).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("overwrites existing manifest", async () => {
|
|
120
|
+
const manifest1: Manifest = {
|
|
121
|
+
version: 1,
|
|
122
|
+
repos: { "first/repo@main": { type: "cached", path: "/first", lastAccessed: "2024-01-01T00:00:00.000Z", defaultBranch: "main", shallow: true } },
|
|
123
|
+
localIndex: {},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const manifest2: Manifest = {
|
|
127
|
+
version: 1,
|
|
128
|
+
repos: { "second/repo@main": { type: "local", path: "/second", lastAccessed: "2024-02-01T00:00:00.000Z", defaultBranch: "main", shallow: false } },
|
|
129
|
+
localIndex: { "url": "/path" },
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await saveManifest(manifest1)
|
|
133
|
+
await saveManifest(manifest2)
|
|
134
|
+
|
|
135
|
+
const savedContent = await Bun.file(join(originalCacheDir, "manifest.json")).text()
|
|
136
|
+
const savedManifest = JSON.parse(savedContent)
|
|
137
|
+
|
|
138
|
+
expect(savedManifest).toEqual(manifest2)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("withManifestLock", () => {
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
afterEach(async () => {
|
|
148
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("executes callback and returns result", async () => {
|
|
152
|
+
const result = await withManifestLock(async () => {
|
|
153
|
+
return "test-result"
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(result).toBe("test-result")
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("releases lock after callback completes", async () => {
|
|
160
|
+
await withManifestLock(async () => {
|
|
161
|
+
return "done"
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const lockFile = Bun.file(join(originalCacheDir, "manifest.lock"))
|
|
165
|
+
expect(await lockFile.exists()).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("releases lock even if callback throws", async () => {
|
|
169
|
+
try {
|
|
170
|
+
await withManifestLock(async () => {
|
|
171
|
+
throw new Error("test error")
|
|
172
|
+
})
|
|
173
|
+
} catch {
|
|
174
|
+
// expected
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const lockFile = Bun.file(join(originalCacheDir, "manifest.lock"))
|
|
178
|
+
expect(await lockFile.exists()).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("prevents concurrent access", async () => {
|
|
182
|
+
const results: number[] = []
|
|
183
|
+
|
|
184
|
+
const task1 = withManifestLock(async () => {
|
|
185
|
+
results.push(1)
|
|
186
|
+
await Bun.sleep(50)
|
|
187
|
+
results.push(2)
|
|
188
|
+
return "task1"
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
await Bun.sleep(10)
|
|
192
|
+
|
|
193
|
+
const task2 = withManifestLock(async () => {
|
|
194
|
+
results.push(3)
|
|
195
|
+
return "task2"
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
await Promise.all([task1, task2])
|
|
199
|
+
|
|
200
|
+
expect(results).toEqual([1, 2, 3])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("handles stale lock by removing it", async () => {
|
|
204
|
+
await mkdir(originalCacheDir, { recursive: true })
|
|
205
|
+
|
|
206
|
+
const staleTime = Date.now() - (6 * 60 * 1000)
|
|
207
|
+
await Bun.write(join(originalCacheDir, "manifest.lock"), String(staleTime))
|
|
208
|
+
|
|
209
|
+
const { utimes } = await import("node:fs/promises")
|
|
210
|
+
const staleDate = new Date(staleTime)
|
|
211
|
+
await utimes(join(originalCacheDir, "manifest.lock"), staleDate, staleDate)
|
|
212
|
+
|
|
213
|
+
const result = await withManifestLock(async () => {
|
|
214
|
+
return "acquired-after-stale"
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(result).toBe("acquired-after-stale")
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe("integration", () => {
|
|
222
|
+
beforeEach(async () => {
|
|
223
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
afterEach(async () => {
|
|
227
|
+
await rm(originalCacheDir, { recursive: true, force: true })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("load, modify, and save manifest with lock", async () => {
|
|
231
|
+
await withManifestLock(async () => {
|
|
232
|
+
const manifest = await loadManifest()
|
|
233
|
+
|
|
234
|
+
manifest.repos["new/repo@main"] = {
|
|
235
|
+
type: "cached",
|
|
236
|
+
path: "/new/path",
|
|
237
|
+
lastAccessed: new Date().toISOString(),
|
|
238
|
+
defaultBranch: "main",
|
|
239
|
+
shallow: true,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await saveManifest(manifest)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const loaded = await loadManifest()
|
|
246
|
+
expect(loaded.repos["new/repo@main"]).toBeDefined()
|
|
247
|
+
expect(loaded.repos["new/repo@main"].type).toBe("cached")
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentConfig } from "@opencode-ai/sdk"
|
|
2
|
+
|
|
3
|
+
export function createRepoExplorerAgent(): AgentConfig {
|
|
4
|
+
return {
|
|
5
|
+
description:
|
|
6
|
+
"Specialized agent for exploring external codebases. Use when you need to understand another project's architecture, APIs, patterns, or implementation details to integrate with it or learn from it.",
|
|
7
|
+
mode: "subagent",
|
|
8
|
+
temperature: 0.1,
|
|
9
|
+
permission: {
|
|
10
|
+
edit: "deny",
|
|
11
|
+
},
|
|
12
|
+
prompt: `You are a codebase exploration specialist. Your job is to deeply understand external codebases and report your findings clearly.
|
|
13
|
+
|
|
14
|
+
## Your Capabilities
|
|
15
|
+
- Read and analyze source code across any programming language
|
|
16
|
+
- Search for patterns and implementations using grep, glob, and AST tools
|
|
17
|
+
- Understand project structure and architecture
|
|
18
|
+
- Identify APIs, interfaces, and integration points
|
|
19
|
+
- Trace code paths and data flows
|
|
20
|
+
- Explain complex implementations in simple terms
|
|
21
|
+
|
|
22
|
+
## Your Approach
|
|
23
|
+
1. **Start high-level**: Begin with README, package.json, main entry points to understand the project's purpose
|
|
24
|
+
2. **Map the structure**: Identify key directories and their purposes (src/, lib/, tests/, etc.)
|
|
25
|
+
3. **Trace relevant paths**: Follow the code paths relevant to the specific question
|
|
26
|
+
4. **Be specific**: Always cite file paths and line numbers
|
|
27
|
+
5. **Show examples**: Include relevant code snippets to illustrate your findings
|
|
28
|
+
6. **Explain interactions**: Describe how components, modules, and APIs interact
|
|
29
|
+
|
|
30
|
+
## Output Format
|
|
31
|
+
- **Be specific**: Always cite file paths (with line numbers if relevant)
|
|
32
|
+
- **Include code snippets**: Show relevant portions of code with context
|
|
33
|
+
- **Explain architecture**: Describe how components interact
|
|
34
|
+
- **Note patterns**: Highlight any patterns, conventions, or best practices used
|
|
35
|
+
- **Provide examples**: Give concrete examples of how to use discovered APIs
|
|
36
|
+
|
|
37
|
+
## Important Constraints
|
|
38
|
+
- You are **READ-ONLY**: You cannot modify, create, or delete any files
|
|
39
|
+
- You cannot spawn tasks or sub-agents
|
|
40
|
+
- Your job is to explore and report, not to modify
|
|
41
|
+
- Focus on understanding and explaining, not implementing
|
|
42
|
+
|
|
43
|
+
## Example Questions You Might Answer
|
|
44
|
+
- "How does authentication work in this codebase?"
|
|
45
|
+
- "What's the API for creating a new user?"
|
|
46
|
+
- "How does the routing system work?"
|
|
47
|
+
- "Find all places where database transactions are used"
|
|
48
|
+
- "What patterns does this project use for error handling?"
|
|
49
|
+
|
|
50
|
+
Remember: Be thorough but concise. Focus on what's relevant to the question while providing enough context for understanding.`,
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { $ } from "bun"
|
|
2
|
+
import { rm } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
export interface RepoSpec {
|
|
5
|
+
owner: string
|
|
6
|
+
repo: string
|
|
7
|
+
branch: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RepoInfo {
|
|
11
|
+
remote: string
|
|
12
|
+
branch: string
|
|
13
|
+
commit: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CloneOptions {
|
|
17
|
+
branch?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseRepoSpec(spec: string): RepoSpec {
|
|
21
|
+
const atIndex = spec.indexOf("@")
|
|
22
|
+
let repoPath: string
|
|
23
|
+
let branch: string | null = null
|
|
24
|
+
|
|
25
|
+
if (atIndex !== -1) {
|
|
26
|
+
repoPath = spec.slice(0, atIndex)
|
|
27
|
+
branch = spec.slice(atIndex + 1)
|
|
28
|
+
if (!branch) {
|
|
29
|
+
throw new Error(`Invalid repo spec: branch cannot be empty after @`)
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
repoPath = spec
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const slashIndex = repoPath.indexOf("/")
|
|
36
|
+
if (slashIndex === -1) {
|
|
37
|
+
throw new Error(`Invalid repo spec: must be in format "owner/repo" or "owner/repo@branch"`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const owner = repoPath.slice(0, slashIndex)
|
|
41
|
+
const repo = repoPath.slice(slashIndex + 1)
|
|
42
|
+
|
|
43
|
+
if (!owner || !repo) {
|
|
44
|
+
throw new Error(`Invalid repo spec: owner and repo cannot be empty`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (repo.includes("/")) {
|
|
48
|
+
throw new Error(`Invalid repo spec: repo name cannot contain "/"`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { owner, repo, branch }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildGitUrl(owner: string, repo: string): string {
|
|
55
|
+
return `git@github.com:${owner}/${repo}.git`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function cloneRepo(
|
|
59
|
+
url: string,
|
|
60
|
+
destPath: string,
|
|
61
|
+
options: CloneOptions = {}
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const branch = options.branch || "main"
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await $`git clone --depth=1 --single-branch --branch ${branch} --config core.hooksPath=/dev/null ${url} ${destPath}`.quiet()
|
|
67
|
+
} catch (error) {
|
|
68
|
+
try {
|
|
69
|
+
await rm(destPath, { recursive: true, force: true })
|
|
70
|
+
} catch {}
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function updateRepo(path: string, branch: string = "main"): Promise<void> {
|
|
76
|
+
await $`git -C ${path} fetch origin ${branch} --depth=1`.quiet()
|
|
77
|
+
await $`git -C ${path} reset --hard origin/${branch}`.quiet()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getRepoInfo(path: string): Promise<RepoInfo> {
|
|
81
|
+
const remote = await $`git -C ${path} remote get-url origin`.text()
|
|
82
|
+
const branch = await $`git -C ${path} branch --show-current`.text()
|
|
83
|
+
const commit = await $`git -C ${path} rev-parse HEAD`.text()
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
remote: remote.trim(),
|
|
87
|
+
branch: branch.trim(),
|
|
88
|
+
commit: commit.trim(),
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { mkdir, rename, unlink, stat } from "node:fs/promises"
|
|
4
|
+
|
|
5
|
+
export interface RepoEntry {
|
|
6
|
+
type: "cached" | "local"
|
|
7
|
+
path: string
|
|
8
|
+
clonedAt?: string
|
|
9
|
+
lastAccessed: string
|
|
10
|
+
lastUpdated?: string
|
|
11
|
+
sizeBytes?: number
|
|
12
|
+
defaultBranch: string
|
|
13
|
+
shallow: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Manifest {
|
|
17
|
+
version: 1
|
|
18
|
+
repos: Record<string, RepoEntry>
|
|
19
|
+
localIndex: Record<string, string>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Config {
|
|
23
|
+
localSearchPaths: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const CACHE_DIR = join(homedir(), ".cache", "opencode-repos")
|
|
27
|
+
const MANIFEST_PATH = join(CACHE_DIR, "manifest.json")
|
|
28
|
+
const MANIFEST_TMP_PATH = join(CACHE_DIR, "manifest.json.tmp")
|
|
29
|
+
const LOCK_PATH = join(CACHE_DIR, "manifest.lock")
|
|
30
|
+
const LOCK_STALE_MS = 5 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
function createEmptyManifest(): Manifest {
|
|
33
|
+
return {
|
|
34
|
+
version: 1,
|
|
35
|
+
repos: {},
|
|
36
|
+
localIndex: {},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadManifest(): Promise<Manifest> {
|
|
41
|
+
const file = Bun.file(MANIFEST_PATH)
|
|
42
|
+
const exists = await file.exists()
|
|
43
|
+
|
|
44
|
+
if (!exists) {
|
|
45
|
+
return createEmptyManifest()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const content = await file.text()
|
|
50
|
+
const parsed = JSON.parse(content) as Manifest
|
|
51
|
+
return parsed
|
|
52
|
+
} catch {
|
|
53
|
+
console.warn("[opencode-repos] Manifest corrupted, returning empty manifest")
|
|
54
|
+
return createEmptyManifest()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function saveManifest(manifest: Manifest): Promise<void> {
|
|
59
|
+
await mkdir(CACHE_DIR, { recursive: true })
|
|
60
|
+
await Bun.write(MANIFEST_TMP_PATH, JSON.stringify(manifest, null, 2))
|
|
61
|
+
await rename(MANIFEST_TMP_PATH, MANIFEST_PATH)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function isLockStale(): Promise<boolean> {
|
|
65
|
+
try {
|
|
66
|
+
const lockStat = await stat(LOCK_PATH)
|
|
67
|
+
const age = Date.now() - lockStat.mtimeMs
|
|
68
|
+
return age > LOCK_STALE_MS
|
|
69
|
+
} catch {
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function acquireLock(): Promise<void> {
|
|
75
|
+
const maxAttempts = 50
|
|
76
|
+
const retryDelayMs = 100
|
|
77
|
+
|
|
78
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
79
|
+
const lockFile = Bun.file(LOCK_PATH)
|
|
80
|
+
const exists = await lockFile.exists()
|
|
81
|
+
|
|
82
|
+
if (exists) {
|
|
83
|
+
if (await isLockStale()) {
|
|
84
|
+
await unlink(LOCK_PATH).catch(() => {})
|
|
85
|
+
} else {
|
|
86
|
+
await Bun.sleep(retryDelayMs)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await mkdir(CACHE_DIR, { recursive: true })
|
|
93
|
+
await Bun.write(LOCK_PATH, String(Date.now()))
|
|
94
|
+
return
|
|
95
|
+
} catch {
|
|
96
|
+
await Bun.sleep(retryDelayMs)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error("Failed to acquire manifest lock after maximum attempts")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function releaseLock(): Promise<void> {
|
|
104
|
+
await unlink(LOCK_PATH).catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function withManifestLock<T>(
|
|
108
|
+
callback: () => Promise<T>
|
|
109
|
+
): Promise<T> {
|
|
110
|
+
await acquireLock()
|
|
111
|
+
try {
|
|
112
|
+
return await callback()
|
|
113
|
+
} finally {
|
|
114
|
+
await releaseLock()
|
|
115
|
+
}
|
|
116
|
+
}
|