@markjaquith/agency 0.5.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/LICENSE +21 -0
- package/README.md +109 -0
- package/cli.ts +569 -0
- package/index.ts +1 -0
- package/package.json +65 -0
- package/src/commands/base.test.ts +198 -0
- package/src/commands/base.ts +198 -0
- package/src/commands/clean.test.ts +299 -0
- package/src/commands/clean.ts +320 -0
- package/src/commands/emit.test.ts +412 -0
- package/src/commands/emit.ts +521 -0
- package/src/commands/emitted.test.ts +226 -0
- package/src/commands/emitted.ts +57 -0
- package/src/commands/init.test.ts +311 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/merge.test.ts +365 -0
- package/src/commands/merge.ts +253 -0
- package/src/commands/pull.test.ts +385 -0
- package/src/commands/pull.ts +205 -0
- package/src/commands/push.test.ts +394 -0
- package/src/commands/push.ts +346 -0
- package/src/commands/save.test.ts +247 -0
- package/src/commands/save.ts +162 -0
- package/src/commands/source.test.ts +195 -0
- package/src/commands/source.ts +72 -0
- package/src/commands/status.test.ts +489 -0
- package/src/commands/status.ts +258 -0
- package/src/commands/switch.test.ts +194 -0
- package/src/commands/switch.ts +84 -0
- package/src/commands/task-branching.test.ts +334 -0
- package/src/commands/task-edit.test.ts +141 -0
- package/src/commands/task-main.test.ts +872 -0
- package/src/commands/task.ts +712 -0
- package/src/commands/tasks.test.ts +335 -0
- package/src/commands/tasks.ts +155 -0
- package/src/commands/template-delete.test.ts +178 -0
- package/src/commands/template-delete.ts +98 -0
- package/src/commands/template-list.test.ts +135 -0
- package/src/commands/template-list.ts +87 -0
- package/src/commands/template-view.test.ts +158 -0
- package/src/commands/template-view.ts +86 -0
- package/src/commands/template.test.ts +32 -0
- package/src/commands/template.ts +96 -0
- package/src/commands/use.test.ts +87 -0
- package/src/commands/use.ts +97 -0
- package/src/commands/work.test.ts +462 -0
- package/src/commands/work.ts +193 -0
- package/src/errors.ts +17 -0
- package/src/schemas.ts +33 -0
- package/src/services/AgencyMetadataService.ts +287 -0
- package/src/services/ClaudeService.test.ts +184 -0
- package/src/services/ClaudeService.ts +91 -0
- package/src/services/ConfigService.ts +115 -0
- package/src/services/FileSystemService.ts +222 -0
- package/src/services/GitService.ts +751 -0
- package/src/services/OpencodeService.ts +263 -0
- package/src/services/PromptService.ts +183 -0
- package/src/services/TemplateService.ts +75 -0
- package/src/test-utils.ts +362 -0
- package/src/types/native-exec.d.ts +8 -0
- package/src/types.ts +216 -0
- package/src/utils/colors.ts +178 -0
- package/src/utils/command.ts +17 -0
- package/src/utils/effect.ts +281 -0
- package/src/utils/exec.ts +48 -0
- package/src/utils/paths.ts +51 -0
- package/src/utils/pr-branch.test.ts +372 -0
- package/src/utils/pr-branch.ts +473 -0
- package/src/utils/process.ts +110 -0
- package/src/utils/spinner.ts +82 -0
- package/templates/AGENCY.md +20 -0
- package/templates/AGENTS.md +11 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/TASK.md +5 -0
- package/templates/opencode.json +4 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { Effect, Data, pipe } from "effect"
|
|
2
|
+
import { resolve } from "path"
|
|
3
|
+
import { realpath } from "fs/promises"
|
|
4
|
+
import {
|
|
5
|
+
spawnProcess,
|
|
6
|
+
checkExitCodeAndReturnStdout,
|
|
7
|
+
checkExitCodeAndReturnVoid,
|
|
8
|
+
createErrorMapper,
|
|
9
|
+
} from "../utils/process"
|
|
10
|
+
|
|
11
|
+
// Error types for Git operations
|
|
12
|
+
class GitError extends Data.TaggedError("GitError")<{
|
|
13
|
+
message: string
|
|
14
|
+
cause?: unknown
|
|
15
|
+
}> {}
|
|
16
|
+
|
|
17
|
+
class NotInGitRepoError extends Data.TaggedError("NotInGitRepoError")<{
|
|
18
|
+
path: string
|
|
19
|
+
}> {}
|
|
20
|
+
|
|
21
|
+
export class GitCommandError extends Data.TaggedError("GitCommandError")<{
|
|
22
|
+
command: string
|
|
23
|
+
exitCode: number
|
|
24
|
+
stderr: string
|
|
25
|
+
}> {}
|
|
26
|
+
|
|
27
|
+
// Error mapper for git command failures
|
|
28
|
+
const mapToGitCommandError = createErrorMapper(GitCommandError)
|
|
29
|
+
|
|
30
|
+
// Helper to run git commands with proper error handling
|
|
31
|
+
const runGitCommand = (args: readonly string[], cwd: string) =>
|
|
32
|
+
pipe(
|
|
33
|
+
spawnProcess(args, { cwd }),
|
|
34
|
+
Effect.mapError(
|
|
35
|
+
(processError) =>
|
|
36
|
+
new GitCommandError({
|
|
37
|
+
command: processError.command,
|
|
38
|
+
exitCode: processError.exitCode,
|
|
39
|
+
stderr: processError.stderr,
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Helper to run git commands and check exit code
|
|
45
|
+
const runGitCommandOrFail = (args: readonly string[], cwd: string) =>
|
|
46
|
+
pipe(
|
|
47
|
+
runGitCommand(args, cwd),
|
|
48
|
+
Effect.flatMap(checkExitCodeAndReturnStdout(mapToGitCommandError(args))),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Helper to run git commands that return void
|
|
52
|
+
const runGitCommandVoid = (args: readonly string[], cwd: string) =>
|
|
53
|
+
pipe(
|
|
54
|
+
runGitCommand(args, cwd),
|
|
55
|
+
Effect.flatMap(checkExitCodeAndReturnVoid(mapToGitCommandError(args))),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Helper to get git config value
|
|
59
|
+
const getGitConfigEffect = (key: string, gitRoot: string) =>
|
|
60
|
+
pipe(
|
|
61
|
+
runGitCommand(["git", "config", "--local", "--get", key], gitRoot),
|
|
62
|
+
Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Helper to set git config value
|
|
66
|
+
const setGitConfigEffect = (key: string, value: string, gitRoot: string) =>
|
|
67
|
+
pipe(
|
|
68
|
+
runGitCommandVoid(["git", "config", "--local", key, value], gitRoot),
|
|
69
|
+
Effect.mapError(
|
|
70
|
+
(error) =>
|
|
71
|
+
new GitError({
|
|
72
|
+
message:
|
|
73
|
+
error instanceof GitCommandError
|
|
74
|
+
? `Failed to set git config ${key}: ${error.stderr}`
|
|
75
|
+
: `Failed to set git config ${key}`,
|
|
76
|
+
cause: error,
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// Helper to check if branch exists
|
|
82
|
+
const branchExistsEffect = (gitRoot: string, branch: string) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
// Get list of remotes to dynamically check if it's a remote branch
|
|
85
|
+
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
86
|
+
const remotes =
|
|
87
|
+
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
88
|
+
? remotesResult.stdout.trim().split("\n")
|
|
89
|
+
: []
|
|
90
|
+
|
|
91
|
+
// Check if branch name starts with any remote prefix
|
|
92
|
+
const hasRemotePrefix = remotes.some((remote) =>
|
|
93
|
+
branch.startsWith(`${remote}/`),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const ref = hasRemotePrefix
|
|
97
|
+
? `refs/remotes/${branch}`
|
|
98
|
+
: `refs/heads/${branch}`
|
|
99
|
+
|
|
100
|
+
const result = yield* runGitCommand(
|
|
101
|
+
["git", "show-ref", "--verify", "--quiet", ref],
|
|
102
|
+
gitRoot,
|
|
103
|
+
)
|
|
104
|
+
return result.exitCode === 0
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Helper to find default remote
|
|
108
|
+
const findDefaultRemoteEffect = (gitRoot: string) =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
// Get list of remotes
|
|
111
|
+
const result = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
112
|
+
|
|
113
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
114
|
+
// Return first remote (usually "origin")
|
|
115
|
+
const remotes = result.stdout.trim().split("\n")
|
|
116
|
+
return remotes[0] || null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Helper to find main branch (shared logic)
|
|
123
|
+
const findMainBranchEffect = (gitRoot: string) =>
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
// Get list of remotes
|
|
126
|
+
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
127
|
+
const remotes =
|
|
128
|
+
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
129
|
+
? remotesResult.stdout.trim().split("\n")
|
|
130
|
+
: []
|
|
131
|
+
|
|
132
|
+
// Try to resolve a remote (prefer origin > upstream > first)
|
|
133
|
+
let defaultRemote: string | null = null
|
|
134
|
+
if (remotes.length > 0) {
|
|
135
|
+
if (remotes.includes("origin")) {
|
|
136
|
+
defaultRemote = "origin"
|
|
137
|
+
} else if (remotes.includes("upstream")) {
|
|
138
|
+
defaultRemote = "upstream"
|
|
139
|
+
} else {
|
|
140
|
+
defaultRemote = remotes[0] || null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// If we have a remote, try remote branches first (prioritize remote over local)
|
|
145
|
+
if (defaultRemote) {
|
|
146
|
+
// Check for common remote branch names
|
|
147
|
+
for (const branch of ["main", "master"]) {
|
|
148
|
+
const remoteBranch = `${defaultRemote}/${branch}`
|
|
149
|
+
const exists = yield* branchExistsEffect(gitRoot, remoteBranch)
|
|
150
|
+
if (exists) {
|
|
151
|
+
return remoteBranch
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fall back to local branches
|
|
157
|
+
const commonBases = ["main", "master"]
|
|
158
|
+
for (const base of commonBases) {
|
|
159
|
+
const exists = yield* branchExistsEffect(gitRoot, base)
|
|
160
|
+
if (exists) {
|
|
161
|
+
return base
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Git Service using Effect.Service pattern
|
|
169
|
+
export class GitService extends Effect.Service<GitService>()("GitService", {
|
|
170
|
+
sync: () => ({
|
|
171
|
+
isInsideGitRepo: (path: string) =>
|
|
172
|
+
pipe(
|
|
173
|
+
spawnProcess(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
174
|
+
cwd: path,
|
|
175
|
+
}),
|
|
176
|
+
Effect.map((result) => result.exitCode === 0),
|
|
177
|
+
Effect.mapError(
|
|
178
|
+
() => new GitError({ message: "Failed to check git repo status" }),
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
|
|
182
|
+
getGitRoot: (path: string) =>
|
|
183
|
+
pipe(
|
|
184
|
+
spawnProcess(["git", "rev-parse", "--show-toplevel"], { cwd: path }),
|
|
185
|
+
Effect.flatMap((result) =>
|
|
186
|
+
result.exitCode === 0
|
|
187
|
+
? Effect.succeed(result.stdout)
|
|
188
|
+
: Effect.fail(new NotInGitRepoError({ path })),
|
|
189
|
+
),
|
|
190
|
+
Effect.mapError(() => new NotInGitRepoError({ path })),
|
|
191
|
+
),
|
|
192
|
+
|
|
193
|
+
isGitRoot: (path: string) =>
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
const absolutePath = yield* Effect.tryPromise({
|
|
196
|
+
try: () => realpath(resolve(path)),
|
|
197
|
+
catch: () => new GitError({ message: "Failed to check if git root" }),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const result = yield* pipe(
|
|
201
|
+
spawnProcess(["git", "rev-parse", "--show-toplevel"], {
|
|
202
|
+
cwd: absolutePath,
|
|
203
|
+
}),
|
|
204
|
+
Effect.mapError(
|
|
205
|
+
() => new GitError({ message: "Failed to check if git root" }),
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if (result.exitCode !== 0) {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const gitRootReal = yield* Effect.tryPromise({
|
|
214
|
+
try: () => realpath(result.stdout),
|
|
215
|
+
catch: () => new GitError({ message: "Failed to check if git root" }),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
return gitRootReal === absolutePath
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
getGitConfig: (key: string, gitRoot: string) =>
|
|
222
|
+
pipe(
|
|
223
|
+
getGitConfigEffect(key, gitRoot),
|
|
224
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
225
|
+
),
|
|
226
|
+
|
|
227
|
+
setGitConfig: (key: string, value: string, gitRoot: string) =>
|
|
228
|
+
setGitConfigEffect(key, value, gitRoot),
|
|
229
|
+
|
|
230
|
+
getCurrentBranch: (gitRoot: string) =>
|
|
231
|
+
runGitCommandOrFail(["git", "branch", "--show-current"], gitRoot),
|
|
232
|
+
|
|
233
|
+
branchExists: (gitRoot: string, branch: string) =>
|
|
234
|
+
pipe(
|
|
235
|
+
branchExistsEffect(gitRoot, branch),
|
|
236
|
+
Effect.mapError(
|
|
237
|
+
() =>
|
|
238
|
+
new GitError({
|
|
239
|
+
message: `Failed to check if branch exists: ${branch}`,
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
|
|
244
|
+
createBranch: (
|
|
245
|
+
branchName: string,
|
|
246
|
+
gitRoot: string,
|
|
247
|
+
baseBranch?: string,
|
|
248
|
+
) => {
|
|
249
|
+
const args = ["git", "checkout", "-b", branchName]
|
|
250
|
+
if (baseBranch) {
|
|
251
|
+
args.push(baseBranch)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return runGitCommandVoid(args, gitRoot)
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
checkoutBranch: (gitRoot: string, branch: string) =>
|
|
258
|
+
runGitCommandVoid(["git", "checkout", branch], gitRoot),
|
|
259
|
+
|
|
260
|
+
gitAdd: (files: readonly string[], gitRoot: string) =>
|
|
261
|
+
runGitCommandVoid(["git", "add", ...files], gitRoot),
|
|
262
|
+
|
|
263
|
+
gitCommit: (
|
|
264
|
+
message: string,
|
|
265
|
+
gitRoot: string,
|
|
266
|
+
options?: { readonly noVerify?: boolean },
|
|
267
|
+
) => {
|
|
268
|
+
const args = ["git", "commit", "-m", message]
|
|
269
|
+
if (options?.noVerify) {
|
|
270
|
+
args.push("--no-verify")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return runGitCommandVoid(args, gitRoot)
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
getDefaultRemoteBranch: (gitRoot: string) =>
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
// Get list of remotes
|
|
279
|
+
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
280
|
+
const remotes =
|
|
281
|
+
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
282
|
+
? remotesResult.stdout.trim().split("\n")
|
|
283
|
+
: []
|
|
284
|
+
|
|
285
|
+
if (remotes.length === 0) {
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Try remotes in order of preference: origin > upstream > first
|
|
290
|
+
let remote: string
|
|
291
|
+
if (remotes.includes("origin")) {
|
|
292
|
+
remote = "origin"
|
|
293
|
+
} else if (remotes.includes("upstream")) {
|
|
294
|
+
remote = "upstream"
|
|
295
|
+
} else {
|
|
296
|
+
remote = remotes[0] || ""
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!remote) {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const result = yield* runGitCommand(
|
|
304
|
+
["git", "rev-parse", "--abbrev-ref", `${remote}/HEAD`],
|
|
305
|
+
gitRoot,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return result.exitCode === 0 ? result.stdout : null
|
|
309
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
310
|
+
|
|
311
|
+
findMainBranch: (gitRoot: string) =>
|
|
312
|
+
pipe(
|
|
313
|
+
findMainBranchEffect(gitRoot),
|
|
314
|
+
Effect.mapError(
|
|
315
|
+
() => new GitError({ message: "Failed to find main branch" }),
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
|
|
319
|
+
getSuggestedBaseBranches: (gitRoot: string) =>
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const suggestions: string[] = []
|
|
322
|
+
|
|
323
|
+
// Get the main branch from config or find it
|
|
324
|
+
const mainBranchFromConfig = yield* getGitConfigEffect(
|
|
325
|
+
"agency.mainBranch",
|
|
326
|
+
gitRoot,
|
|
327
|
+
)
|
|
328
|
+
const mainBranch =
|
|
329
|
+
mainBranchFromConfig || (yield* findMainBranchEffect(gitRoot))
|
|
330
|
+
|
|
331
|
+
if (mainBranch) {
|
|
332
|
+
suggestions.push(mainBranch)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check for other common base branches
|
|
336
|
+
const commonBases = ["develop", "development", "staging"]
|
|
337
|
+
for (const base of commonBases) {
|
|
338
|
+
const exists = yield* branchExistsEffect(gitRoot, base)
|
|
339
|
+
if (exists && !suggestions.includes(base)) {
|
|
340
|
+
suggestions.push(base)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Get current branch as a suggestion too
|
|
345
|
+
const currentBranch = yield* runGitCommandOrFail(
|
|
346
|
+
["git", "branch", "--show-current"],
|
|
347
|
+
gitRoot,
|
|
348
|
+
)
|
|
349
|
+
if (currentBranch && !suggestions.includes(currentBranch)) {
|
|
350
|
+
suggestions.push(currentBranch)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return suggestions as readonly string[]
|
|
354
|
+
}).pipe(
|
|
355
|
+
Effect.mapError(
|
|
356
|
+
() =>
|
|
357
|
+
new GitError({ message: "Failed to get suggested base branches" }),
|
|
358
|
+
),
|
|
359
|
+
),
|
|
360
|
+
|
|
361
|
+
isFeatureBranch: (currentBranch: string, gitRoot: string) =>
|
|
362
|
+
Effect.gen(function* () {
|
|
363
|
+
// Get the main branch from config or find it
|
|
364
|
+
const configBranch = yield* getGitConfigEffect(
|
|
365
|
+
"agency.mainBranch",
|
|
366
|
+
gitRoot,
|
|
367
|
+
)
|
|
368
|
+
const mainBranch =
|
|
369
|
+
configBranch || (yield* findMainBranchEffect(gitRoot))
|
|
370
|
+
|
|
371
|
+
// If we couldn't determine a main branch, assume current is a feature branch
|
|
372
|
+
if (!mainBranch) {
|
|
373
|
+
return true
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Save it for future use if we found it and it wasn't in config
|
|
377
|
+
if (!configBranch) {
|
|
378
|
+
yield* setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Handle both local (main) and remote (origin/main) references
|
|
382
|
+
// If mainBranch is "origin/main", we should also consider "main" as the main branch
|
|
383
|
+
const strippedMainBranch =
|
|
384
|
+
mainBranch.match(/^[^/]+\/(.+)$/)?.[1] || mainBranch
|
|
385
|
+
|
|
386
|
+
// Current branch is not a feature branch if it matches either the full or stripped name
|
|
387
|
+
return (
|
|
388
|
+
currentBranch !== mainBranch && currentBranch !== strippedMainBranch
|
|
389
|
+
)
|
|
390
|
+
}).pipe(
|
|
391
|
+
Effect.mapError(
|
|
392
|
+
() => new GitError({ message: "Failed to check if feature branch" }),
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
|
|
396
|
+
getMainBranchConfig: (gitRoot: string) =>
|
|
397
|
+
pipe(
|
|
398
|
+
getGitConfigEffect("agency.mainBranch", gitRoot),
|
|
399
|
+
Effect.mapError(
|
|
400
|
+
() => new GitError({ message: "Failed to get main branch config" }),
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
|
|
404
|
+
setMainBranchConfig: (mainBranch: string, gitRoot: string) =>
|
|
405
|
+
setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot),
|
|
406
|
+
|
|
407
|
+
getDefaultBaseBranchConfig: (gitRoot: string) =>
|
|
408
|
+
pipe(
|
|
409
|
+
getGitConfigEffect("agency.baseBranch", gitRoot),
|
|
410
|
+
Effect.mapError(
|
|
411
|
+
() => new GitError({ message: "Failed to get base branch config" }),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
|
|
415
|
+
setDefaultBaseBranchConfig: (baseBranch: string, gitRoot: string) =>
|
|
416
|
+
setGitConfigEffect("agency.baseBranch", baseBranch, gitRoot),
|
|
417
|
+
|
|
418
|
+
findDefaultRemote: (gitRoot: string) =>
|
|
419
|
+
pipe(
|
|
420
|
+
findDefaultRemoteEffect(gitRoot),
|
|
421
|
+
Effect.mapError(
|
|
422
|
+
() => new GitError({ message: "Failed to find default remote" }),
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
|
|
426
|
+
getRemoteConfig: (gitRoot: string) =>
|
|
427
|
+
pipe(
|
|
428
|
+
getGitConfigEffect("agency.remote", gitRoot),
|
|
429
|
+
Effect.mapError(
|
|
430
|
+
() => new GitError({ message: "Failed to get remote config" }),
|
|
431
|
+
),
|
|
432
|
+
),
|
|
433
|
+
|
|
434
|
+
setRemoteConfig: (remote: string, gitRoot: string) =>
|
|
435
|
+
setGitConfigEffect("agency.remote", remote, gitRoot),
|
|
436
|
+
|
|
437
|
+
getAllRemotes: (gitRoot: string) =>
|
|
438
|
+
pipe(
|
|
439
|
+
runGitCommand(["git", "remote"], gitRoot),
|
|
440
|
+
Effect.map((result) => {
|
|
441
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
442
|
+
return result.stdout.trim().split("\n") as readonly string[]
|
|
443
|
+
}
|
|
444
|
+
return [] as readonly string[]
|
|
445
|
+
}),
|
|
446
|
+
Effect.mapError(
|
|
447
|
+
() => new GitError({ message: "Failed to get list of remotes" }),
|
|
448
|
+
),
|
|
449
|
+
),
|
|
450
|
+
|
|
451
|
+
remoteExists: (gitRoot: string, remoteName: string) =>
|
|
452
|
+
Effect.gen(function* () {
|
|
453
|
+
const remotes = yield* pipe(
|
|
454
|
+
runGitCommand(["git", "remote"], gitRoot),
|
|
455
|
+
Effect.map((result) => {
|
|
456
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
457
|
+
return result.stdout.trim().split("\n")
|
|
458
|
+
}
|
|
459
|
+
return []
|
|
460
|
+
}),
|
|
461
|
+
)
|
|
462
|
+
return remotes.includes(remoteName)
|
|
463
|
+
}).pipe(
|
|
464
|
+
Effect.mapError(
|
|
465
|
+
() =>
|
|
466
|
+
new GitError({
|
|
467
|
+
message: `Failed to check if remote ${remoteName} exists`,
|
|
468
|
+
}),
|
|
469
|
+
),
|
|
470
|
+
),
|
|
471
|
+
|
|
472
|
+
getRemoteUrl: (gitRoot: string, remoteName: string) =>
|
|
473
|
+
runGitCommandOrFail(
|
|
474
|
+
["git", "remote", "get-url", remoteName],
|
|
475
|
+
gitRoot,
|
|
476
|
+
).pipe(
|
|
477
|
+
Effect.mapError(
|
|
478
|
+
() =>
|
|
479
|
+
new GitError({
|
|
480
|
+
message: `Failed to get URL for remote ${remoteName}`,
|
|
481
|
+
}),
|
|
482
|
+
),
|
|
483
|
+
),
|
|
484
|
+
|
|
485
|
+
resolveRemote: (gitRoot: string, providedRemote?: string) =>
|
|
486
|
+
Effect.gen(function* () {
|
|
487
|
+
// 1. If explicitly provided, validate and use it
|
|
488
|
+
if (providedRemote) {
|
|
489
|
+
const exists = yield* Effect.gen(function* () {
|
|
490
|
+
const remotes = yield* pipe(
|
|
491
|
+
runGitCommand(["git", "remote"], gitRoot),
|
|
492
|
+
Effect.map((result) => {
|
|
493
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
494
|
+
return result.stdout.trim().split("\n")
|
|
495
|
+
}
|
|
496
|
+
return []
|
|
497
|
+
}),
|
|
498
|
+
)
|
|
499
|
+
return remotes.includes(providedRemote)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
if (!exists) {
|
|
503
|
+
return yield* Effect.fail(
|
|
504
|
+
new GitError({
|
|
505
|
+
message: `Remote '${providedRemote}' does not exist`,
|
|
506
|
+
}),
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
return providedRemote
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 2. Check for saved configuration
|
|
513
|
+
const configRemote = yield* getGitConfigEffect(
|
|
514
|
+
"agency.remote",
|
|
515
|
+
gitRoot,
|
|
516
|
+
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
517
|
+
|
|
518
|
+
if (configRemote) {
|
|
519
|
+
return configRemote
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 3. Auto-detect with smart precedence
|
|
523
|
+
const remotes = yield* pipe(
|
|
524
|
+
runGitCommand(["git", "remote"], gitRoot),
|
|
525
|
+
Effect.map((result) => {
|
|
526
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
527
|
+
return result.stdout.trim().split("\n")
|
|
528
|
+
}
|
|
529
|
+
return []
|
|
530
|
+
}),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if (remotes.length === 0) {
|
|
534
|
+
return yield* Effect.fail(
|
|
535
|
+
new GitError({
|
|
536
|
+
message:
|
|
537
|
+
"No git remotes found. Add a remote with: git remote add <name> <url>",
|
|
538
|
+
}),
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (remotes.length === 1) {
|
|
543
|
+
return remotes[0]!
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Multiple remotes: prefer origin > upstream > first alphabetically
|
|
547
|
+
if (remotes.includes("origin")) {
|
|
548
|
+
return "origin"
|
|
549
|
+
}
|
|
550
|
+
if (remotes.includes("upstream")) {
|
|
551
|
+
return "upstream"
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return remotes[0]!
|
|
555
|
+
}).pipe(
|
|
556
|
+
Effect.mapError((error) =>
|
|
557
|
+
error instanceof GitError
|
|
558
|
+
? error
|
|
559
|
+
: new GitError({
|
|
560
|
+
message: "Failed to resolve remote",
|
|
561
|
+
cause: error,
|
|
562
|
+
}),
|
|
563
|
+
),
|
|
564
|
+
),
|
|
565
|
+
|
|
566
|
+
stripRemotePrefix: (branchName: string) => {
|
|
567
|
+
const match = branchName.match(/^[^/]+\/(.+)$/)
|
|
568
|
+
return match?.[1] || branchName
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
hasRemotePrefix: (branchName: string, gitRoot: string) =>
|
|
572
|
+
Effect.gen(function* () {
|
|
573
|
+
const remotes = yield* pipe(
|
|
574
|
+
runGitCommand(["git", "remote"], gitRoot),
|
|
575
|
+
Effect.map((result) => {
|
|
576
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
577
|
+
return result.stdout.trim().split("\n")
|
|
578
|
+
}
|
|
579
|
+
return []
|
|
580
|
+
}),
|
|
581
|
+
)
|
|
582
|
+
return remotes.some((remote) => branchName.startsWith(`${remote}/`))
|
|
583
|
+
}).pipe(
|
|
584
|
+
Effect.mapError(
|
|
585
|
+
() =>
|
|
586
|
+
new GitError({
|
|
587
|
+
message: `Failed to check if branch has remote prefix: ${branchName}`,
|
|
588
|
+
}),
|
|
589
|
+
),
|
|
590
|
+
),
|
|
591
|
+
|
|
592
|
+
getRemoteFromBranch: (branchName: string, gitRoot: string) =>
|
|
593
|
+
Effect.gen(function* () {
|
|
594
|
+
const remotes = yield* pipe(
|
|
595
|
+
runGitCommand(["git", "remote"], gitRoot),
|
|
596
|
+
Effect.map((result) => {
|
|
597
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
598
|
+
return result.stdout.trim().split("\n")
|
|
599
|
+
}
|
|
600
|
+
return []
|
|
601
|
+
}),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
for (const remote of remotes) {
|
|
605
|
+
if (branchName.startsWith(`${remote}/`)) {
|
|
606
|
+
return remote
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return null
|
|
611
|
+
}).pipe(
|
|
612
|
+
Effect.mapError(
|
|
613
|
+
() =>
|
|
614
|
+
new GitError({
|
|
615
|
+
message: `Failed to get remote from branch: ${branchName}`,
|
|
616
|
+
}),
|
|
617
|
+
),
|
|
618
|
+
),
|
|
619
|
+
|
|
620
|
+
getDefaultBranchForRemote: (gitRoot: string, remoteName: string) =>
|
|
621
|
+
pipe(
|
|
622
|
+
runGitCommand(
|
|
623
|
+
["git", "rev-parse", "--abbrev-ref", `${remoteName}/HEAD`],
|
|
624
|
+
gitRoot,
|
|
625
|
+
),
|
|
626
|
+
Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
|
|
627
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
628
|
+
),
|
|
629
|
+
|
|
630
|
+
getMergeBase: (gitRoot: string, branch1: string, branch2: string) =>
|
|
631
|
+
runGitCommandOrFail(["git", "merge-base", branch1, branch2], gitRoot),
|
|
632
|
+
|
|
633
|
+
getMergeBaseForkPoint: (
|
|
634
|
+
gitRoot: string,
|
|
635
|
+
baseBranch: string,
|
|
636
|
+
featureBranch: string,
|
|
637
|
+
) =>
|
|
638
|
+
runGitCommandOrFail(
|
|
639
|
+
["git", "merge-base", "--fork-point", baseBranch, featureBranch],
|
|
640
|
+
gitRoot,
|
|
641
|
+
),
|
|
642
|
+
|
|
643
|
+
deleteBranch: (gitRoot: string, branchName: string, force = false) =>
|
|
644
|
+
runGitCommandVoid(
|
|
645
|
+
["git", "branch", force ? "-D" : "-d", branchName],
|
|
646
|
+
gitRoot,
|
|
647
|
+
),
|
|
648
|
+
|
|
649
|
+
unsetGitConfig: (key: string, gitRoot: string) =>
|
|
650
|
+
pipe(
|
|
651
|
+
spawnProcess(["git", "config", "--unset", key], { cwd: gitRoot }),
|
|
652
|
+
Effect.asVoid,
|
|
653
|
+
// Ignore errors - the config might not exist, which is fine
|
|
654
|
+
Effect.mapError(
|
|
655
|
+
() => new GitError({ message: `Failed to unset config ${key}` }),
|
|
656
|
+
),
|
|
657
|
+
),
|
|
658
|
+
|
|
659
|
+
checkCommandExists: (command: string) =>
|
|
660
|
+
pipe(
|
|
661
|
+
spawnProcess(["which", command]),
|
|
662
|
+
Effect.map((result) => result.exitCode === 0),
|
|
663
|
+
Effect.mapError(
|
|
664
|
+
() =>
|
|
665
|
+
new GitError({ message: `Failed to check if ${command} exists` }),
|
|
666
|
+
),
|
|
667
|
+
),
|
|
668
|
+
|
|
669
|
+
runGitCommand: (
|
|
670
|
+
args: readonly string[],
|
|
671
|
+
gitRoot: string,
|
|
672
|
+
options?: {
|
|
673
|
+
readonly env?: Record<string, string>
|
|
674
|
+
readonly stdin?: string
|
|
675
|
+
readonly captureOutput?: boolean
|
|
676
|
+
},
|
|
677
|
+
) =>
|
|
678
|
+
pipe(
|
|
679
|
+
spawnProcess(args, {
|
|
680
|
+
cwd: gitRoot,
|
|
681
|
+
stdout: options?.captureOutput ? "pipe" : "inherit",
|
|
682
|
+
stderr: "pipe",
|
|
683
|
+
env: options?.env,
|
|
684
|
+
}),
|
|
685
|
+
Effect.mapError(
|
|
686
|
+
(processError) =>
|
|
687
|
+
new GitCommandError({
|
|
688
|
+
command: processError.command,
|
|
689
|
+
exitCode: processError.exitCode,
|
|
690
|
+
stderr: processError.stderr,
|
|
691
|
+
}),
|
|
692
|
+
),
|
|
693
|
+
),
|
|
694
|
+
|
|
695
|
+
fetch: (gitRoot: string, remote?: string, branch?: string) => {
|
|
696
|
+
const args = ["git", "fetch"]
|
|
697
|
+
if (remote) {
|
|
698
|
+
args.push(remote)
|
|
699
|
+
if (branch) {
|
|
700
|
+
args.push(branch)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return runGitCommandVoid(args, gitRoot)
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
getCommitsBetween: (gitRoot: string, base: string, head: string) =>
|
|
707
|
+
runGitCommandOrFail(
|
|
708
|
+
["git", "rev-list", "--reverse", `${base}..${head}`],
|
|
709
|
+
gitRoot,
|
|
710
|
+
),
|
|
711
|
+
|
|
712
|
+
cherryPick: (gitRoot: string, commit: string) =>
|
|
713
|
+
runGitCommandVoid(["git", "cherry-pick", commit], gitRoot),
|
|
714
|
+
|
|
715
|
+
getRemoteTrackingBranch: (gitRoot: string, branch: string) =>
|
|
716
|
+
Effect.gen(function* () {
|
|
717
|
+
const remote = yield* getGitConfigEffect(
|
|
718
|
+
`branch.${branch}.remote`,
|
|
719
|
+
gitRoot,
|
|
720
|
+
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
721
|
+
|
|
722
|
+
const merge = yield* getGitConfigEffect(
|
|
723
|
+
`branch.${branch}.merge`,
|
|
724
|
+
gitRoot,
|
|
725
|
+
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
726
|
+
|
|
727
|
+
if (!remote || !merge) {
|
|
728
|
+
return null
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Convert refs/heads/branch to remote/branch
|
|
732
|
+
const branchName = merge.replace(/^refs\/heads\//, "")
|
|
733
|
+
return `${remote}/${branchName}`
|
|
734
|
+
}),
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Read file contents from a specific git ref without checking out.
|
|
738
|
+
* Uses `git show <ref>:<path>` to read file contents directly.
|
|
739
|
+
* @param gitRoot - The git repository root
|
|
740
|
+
* @param ref - The git ref (branch name, commit, tag, etc.)
|
|
741
|
+
* @param filePath - Path to the file relative to git root
|
|
742
|
+
* @returns The file contents, or null if the file doesn't exist at that ref
|
|
743
|
+
*/
|
|
744
|
+
getFileAtRef: (gitRoot: string, ref: string, filePath: string) =>
|
|
745
|
+
pipe(
|
|
746
|
+
runGitCommand(["git", "show", `${ref}:${filePath}`], gitRoot),
|
|
747
|
+
Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
|
|
748
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
749
|
+
),
|
|
750
|
+
}),
|
|
751
|
+
}) {}
|