@markjaquith/agency 1.11.0 → 2.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.
- package/README.md +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +172 -419
- package/src/commands/work.ts +47 -166
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -518
- package/src/commands/push.ts +0 -434
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1183
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
|
@@ -1,1183 +0,0 @@
|
|
|
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
|
-
override get message(): string {
|
|
27
|
-
return (
|
|
28
|
-
this.stderr ||
|
|
29
|
-
`Git command failed with exit code ${this.exitCode}: ${this.command}`
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Error mapper for git command failures
|
|
35
|
-
const mapToGitCommandError = createErrorMapper(GitCommandError)
|
|
36
|
-
|
|
37
|
-
// Helper to run git commands with proper error handling
|
|
38
|
-
const resolveGitArgs = (args: readonly string[]) => {
|
|
39
|
-
if (args[0] === "git") {
|
|
40
|
-
// Use absolute path fallback to avoid PATH issues in test environments
|
|
41
|
-
return ["/usr/bin/git", ...args.slice(1)] as readonly string[]
|
|
42
|
-
}
|
|
43
|
-
return args
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const runGitCommand = (args: readonly string[], cwd: string) =>
|
|
47
|
-
pipe(
|
|
48
|
-
spawnProcess(resolveGitArgs(args), { cwd }),
|
|
49
|
-
Effect.mapError(
|
|
50
|
-
(processError) =>
|
|
51
|
-
new GitCommandError({
|
|
52
|
-
command: processError.command,
|
|
53
|
-
exitCode: processError.exitCode,
|
|
54
|
-
stderr: processError.stderr,
|
|
55
|
-
}),
|
|
56
|
-
),
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
// Helper to run git commands and check exit code
|
|
60
|
-
const runGitCommandOrFail = (args: readonly string[], cwd: string) =>
|
|
61
|
-
pipe(
|
|
62
|
-
runGitCommand(args, cwd),
|
|
63
|
-
Effect.flatMap(checkExitCodeAndReturnStdout(mapToGitCommandError(args))),
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
// Helper to run git commands that return void
|
|
67
|
-
const runGitCommandVoid = (args: readonly string[], cwd: string) =>
|
|
68
|
-
pipe(
|
|
69
|
-
runGitCommand(args, cwd),
|
|
70
|
-
Effect.flatMap(checkExitCodeAndReturnVoid(mapToGitCommandError(args))),
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
// Helper to get git config value
|
|
74
|
-
const getGitConfigEffect = (key: string, gitRoot: string) =>
|
|
75
|
-
pipe(
|
|
76
|
-
runGitCommand(["git", "config", "--local", "--get", key], gitRoot),
|
|
77
|
-
Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
// Helper to set git config value
|
|
81
|
-
const setGitConfigEffect = (key: string, value: string, gitRoot: string) =>
|
|
82
|
-
pipe(
|
|
83
|
-
runGitCommandVoid(["git", "config", "--local", key, value], gitRoot),
|
|
84
|
-
Effect.mapError(
|
|
85
|
-
(error) =>
|
|
86
|
-
new GitError({
|
|
87
|
-
message:
|
|
88
|
-
error instanceof GitCommandError
|
|
89
|
-
? `Failed to set git config ${key}: ${error.stderr}`
|
|
90
|
-
: `Failed to set git config ${key}`,
|
|
91
|
-
cause: error,
|
|
92
|
-
}),
|
|
93
|
-
),
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
// Helper to check if branch exists
|
|
97
|
-
const branchExistsEffect = (gitRoot: string, branch: string) =>
|
|
98
|
-
Effect.gen(function* () {
|
|
99
|
-
// Get list of remotes to dynamically check if it's a remote branch
|
|
100
|
-
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
101
|
-
const remotes =
|
|
102
|
-
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
103
|
-
? remotesResult.stdout.trim().split("\n")
|
|
104
|
-
: []
|
|
105
|
-
|
|
106
|
-
// Check if branch name starts with any remote prefix
|
|
107
|
-
const hasRemotePrefix = remotes.some((remote) =>
|
|
108
|
-
branch.startsWith(`${remote}/`),
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
const ref = hasRemotePrefix
|
|
112
|
-
? `refs/remotes/${branch}`
|
|
113
|
-
: `refs/heads/${branch}`
|
|
114
|
-
|
|
115
|
-
const result = yield* runGitCommand(
|
|
116
|
-
["git", "show-ref", "--verify", "--quiet", ref],
|
|
117
|
-
gitRoot,
|
|
118
|
-
)
|
|
119
|
-
return result.exitCode === 0
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
// Helper to find default remote
|
|
123
|
-
const findDefaultRemoteEffect = (gitRoot: string) =>
|
|
124
|
-
Effect.gen(function* () {
|
|
125
|
-
// Get list of remotes
|
|
126
|
-
const result = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
127
|
-
|
|
128
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
129
|
-
// Return first remote (usually "origin")
|
|
130
|
-
const remotes = result.stdout.trim().split("\n")
|
|
131
|
-
return remotes[0] || null
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return null
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
// Helper to find main branch (shared logic)
|
|
138
|
-
const findMainBranchEffect = (gitRoot: string) =>
|
|
139
|
-
Effect.gen(function* () {
|
|
140
|
-
// Get list of remotes
|
|
141
|
-
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
142
|
-
const remotes =
|
|
143
|
-
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
144
|
-
? remotesResult.stdout.trim().split("\n")
|
|
145
|
-
: []
|
|
146
|
-
|
|
147
|
-
// Try to resolve a remote (prefer origin > upstream > first)
|
|
148
|
-
let defaultRemote: string | null = null
|
|
149
|
-
if (remotes.length > 0) {
|
|
150
|
-
if (remotes.includes("origin")) {
|
|
151
|
-
defaultRemote = "origin"
|
|
152
|
-
} else if (remotes.includes("upstream")) {
|
|
153
|
-
defaultRemote = "upstream"
|
|
154
|
-
} else {
|
|
155
|
-
defaultRemote = remotes[0] || null
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// If we have a remote, try remote branches first (prioritize remote over local)
|
|
160
|
-
if (defaultRemote) {
|
|
161
|
-
// Check for common remote branch names
|
|
162
|
-
for (const branch of ["main", "master"]) {
|
|
163
|
-
const remoteBranch = `${defaultRemote}/${branch}`
|
|
164
|
-
const exists = yield* branchExistsEffect(gitRoot, remoteBranch)
|
|
165
|
-
if (exists) {
|
|
166
|
-
return remoteBranch
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Fall back to local branches
|
|
172
|
-
const commonBases = ["main", "master"]
|
|
173
|
-
for (const base of commonBases) {
|
|
174
|
-
const exists = yield* branchExistsEffect(gitRoot, base)
|
|
175
|
-
if (exists) {
|
|
176
|
-
return base
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return null
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
// Git Service using Effect.Service pattern
|
|
184
|
-
export class GitService extends Effect.Service<GitService>()("GitService", {
|
|
185
|
-
sync: () => ({
|
|
186
|
-
isInsideGitRepo: (path: string) =>
|
|
187
|
-
pipe(
|
|
188
|
-
spawnProcess(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
189
|
-
cwd: path,
|
|
190
|
-
}),
|
|
191
|
-
Effect.map((result) => result.exitCode === 0),
|
|
192
|
-
Effect.mapError(
|
|
193
|
-
() => new GitError({ message: "Failed to check git repo status" }),
|
|
194
|
-
),
|
|
195
|
-
),
|
|
196
|
-
|
|
197
|
-
getGitRoot: (path: string) =>
|
|
198
|
-
pipe(
|
|
199
|
-
spawnProcess(["git", "rev-parse", "--show-toplevel"], { cwd: path }),
|
|
200
|
-
Effect.flatMap((result) =>
|
|
201
|
-
result.exitCode === 0
|
|
202
|
-
? Effect.succeed(result.stdout)
|
|
203
|
-
: Effect.fail(new NotInGitRepoError({ path })),
|
|
204
|
-
),
|
|
205
|
-
Effect.mapError(() => new NotInGitRepoError({ path })),
|
|
206
|
-
),
|
|
207
|
-
|
|
208
|
-
isGitRoot: (path: string) =>
|
|
209
|
-
Effect.gen(function* () {
|
|
210
|
-
const absolutePath = yield* Effect.tryPromise({
|
|
211
|
-
try: () => realpath(resolve(path)),
|
|
212
|
-
catch: () => new GitError({ message: "Failed to check if git root" }),
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
const result = yield* pipe(
|
|
216
|
-
spawnProcess(["git", "rev-parse", "--show-toplevel"], {
|
|
217
|
-
cwd: absolutePath,
|
|
218
|
-
}),
|
|
219
|
-
Effect.mapError(
|
|
220
|
-
() => new GitError({ message: "Failed to check if git root" }),
|
|
221
|
-
),
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
if (result.exitCode !== 0) {
|
|
225
|
-
return false
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const gitRootReal = yield* Effect.tryPromise({
|
|
229
|
-
try: () => realpath(result.stdout),
|
|
230
|
-
catch: () => new GitError({ message: "Failed to check if git root" }),
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
return gitRootReal === absolutePath
|
|
234
|
-
}),
|
|
235
|
-
|
|
236
|
-
getGitConfig: (key: string, gitRoot: string) =>
|
|
237
|
-
pipe(
|
|
238
|
-
getGitConfigEffect(key, gitRoot),
|
|
239
|
-
Effect.catchAll(() => Effect.succeed(null)),
|
|
240
|
-
),
|
|
241
|
-
|
|
242
|
-
setGitConfig: (key: string, value: string, gitRoot: string) =>
|
|
243
|
-
setGitConfigEffect(key, value, gitRoot),
|
|
244
|
-
|
|
245
|
-
getCurrentBranch: (gitRoot: string) =>
|
|
246
|
-
runGitCommandOrFail(["git", "branch", "--show-current"], gitRoot),
|
|
247
|
-
|
|
248
|
-
branchExists: (gitRoot: string, branch: string) =>
|
|
249
|
-
pipe(
|
|
250
|
-
branchExistsEffect(gitRoot, branch),
|
|
251
|
-
Effect.mapError(
|
|
252
|
-
() =>
|
|
253
|
-
new GitError({
|
|
254
|
-
message: `Failed to check if branch exists: ${branch}`,
|
|
255
|
-
}),
|
|
256
|
-
),
|
|
257
|
-
),
|
|
258
|
-
|
|
259
|
-
createBranch: (
|
|
260
|
-
branchName: string,
|
|
261
|
-
gitRoot: string,
|
|
262
|
-
baseBranch?: string,
|
|
263
|
-
) => {
|
|
264
|
-
const args = ["git", "checkout", "-q", "--no-track", "-b", branchName]
|
|
265
|
-
if (baseBranch) {
|
|
266
|
-
args.push(baseBranch)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return runGitCommandVoid(args, gitRoot)
|
|
270
|
-
},
|
|
271
|
-
|
|
272
|
-
checkoutBranch: (gitRoot: string, branch: string) =>
|
|
273
|
-
runGitCommandVoid(["git", "checkout", "-q", branch], gitRoot),
|
|
274
|
-
|
|
275
|
-
gitAdd: (files: readonly string[], gitRoot: string) =>
|
|
276
|
-
runGitCommandVoid(["git", "add", ...files], gitRoot),
|
|
277
|
-
|
|
278
|
-
gitCommit: (
|
|
279
|
-
message: string,
|
|
280
|
-
gitRoot: string,
|
|
281
|
-
options?: { readonly noVerify?: boolean },
|
|
282
|
-
) => {
|
|
283
|
-
const args = ["git", "commit", "-m", message]
|
|
284
|
-
if (options?.noVerify) {
|
|
285
|
-
args.push("--no-verify")
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return runGitCommandVoid(args, gitRoot)
|
|
289
|
-
},
|
|
290
|
-
|
|
291
|
-
getDefaultRemoteBranch: (gitRoot: string) =>
|
|
292
|
-
Effect.gen(function* () {
|
|
293
|
-
// Get list of remotes
|
|
294
|
-
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
295
|
-
const remotes =
|
|
296
|
-
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
297
|
-
? remotesResult.stdout.trim().split("\n")
|
|
298
|
-
: []
|
|
299
|
-
|
|
300
|
-
if (remotes.length === 0) {
|
|
301
|
-
return null
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Try remotes in order of preference: origin > upstream > first
|
|
305
|
-
let remote: string
|
|
306
|
-
if (remotes.includes("origin")) {
|
|
307
|
-
remote = "origin"
|
|
308
|
-
} else if (remotes.includes("upstream")) {
|
|
309
|
-
remote = "upstream"
|
|
310
|
-
} else {
|
|
311
|
-
remote = remotes[0] || ""
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (!remote) {
|
|
315
|
-
return null
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const result = yield* runGitCommand(
|
|
319
|
-
["git", "rev-parse", "--abbrev-ref", `${remote}/HEAD`],
|
|
320
|
-
gitRoot,
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
return result.exitCode === 0 ? result.stdout : null
|
|
324
|
-
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
325
|
-
|
|
326
|
-
findMainBranch: (gitRoot: string) =>
|
|
327
|
-
pipe(
|
|
328
|
-
findMainBranchEffect(gitRoot),
|
|
329
|
-
Effect.mapError(
|
|
330
|
-
() => new GitError({ message: "Failed to find main branch" }),
|
|
331
|
-
),
|
|
332
|
-
),
|
|
333
|
-
|
|
334
|
-
getSuggestedBaseBranches: (gitRoot: string) =>
|
|
335
|
-
Effect.gen(function* () {
|
|
336
|
-
const suggestions: string[] = []
|
|
337
|
-
|
|
338
|
-
// Get the main branch from config or find it
|
|
339
|
-
const mainBranchFromConfig = yield* getGitConfigEffect(
|
|
340
|
-
"agency.mainBranch",
|
|
341
|
-
gitRoot,
|
|
342
|
-
)
|
|
343
|
-
const mainBranch =
|
|
344
|
-
mainBranchFromConfig || (yield* findMainBranchEffect(gitRoot))
|
|
345
|
-
|
|
346
|
-
if (mainBranch) {
|
|
347
|
-
suggestions.push(mainBranch)
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Check for other common base branches
|
|
351
|
-
const commonBases = ["develop", "development", "staging"]
|
|
352
|
-
for (const base of commonBases) {
|
|
353
|
-
const exists = yield* branchExistsEffect(gitRoot, base)
|
|
354
|
-
if (exists && !suggestions.includes(base)) {
|
|
355
|
-
suggestions.push(base)
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Get current branch as a suggestion too
|
|
360
|
-
const currentBranch = yield* runGitCommandOrFail(
|
|
361
|
-
["git", "branch", "--show-current"],
|
|
362
|
-
gitRoot,
|
|
363
|
-
)
|
|
364
|
-
if (currentBranch && !suggestions.includes(currentBranch)) {
|
|
365
|
-
suggestions.push(currentBranch)
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return suggestions as readonly string[]
|
|
369
|
-
}).pipe(
|
|
370
|
-
Effect.mapError(
|
|
371
|
-
() =>
|
|
372
|
-
new GitError({ message: "Failed to get suggested base branches" }),
|
|
373
|
-
),
|
|
374
|
-
),
|
|
375
|
-
|
|
376
|
-
isFeatureBranch: (currentBranch: string, gitRoot: string) =>
|
|
377
|
-
Effect.gen(function* () {
|
|
378
|
-
// Get the main branch from config or find it
|
|
379
|
-
const configBranch = yield* getGitConfigEffect(
|
|
380
|
-
"agency.mainBranch",
|
|
381
|
-
gitRoot,
|
|
382
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
383
|
-
const mainBranch =
|
|
384
|
-
configBranch || (yield* findMainBranchEffect(gitRoot))
|
|
385
|
-
|
|
386
|
-
// If we couldn't determine a main branch, assume current is a feature branch
|
|
387
|
-
if (!mainBranch) {
|
|
388
|
-
return true
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Save it for future use if we found it and it wasn't in config
|
|
392
|
-
if (!configBranch) {
|
|
393
|
-
yield* setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Handle both local (main) and remote (origin/main) references
|
|
397
|
-
// If mainBranch is "origin/main", we should also consider "main" as the main branch
|
|
398
|
-
const strippedMainBranch =
|
|
399
|
-
mainBranch.match(/^[^/]+\/(.+)$/)?.[1] || mainBranch
|
|
400
|
-
|
|
401
|
-
// If currentBranch is empty (detached HEAD), check if HEAD points to main branch
|
|
402
|
-
if (!currentBranch) {
|
|
403
|
-
const headCommit = yield* runGitCommandOrFail(
|
|
404
|
-
["git", "rev-parse", "HEAD"],
|
|
405
|
-
gitRoot,
|
|
406
|
-
)
|
|
407
|
-
const headSha = headCommit.trim()
|
|
408
|
-
|
|
409
|
-
// Check if HEAD matches the configured main branch
|
|
410
|
-
const mainCommitResult = yield* spawnProcess(
|
|
411
|
-
["git", "rev-parse", mainBranch],
|
|
412
|
-
{ cwd: gitRoot },
|
|
413
|
-
)
|
|
414
|
-
if (
|
|
415
|
-
mainCommitResult.exitCode === 0 &&
|
|
416
|
-
headSha === mainCommitResult.stdout.trim()
|
|
417
|
-
) {
|
|
418
|
-
return false
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// If mainBranch doesn't have a remote prefix, also check the remote version
|
|
422
|
-
if (!mainBranch.includes("/")) {
|
|
423
|
-
const remote = yield* findDefaultRemoteEffect(gitRoot).pipe(
|
|
424
|
-
Effect.catchAll(() => Effect.succeed(null)),
|
|
425
|
-
)
|
|
426
|
-
if (remote) {
|
|
427
|
-
const remoteBranch = `${remote}/${mainBranch}`
|
|
428
|
-
const remoteCommitResult = yield* spawnProcess(
|
|
429
|
-
["git", "rev-parse", remoteBranch],
|
|
430
|
-
{ cwd: gitRoot },
|
|
431
|
-
)
|
|
432
|
-
if (
|
|
433
|
-
remoteCommitResult.exitCode === 0 &&
|
|
434
|
-
headSha === remoteCommitResult.stdout.trim()
|
|
435
|
-
) {
|
|
436
|
-
return false
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return true
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Current branch is not a feature branch if it matches either the full or stripped name
|
|
445
|
-
return (
|
|
446
|
-
currentBranch !== mainBranch && currentBranch !== strippedMainBranch
|
|
447
|
-
)
|
|
448
|
-
}).pipe(
|
|
449
|
-
Effect.mapError(
|
|
450
|
-
() => new GitError({ message: "Failed to check if feature branch" }),
|
|
451
|
-
),
|
|
452
|
-
),
|
|
453
|
-
|
|
454
|
-
getMainBranchConfig: (gitRoot: string) =>
|
|
455
|
-
pipe(
|
|
456
|
-
getGitConfigEffect("agency.mainBranch", gitRoot),
|
|
457
|
-
Effect.mapError(
|
|
458
|
-
() => new GitError({ message: "Failed to get main branch config" }),
|
|
459
|
-
),
|
|
460
|
-
),
|
|
461
|
-
|
|
462
|
-
setMainBranchConfig: (mainBranch: string, gitRoot: string) =>
|
|
463
|
-
setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot),
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Resolves the main branch, preferring remote branches over local ones.
|
|
467
|
-
*
|
|
468
|
-
* Logic:
|
|
469
|
-
* 1. Get the configured main branch (e.g., "main")
|
|
470
|
-
* 2. If not configured, use findMainBranch() which already prefers remote
|
|
471
|
-
* 3. If configured branch doesn't have a remote prefix, try to find a remote version
|
|
472
|
-
* 4. Check if ${remote}/${branch} exists (e.g., "origin/main")
|
|
473
|
-
* 5. Use remote version if it exists, fall back to local if not
|
|
474
|
-
*
|
|
475
|
-
* This ensures we always use the most up-to-date code from the remote
|
|
476
|
-
* when creating new branches.
|
|
477
|
-
*/
|
|
478
|
-
resolveMainBranch: (gitRoot: string) =>
|
|
479
|
-
Effect.gen(function* () {
|
|
480
|
-
// Get the configured main branch
|
|
481
|
-
const configuredBranch = yield* getGitConfigEffect(
|
|
482
|
-
"agency.mainBranch",
|
|
483
|
-
gitRoot,
|
|
484
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
485
|
-
|
|
486
|
-
// If no config, use findMainBranch which already prefers remote
|
|
487
|
-
if (!configuredBranch) {
|
|
488
|
-
return yield* findMainBranchEffect(gitRoot)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Check if the configured branch already has a remote prefix (e.g., "origin/main")
|
|
492
|
-
const hasRemotePrefix = configuredBranch.includes("/")
|
|
493
|
-
if (hasRemotePrefix) {
|
|
494
|
-
// Already a remote branch, use as-is
|
|
495
|
-
return configuredBranch
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Try to resolve the configured remote
|
|
499
|
-
const configuredRemote = yield* getGitConfigEffect(
|
|
500
|
-
"agency.remote",
|
|
501
|
-
gitRoot,
|
|
502
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
503
|
-
|
|
504
|
-
// If no configured remote, try to find the default remote
|
|
505
|
-
const remote =
|
|
506
|
-
configuredRemote || (yield* findDefaultRemoteEffect(gitRoot))
|
|
507
|
-
|
|
508
|
-
if (remote) {
|
|
509
|
-
// Check if the remote version of the branch exists
|
|
510
|
-
const remoteBranch = `${remote}/${configuredBranch}`
|
|
511
|
-
const remoteExists = yield* branchExistsEffect(gitRoot, remoteBranch)
|
|
512
|
-
if (remoteExists) {
|
|
513
|
-
return remoteBranch
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Fall back to the configured local branch
|
|
518
|
-
return configuredBranch
|
|
519
|
-
}).pipe(
|
|
520
|
-
Effect.mapError(
|
|
521
|
-
() => new GitError({ message: "Failed to resolve main branch" }),
|
|
522
|
-
),
|
|
523
|
-
),
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Gets just the main branch name (e.g., "main" or "master") without any remote prefix.
|
|
527
|
-
*
|
|
528
|
-
* This is useful when you need to fetch a specific branch from a remote,
|
|
529
|
-
* as `git fetch origin main` requires just the branch name, not `origin/main`.
|
|
530
|
-
*/
|
|
531
|
-
getMainBranchName: (gitRoot: string) =>
|
|
532
|
-
Effect.gen(function* () {
|
|
533
|
-
// Get the configured main branch
|
|
534
|
-
const configuredBranch = yield* getGitConfigEffect(
|
|
535
|
-
"agency.mainBranch",
|
|
536
|
-
gitRoot,
|
|
537
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
538
|
-
|
|
539
|
-
// If configured, strip any remote prefix and return
|
|
540
|
-
if (configuredBranch) {
|
|
541
|
-
// Strip remote prefix if present (e.g., "origin/main" -> "main")
|
|
542
|
-
return (
|
|
543
|
-
configuredBranch.match(/^[^/]+\/(.+)$/)?.[1] || configuredBranch
|
|
544
|
-
)
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Otherwise, find the main branch by checking what exists
|
|
548
|
-
// We need to check for common branch names that exist (local or remote)
|
|
549
|
-
const remotesResult = yield* runGitCommand(["git", "remote"], gitRoot)
|
|
550
|
-
const remotes =
|
|
551
|
-
remotesResult.exitCode === 0 && remotesResult.stdout.trim()
|
|
552
|
-
? remotesResult.stdout.trim().split("\n")
|
|
553
|
-
: []
|
|
554
|
-
|
|
555
|
-
// Determine the default remote
|
|
556
|
-
let defaultRemote: string | null = null
|
|
557
|
-
if (remotes.length > 0) {
|
|
558
|
-
if (remotes.includes("origin")) {
|
|
559
|
-
defaultRemote = "origin"
|
|
560
|
-
} else if (remotes.includes("upstream")) {
|
|
561
|
-
defaultRemote = "upstream"
|
|
562
|
-
} else {
|
|
563
|
-
defaultRemote = remotes[0] || null
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Check for common branch names (either remote or local)
|
|
568
|
-
for (const branchName of ["main", "master"]) {
|
|
569
|
-
// Check remote version first
|
|
570
|
-
if (defaultRemote) {
|
|
571
|
-
const remoteBranch = `${defaultRemote}/${branchName}`
|
|
572
|
-
const exists = yield* branchExistsEffect(gitRoot, remoteBranch)
|
|
573
|
-
if (exists) {
|
|
574
|
-
return branchName
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
// Check local version
|
|
578
|
-
const localExists = yield* branchExistsEffect(gitRoot, branchName)
|
|
579
|
-
if (localExists) {
|
|
580
|
-
return branchName
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
return null
|
|
585
|
-
}).pipe(
|
|
586
|
-
Effect.mapError(
|
|
587
|
-
() => new GitError({ message: "Failed to get main branch name" }),
|
|
588
|
-
),
|
|
589
|
-
),
|
|
590
|
-
|
|
591
|
-
getDefaultBaseBranchConfig: (gitRoot: string) =>
|
|
592
|
-
pipe(
|
|
593
|
-
getGitConfigEffect("agency.baseBranch", gitRoot),
|
|
594
|
-
Effect.mapError(
|
|
595
|
-
() => new GitError({ message: "Failed to get base branch config" }),
|
|
596
|
-
),
|
|
597
|
-
),
|
|
598
|
-
|
|
599
|
-
setDefaultBaseBranchConfig: (baseBranch: string, gitRoot: string) =>
|
|
600
|
-
setGitConfigEffect("agency.baseBranch", baseBranch, gitRoot),
|
|
601
|
-
|
|
602
|
-
findDefaultRemote: (gitRoot: string) =>
|
|
603
|
-
pipe(
|
|
604
|
-
findDefaultRemoteEffect(gitRoot),
|
|
605
|
-
Effect.mapError(
|
|
606
|
-
() => new GitError({ message: "Failed to find default remote" }),
|
|
607
|
-
),
|
|
608
|
-
),
|
|
609
|
-
|
|
610
|
-
getRemoteConfig: (gitRoot: string) =>
|
|
611
|
-
pipe(
|
|
612
|
-
getGitConfigEffect("agency.remote", gitRoot),
|
|
613
|
-
Effect.mapError(
|
|
614
|
-
() => new GitError({ message: "Failed to get remote config" }),
|
|
615
|
-
),
|
|
616
|
-
),
|
|
617
|
-
|
|
618
|
-
setRemoteConfig: (remote: string, gitRoot: string) =>
|
|
619
|
-
setGitConfigEffect("agency.remote", remote, gitRoot),
|
|
620
|
-
|
|
621
|
-
getAllRemotes: (gitRoot: string) =>
|
|
622
|
-
pipe(
|
|
623
|
-
runGitCommand(["git", "remote"], gitRoot),
|
|
624
|
-
Effect.map((result) => {
|
|
625
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
626
|
-
return result.stdout.trim().split("\n") as readonly string[]
|
|
627
|
-
}
|
|
628
|
-
return [] as readonly string[]
|
|
629
|
-
}),
|
|
630
|
-
Effect.mapError(
|
|
631
|
-
() => new GitError({ message: "Failed to get list of remotes" }),
|
|
632
|
-
),
|
|
633
|
-
),
|
|
634
|
-
|
|
635
|
-
remoteExists: (gitRoot: string, remoteName: string) =>
|
|
636
|
-
Effect.gen(function* () {
|
|
637
|
-
const remotes = yield* pipe(
|
|
638
|
-
runGitCommand(["git", "remote"], gitRoot),
|
|
639
|
-
Effect.map((result) => {
|
|
640
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
641
|
-
return result.stdout.trim().split("\n")
|
|
642
|
-
}
|
|
643
|
-
return []
|
|
644
|
-
}),
|
|
645
|
-
)
|
|
646
|
-
return remotes.includes(remoteName)
|
|
647
|
-
}).pipe(
|
|
648
|
-
Effect.mapError(
|
|
649
|
-
() =>
|
|
650
|
-
new GitError({
|
|
651
|
-
message: `Failed to check if remote ${remoteName} exists`,
|
|
652
|
-
}),
|
|
653
|
-
),
|
|
654
|
-
),
|
|
655
|
-
|
|
656
|
-
getRemoteUrl: (gitRoot: string, remoteName: string) =>
|
|
657
|
-
runGitCommandOrFail(
|
|
658
|
-
["git", "remote", "get-url", remoteName],
|
|
659
|
-
gitRoot,
|
|
660
|
-
).pipe(
|
|
661
|
-
Effect.mapError(
|
|
662
|
-
() =>
|
|
663
|
-
new GitError({
|
|
664
|
-
message: `Failed to get URL for remote ${remoteName}`,
|
|
665
|
-
}),
|
|
666
|
-
),
|
|
667
|
-
),
|
|
668
|
-
|
|
669
|
-
resolveRemote: (gitRoot: string, providedRemote?: string) =>
|
|
670
|
-
Effect.gen(function* () {
|
|
671
|
-
// 1. If explicitly provided, validate and use it
|
|
672
|
-
if (providedRemote) {
|
|
673
|
-
const exists = yield* Effect.gen(function* () {
|
|
674
|
-
const remotes = yield* pipe(
|
|
675
|
-
runGitCommand(["git", "remote"], gitRoot),
|
|
676
|
-
Effect.map((result) => {
|
|
677
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
678
|
-
return result.stdout.trim().split("\n")
|
|
679
|
-
}
|
|
680
|
-
return []
|
|
681
|
-
}),
|
|
682
|
-
)
|
|
683
|
-
return remotes.includes(providedRemote)
|
|
684
|
-
})
|
|
685
|
-
|
|
686
|
-
if (!exists) {
|
|
687
|
-
return yield* Effect.fail(
|
|
688
|
-
new GitError({
|
|
689
|
-
message: `Remote '${providedRemote}' does not exist`,
|
|
690
|
-
}),
|
|
691
|
-
)
|
|
692
|
-
}
|
|
693
|
-
return providedRemote
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// 2. Check for saved configuration
|
|
697
|
-
const configRemote = yield* getGitConfigEffect(
|
|
698
|
-
"agency.remote",
|
|
699
|
-
gitRoot,
|
|
700
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
701
|
-
|
|
702
|
-
if (configRemote) {
|
|
703
|
-
return configRemote
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// 3. Auto-detect with smart precedence
|
|
707
|
-
const remotes = yield* pipe(
|
|
708
|
-
runGitCommand(["git", "remote"], gitRoot),
|
|
709
|
-
Effect.map((result) => {
|
|
710
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
711
|
-
return result.stdout.trim().split("\n")
|
|
712
|
-
}
|
|
713
|
-
return []
|
|
714
|
-
}),
|
|
715
|
-
)
|
|
716
|
-
|
|
717
|
-
if (remotes.length === 0) {
|
|
718
|
-
return yield* Effect.fail(
|
|
719
|
-
new GitError({
|
|
720
|
-
message:
|
|
721
|
-
"No git remotes found. Add a remote with: git remote add <name> <url>",
|
|
722
|
-
}),
|
|
723
|
-
)
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
if (remotes.length === 1) {
|
|
727
|
-
return remotes[0]!
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// Multiple remotes: prefer origin > upstream > first alphabetically
|
|
731
|
-
if (remotes.includes("origin")) {
|
|
732
|
-
return "origin"
|
|
733
|
-
}
|
|
734
|
-
if (remotes.includes("upstream")) {
|
|
735
|
-
return "upstream"
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
return remotes[0]!
|
|
739
|
-
}).pipe(
|
|
740
|
-
Effect.mapError((error) =>
|
|
741
|
-
error instanceof GitError
|
|
742
|
-
? error
|
|
743
|
-
: new GitError({
|
|
744
|
-
message: "Failed to resolve remote",
|
|
745
|
-
cause: error,
|
|
746
|
-
}),
|
|
747
|
-
),
|
|
748
|
-
),
|
|
749
|
-
|
|
750
|
-
stripRemotePrefix: (branchName: string) => {
|
|
751
|
-
const match = branchName.match(/^[^/]+\/(.+)$/)
|
|
752
|
-
return match?.[1] || branchName
|
|
753
|
-
},
|
|
754
|
-
|
|
755
|
-
hasRemotePrefix: (branchName: string, gitRoot: string) =>
|
|
756
|
-
Effect.gen(function* () {
|
|
757
|
-
const remotes = yield* pipe(
|
|
758
|
-
runGitCommand(["git", "remote"], gitRoot),
|
|
759
|
-
Effect.map((result) => {
|
|
760
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
761
|
-
return result.stdout.trim().split("\n")
|
|
762
|
-
}
|
|
763
|
-
return []
|
|
764
|
-
}),
|
|
765
|
-
)
|
|
766
|
-
return remotes.some((remote) => branchName.startsWith(`${remote}/`))
|
|
767
|
-
}).pipe(
|
|
768
|
-
Effect.mapError(
|
|
769
|
-
() =>
|
|
770
|
-
new GitError({
|
|
771
|
-
message: `Failed to check if branch has remote prefix: ${branchName}`,
|
|
772
|
-
}),
|
|
773
|
-
),
|
|
774
|
-
),
|
|
775
|
-
|
|
776
|
-
getRemoteFromBranch: (branchName: string, gitRoot: string) =>
|
|
777
|
-
Effect.gen(function* () {
|
|
778
|
-
const remotes = yield* pipe(
|
|
779
|
-
runGitCommand(["git", "remote"], gitRoot),
|
|
780
|
-
Effect.map((result) => {
|
|
781
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
782
|
-
return result.stdout.trim().split("\n")
|
|
783
|
-
}
|
|
784
|
-
return []
|
|
785
|
-
}),
|
|
786
|
-
)
|
|
787
|
-
|
|
788
|
-
for (const remote of remotes) {
|
|
789
|
-
if (branchName.startsWith(`${remote}/`)) {
|
|
790
|
-
return remote
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
return null
|
|
795
|
-
}).pipe(
|
|
796
|
-
Effect.mapError(
|
|
797
|
-
() =>
|
|
798
|
-
new GitError({
|
|
799
|
-
message: `Failed to get remote from branch: ${branchName}`,
|
|
800
|
-
}),
|
|
801
|
-
),
|
|
802
|
-
),
|
|
803
|
-
|
|
804
|
-
getDefaultBranchForRemote: (gitRoot: string, remoteName: string) =>
|
|
805
|
-
pipe(
|
|
806
|
-
runGitCommand(
|
|
807
|
-
["git", "rev-parse", "--abbrev-ref", `${remoteName}/HEAD`],
|
|
808
|
-
gitRoot,
|
|
809
|
-
),
|
|
810
|
-
Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
|
|
811
|
-
Effect.catchAll(() => Effect.succeed(null)),
|
|
812
|
-
),
|
|
813
|
-
|
|
814
|
-
getMergeBase: (gitRoot: string, branch1: string, branch2: string) =>
|
|
815
|
-
runGitCommandOrFail(["git", "merge-base", branch1, branch2], gitRoot),
|
|
816
|
-
|
|
817
|
-
getMergeBaseForkPoint: (
|
|
818
|
-
gitRoot: string,
|
|
819
|
-
baseBranch: string,
|
|
820
|
-
featureBranch: string,
|
|
821
|
-
) =>
|
|
822
|
-
runGitCommandOrFail(
|
|
823
|
-
["git", "merge-base", "--fork-point", baseBranch, featureBranch],
|
|
824
|
-
gitRoot,
|
|
825
|
-
),
|
|
826
|
-
|
|
827
|
-
deleteBranch: (gitRoot: string, branchName: string, force = false) =>
|
|
828
|
-
runGitCommandVoid(
|
|
829
|
-
["git", "branch", force ? "-D" : "-d", branchName],
|
|
830
|
-
gitRoot,
|
|
831
|
-
),
|
|
832
|
-
|
|
833
|
-
unsetGitConfig: (key: string, gitRoot: string) =>
|
|
834
|
-
pipe(
|
|
835
|
-
spawnProcess(["git", "config", "--unset", key], { cwd: gitRoot }),
|
|
836
|
-
Effect.asVoid,
|
|
837
|
-
// Ignore errors - the config might not exist, which is fine
|
|
838
|
-
Effect.mapError(
|
|
839
|
-
() => new GitError({ message: `Failed to unset config ${key}` }),
|
|
840
|
-
),
|
|
841
|
-
),
|
|
842
|
-
|
|
843
|
-
checkCommandExists: (command: string) =>
|
|
844
|
-
pipe(
|
|
845
|
-
spawnProcess(["which", command]),
|
|
846
|
-
Effect.map((result) => result.exitCode === 0),
|
|
847
|
-
Effect.mapError(
|
|
848
|
-
() =>
|
|
849
|
-
new GitError({ message: `Failed to check if ${command} exists` }),
|
|
850
|
-
),
|
|
851
|
-
),
|
|
852
|
-
|
|
853
|
-
runGitCommand: (
|
|
854
|
-
args: readonly string[],
|
|
855
|
-
gitRoot: string,
|
|
856
|
-
options?: {
|
|
857
|
-
readonly env?: Record<string, string>
|
|
858
|
-
readonly stdin?: string
|
|
859
|
-
readonly captureOutput?: boolean
|
|
860
|
-
},
|
|
861
|
-
) =>
|
|
862
|
-
pipe(
|
|
863
|
-
spawnProcess(args, {
|
|
864
|
-
cwd: gitRoot,
|
|
865
|
-
stdout: options?.captureOutput ? "pipe" : "inherit",
|
|
866
|
-
stderr: "pipe",
|
|
867
|
-
env: options?.env,
|
|
868
|
-
}),
|
|
869
|
-
Effect.mapError(
|
|
870
|
-
(processError) =>
|
|
871
|
-
new GitCommandError({
|
|
872
|
-
command: processError.command,
|
|
873
|
-
exitCode: processError.exitCode,
|
|
874
|
-
stderr: processError.stderr,
|
|
875
|
-
}),
|
|
876
|
-
),
|
|
877
|
-
),
|
|
878
|
-
|
|
879
|
-
fetch: (gitRoot: string, remote?: string, branch?: string) => {
|
|
880
|
-
const args = ["git", "fetch"]
|
|
881
|
-
if (remote) {
|
|
882
|
-
args.push(remote)
|
|
883
|
-
if (branch) {
|
|
884
|
-
args.push(branch)
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return runGitCommandVoid(args, gitRoot)
|
|
888
|
-
},
|
|
889
|
-
|
|
890
|
-
getCommitsBetween: (gitRoot: string, base: string, head: string) =>
|
|
891
|
-
runGitCommandOrFail(
|
|
892
|
-
["git", "rev-list", "--reverse", `${base}..${head}`],
|
|
893
|
-
gitRoot,
|
|
894
|
-
),
|
|
895
|
-
|
|
896
|
-
cherryPick: (gitRoot: string, commit: string) =>
|
|
897
|
-
runGitCommandVoid(["git", "cherry-pick", commit], gitRoot),
|
|
898
|
-
|
|
899
|
-
getRemoteTrackingBranch: (gitRoot: string, branch: string) =>
|
|
900
|
-
Effect.gen(function* () {
|
|
901
|
-
const remote = yield* getGitConfigEffect(
|
|
902
|
-
`branch.${branch}.remote`,
|
|
903
|
-
gitRoot,
|
|
904
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
905
|
-
|
|
906
|
-
const merge = yield* getGitConfigEffect(
|
|
907
|
-
`branch.${branch}.merge`,
|
|
908
|
-
gitRoot,
|
|
909
|
-
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
910
|
-
|
|
911
|
-
if (!remote || !merge) {
|
|
912
|
-
return null
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// Convert refs/heads/branch to remote/branch
|
|
916
|
-
const branchName = merge.replace(/^refs\/heads\//, "")
|
|
917
|
-
return `${remote}/${branchName}`
|
|
918
|
-
}),
|
|
919
|
-
|
|
920
|
-
/**
|
|
921
|
-
* Read file contents from a specific git ref without checking out.
|
|
922
|
-
* Uses `git show <ref>:<path>` to read file contents directly.
|
|
923
|
-
* @param gitRoot - The git repository root
|
|
924
|
-
* @param ref - The git ref (branch name, commit, tag, etc.)
|
|
925
|
-
* @param filePath - Path to the file relative to git root
|
|
926
|
-
* @returns The file contents, or null if the file doesn't exist at that ref
|
|
927
|
-
*/
|
|
928
|
-
getFileAtRef: (gitRoot: string, ref: string, filePath: string) =>
|
|
929
|
-
pipe(
|
|
930
|
-
runGitCommand(["git", "show", `${ref}:${filePath}`], gitRoot),
|
|
931
|
-
Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
|
|
932
|
-
Effect.catchAll(() => Effect.succeed(null)),
|
|
933
|
-
),
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Check if a file has uncommitted changes (staged or unstaged).
|
|
937
|
-
* Uses `git diff HEAD` to check for any changes to the file.
|
|
938
|
-
* @param gitRoot - The git repository root
|
|
939
|
-
* @param filePath - Path to the file relative to git root
|
|
940
|
-
* @returns true if the file has changes, false otherwise
|
|
941
|
-
*/
|
|
942
|
-
hasUncommittedChanges: (gitRoot: string, filePath: string) =>
|
|
943
|
-
pipe(
|
|
944
|
-
runGitCommand(["git", "diff", "HEAD", "--", filePath], gitRoot),
|
|
945
|
-
Effect.map(
|
|
946
|
-
(result) => result.exitCode === 0 && result.stdout.length > 0,
|
|
947
|
-
),
|
|
948
|
-
Effect.catchAll(() => Effect.succeed(false)),
|
|
949
|
-
),
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* Get all local branch names.
|
|
953
|
-
* @param gitRoot - The git repository root
|
|
954
|
-
* @returns Array of local branch names
|
|
955
|
-
*/
|
|
956
|
-
getAllLocalBranches: (gitRoot: string) =>
|
|
957
|
-
pipe(
|
|
958
|
-
runGitCommand(["git", "branch", "--format=%(refname:short)"], gitRoot),
|
|
959
|
-
Effect.map((result) => {
|
|
960
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
961
|
-
return result.stdout.trim().split("\n") as readonly string[]
|
|
962
|
-
}
|
|
963
|
-
return [] as readonly string[]
|
|
964
|
-
}),
|
|
965
|
-
Effect.mapError(
|
|
966
|
-
() => new GitError({ message: "Failed to get local branches" }),
|
|
967
|
-
),
|
|
968
|
-
),
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Get local branch names matching a prefix.
|
|
972
|
-
* @param gitRoot - The git repository root
|
|
973
|
-
* @param prefix - The prefix to match (e.g., "agency--")
|
|
974
|
-
* @returns Array of matching local branch names
|
|
975
|
-
*/
|
|
976
|
-
getBranchesByPrefix: (gitRoot: string, prefix: string) =>
|
|
977
|
-
pipe(
|
|
978
|
-
runGitCommand(
|
|
979
|
-
[
|
|
980
|
-
"git",
|
|
981
|
-
"branch",
|
|
982
|
-
"--list",
|
|
983
|
-
`${prefix}*`,
|
|
984
|
-
"--format=%(refname:short)",
|
|
985
|
-
],
|
|
986
|
-
gitRoot,
|
|
987
|
-
),
|
|
988
|
-
Effect.map((result) => {
|
|
989
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
990
|
-
return result.stdout.trim().split("\n") as readonly string[]
|
|
991
|
-
}
|
|
992
|
-
return [] as readonly string[]
|
|
993
|
-
}),
|
|
994
|
-
Effect.mapError(
|
|
995
|
-
() =>
|
|
996
|
-
new GitError({
|
|
997
|
-
message: `Failed to get branches with prefix "${prefix}"`,
|
|
998
|
-
}),
|
|
999
|
-
),
|
|
1000
|
-
),
|
|
1001
|
-
|
|
1002
|
-
/**
|
|
1003
|
-
* Get branches that have been merged into a target branch.
|
|
1004
|
-
* @param gitRoot - The git repository root
|
|
1005
|
-
* @param targetBranch - The branch to check merges against
|
|
1006
|
-
* @returns Array of merged branch names
|
|
1007
|
-
*/
|
|
1008
|
-
getMergedBranches: (gitRoot: string, targetBranch: string) =>
|
|
1009
|
-
pipe(
|
|
1010
|
-
runGitCommand(
|
|
1011
|
-
[
|
|
1012
|
-
"git",
|
|
1013
|
-
"branch",
|
|
1014
|
-
"--merged",
|
|
1015
|
-
targetBranch,
|
|
1016
|
-
"--format=%(refname:short)",
|
|
1017
|
-
],
|
|
1018
|
-
gitRoot,
|
|
1019
|
-
),
|
|
1020
|
-
Effect.map((result) => {
|
|
1021
|
-
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
1022
|
-
return result.stdout.trim().split("\n") as readonly string[]
|
|
1023
|
-
}
|
|
1024
|
-
return [] as readonly string[]
|
|
1025
|
-
}),
|
|
1026
|
-
Effect.mapError(
|
|
1027
|
-
() =>
|
|
1028
|
-
new GitError({
|
|
1029
|
-
message: `Failed to get branches merged into ${targetBranch}`,
|
|
1030
|
-
}),
|
|
1031
|
-
),
|
|
1032
|
-
),
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* Push a branch to a remote.
|
|
1036
|
-
* @param gitRoot - The git repository root
|
|
1037
|
-
* @param remote - Remote name (e.g., "origin")
|
|
1038
|
-
* @param branch - Branch name to push
|
|
1039
|
-
* @param options - Push options
|
|
1040
|
-
* @returns Object with exitCode, stdout, stderr
|
|
1041
|
-
*/
|
|
1042
|
-
push: (
|
|
1043
|
-
gitRoot: string,
|
|
1044
|
-
remote: string,
|
|
1045
|
-
branch: string,
|
|
1046
|
-
options?: {
|
|
1047
|
-
readonly setUpstream?: boolean
|
|
1048
|
-
readonly force?: boolean
|
|
1049
|
-
readonly noVerify?: boolean
|
|
1050
|
-
},
|
|
1051
|
-
) => {
|
|
1052
|
-
const args = ["git", "push"]
|
|
1053
|
-
if (options?.setUpstream) {
|
|
1054
|
-
args.push("-u")
|
|
1055
|
-
}
|
|
1056
|
-
if (options?.force) {
|
|
1057
|
-
args.push("--force")
|
|
1058
|
-
}
|
|
1059
|
-
if (options?.noVerify) {
|
|
1060
|
-
args.push("--no-verify")
|
|
1061
|
-
}
|
|
1062
|
-
args.push(remote, branch)
|
|
1063
|
-
|
|
1064
|
-
return pipe(
|
|
1065
|
-
runGitCommand(args, gitRoot),
|
|
1066
|
-
Effect.mapError(
|
|
1067
|
-
(error) =>
|
|
1068
|
-
new GitError({
|
|
1069
|
-
message: `Failed to push ${branch} to ${remote}`,
|
|
1070
|
-
cause: error,
|
|
1071
|
-
}),
|
|
1072
|
-
),
|
|
1073
|
-
)
|
|
1074
|
-
},
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Merge a branch into the current branch.
|
|
1078
|
-
* @param gitRoot - The git repository root
|
|
1079
|
-
* @param branch - Branch to merge
|
|
1080
|
-
* @param options - Merge options
|
|
1081
|
-
* @returns Object with exitCode, stdout, stderr
|
|
1082
|
-
*/
|
|
1083
|
-
merge: (
|
|
1084
|
-
gitRoot: string,
|
|
1085
|
-
branch: string,
|
|
1086
|
-
options?: {
|
|
1087
|
-
readonly squash?: boolean
|
|
1088
|
-
readonly noCommit?: boolean
|
|
1089
|
-
readonly message?: string
|
|
1090
|
-
},
|
|
1091
|
-
) => {
|
|
1092
|
-
const args = ["git", "merge"]
|
|
1093
|
-
if (options?.squash) {
|
|
1094
|
-
args.push("--squash")
|
|
1095
|
-
}
|
|
1096
|
-
if (options?.noCommit) {
|
|
1097
|
-
args.push("--no-commit")
|
|
1098
|
-
}
|
|
1099
|
-
if (options?.message) {
|
|
1100
|
-
args.push("-m", options.message)
|
|
1101
|
-
}
|
|
1102
|
-
args.push(branch)
|
|
1103
|
-
|
|
1104
|
-
return pipe(
|
|
1105
|
-
runGitCommand(args, gitRoot),
|
|
1106
|
-
Effect.mapError(
|
|
1107
|
-
(error) =>
|
|
1108
|
-
new GitError({
|
|
1109
|
-
message: `Failed to merge ${branch}`,
|
|
1110
|
-
cause: error,
|
|
1111
|
-
}),
|
|
1112
|
-
),
|
|
1113
|
-
)
|
|
1114
|
-
},
|
|
1115
|
-
|
|
1116
|
-
/**
|
|
1117
|
-
* Rebase current branch onto a base branch.
|
|
1118
|
-
* @param gitRoot - The git repository root
|
|
1119
|
-
* @param baseBranch - Branch to rebase onto
|
|
1120
|
-
* @returns Object with exitCode, stdout, stderr
|
|
1121
|
-
*/
|
|
1122
|
-
rebase: (gitRoot: string, baseBranch: string) =>
|
|
1123
|
-
pipe(
|
|
1124
|
-
runGitCommand(["git", "rebase", baseBranch], gitRoot),
|
|
1125
|
-
Effect.mapError(
|
|
1126
|
-
(error) =>
|
|
1127
|
-
new GitError({
|
|
1128
|
-
message: `Failed to rebase onto ${baseBranch}`,
|
|
1129
|
-
cause: error,
|
|
1130
|
-
}),
|
|
1131
|
-
),
|
|
1132
|
-
),
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* Get the working tree status (porcelain format).
|
|
1136
|
-
* @param gitRoot - The git repository root
|
|
1137
|
-
* @returns Status output string
|
|
1138
|
-
*/
|
|
1139
|
-
getStatus: (gitRoot: string) =>
|
|
1140
|
-
pipe(
|
|
1141
|
-
runGitCommand(["git", "status", "--porcelain"], gitRoot),
|
|
1142
|
-
Effect.map((result) => result.stdout),
|
|
1143
|
-
Effect.mapError(
|
|
1144
|
-
() => new GitError({ message: "Failed to get git status" }),
|
|
1145
|
-
),
|
|
1146
|
-
),
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Check if one commit is an ancestor of another.
|
|
1150
|
-
* @param gitRoot - The git repository root
|
|
1151
|
-
* @param potentialAncestor - The commit that might be an ancestor
|
|
1152
|
-
* @param commit - The commit to check against
|
|
1153
|
-
* @returns true if potentialAncestor is an ancestor of commit
|
|
1154
|
-
*/
|
|
1155
|
-
isAncestor: (gitRoot: string, potentialAncestor: string, commit: string) =>
|
|
1156
|
-
pipe(
|
|
1157
|
-
runGitCommand(
|
|
1158
|
-
["git", "merge-base", "--is-ancestor", potentialAncestor, commit],
|
|
1159
|
-
gitRoot,
|
|
1160
|
-
),
|
|
1161
|
-
Effect.map((result) => result.exitCode === 0),
|
|
1162
|
-
Effect.catchAll(() => Effect.succeed(false)),
|
|
1163
|
-
),
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Create or reset a branch to point to a specific commit/branch.
|
|
1167
|
-
* Uses `git checkout -B` which creates the branch if it doesn't exist
|
|
1168
|
-
* or resets it if it does.
|
|
1169
|
-
* @param gitRoot - The git repository root
|
|
1170
|
-
* @param branchName - Name of the branch to create/reset
|
|
1171
|
-
* @param startPoint - Commit or branch to start from
|
|
1172
|
-
*/
|
|
1173
|
-
createOrResetBranch: (
|
|
1174
|
-
gitRoot: string,
|
|
1175
|
-
branchName: string,
|
|
1176
|
-
startPoint: string,
|
|
1177
|
-
) =>
|
|
1178
|
-
runGitCommandVoid(
|
|
1179
|
-
["git", "checkout", "-q", "-B", branchName, startPoint],
|
|
1180
|
-
gitRoot,
|
|
1181
|
-
),
|
|
1182
|
-
}),
|
|
1183
|
-
}) {}
|