@markjaquith/agency 1.1.1 → 1.3.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/cli.ts +22 -0
- package/package.json +4 -2
- package/src/commands/clean.ts +4 -33
- package/src/commands/emit.integration.test.ts +277 -0
- package/src/commands/emit.test.ts +53 -208
- package/src/commands/emit.ts +5 -33
- package/src/commands/merge.integration.test.ts +195 -0
- package/src/commands/merge.test.ts +0 -119
- package/src/commands/merge.ts +3 -12
- package/src/commands/pr.ts +77 -0
- package/src/commands/push.ts +4 -15
- package/src/commands/rebase.ts +3 -11
- package/src/commands/status.ts +3 -7
- package/src/commands/tasks.ts +4 -21
- package/src/services/AgencyMetadataService.ts +3 -7
- package/src/services/FilterRepoService.ts +142 -0
- package/src/services/GitService.ts +196 -0
- package/src/services/MockFilterRepoService.ts +133 -0
- package/src/test-utils.ts +85 -2
- package/src/utils/pr-branch.ts +6 -14
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
getGitOutput,
|
|
12
12
|
getCurrentBranch,
|
|
13
13
|
createCommit,
|
|
14
|
-
branchExists,
|
|
15
14
|
checkoutBranch,
|
|
16
15
|
createBranch,
|
|
17
16
|
addAndCommit,
|
|
@@ -20,24 +19,9 @@ import {
|
|
|
20
19
|
runTestEffect,
|
|
21
20
|
} from "../test-utils"
|
|
22
21
|
|
|
23
|
-
// Cache the git-filter-repo availability check (it doesn't change during test run)
|
|
24
|
-
let hasGitFilterRepoCache: boolean | null = null
|
|
25
|
-
async function checkGitFilterRepo(): Promise<boolean> {
|
|
26
|
-
if (hasGitFilterRepoCache === null) {
|
|
27
|
-
const proc = Bun.spawn(["which", "git-filter-repo"], {
|
|
28
|
-
stdout: "pipe",
|
|
29
|
-
stderr: "pipe",
|
|
30
|
-
})
|
|
31
|
-
await proc.exited
|
|
32
|
-
hasGitFilterRepoCache = proc.exitCode === 0
|
|
33
|
-
}
|
|
34
|
-
return hasGitFilterRepoCache
|
|
35
|
-
}
|
|
36
|
-
|
|
37
22
|
describe("merge command", () => {
|
|
38
23
|
let tempDir: string
|
|
39
24
|
let originalCwd: string
|
|
40
|
-
let hasGitFilterRepo: boolean
|
|
41
25
|
|
|
42
26
|
beforeEach(async () => {
|
|
43
27
|
tempDir = await createTempDir()
|
|
@@ -49,9 +33,6 @@ describe("merge command", () => {
|
|
|
49
33
|
// Set config dir to temp dir to avoid picking up user's config files
|
|
50
34
|
process.env.AGENCY_CONFIG_DIR = await createTempDir()
|
|
51
35
|
|
|
52
|
-
// Check if git-filter-repo is available (cached)
|
|
53
|
-
hasGitFilterRepo = await checkGitFilterRepo()
|
|
54
|
-
|
|
55
36
|
// Initialize git repo with main branch (already includes initial commit)
|
|
56
37
|
await initGitRepo(tempDir)
|
|
57
38
|
|
|
@@ -93,106 +74,6 @@ describe("merge command", () => {
|
|
|
93
74
|
await cleanupTempDir(tempDir)
|
|
94
75
|
})
|
|
95
76
|
|
|
96
|
-
describe("merge from source branch", () => {
|
|
97
|
-
test("creates emit branch and merges when run from source branch", async () => {
|
|
98
|
-
if (!hasGitFilterRepo) {
|
|
99
|
-
console.log("Skipping test: git-filter-repo not installed")
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// We're on feature branch (source)
|
|
104
|
-
const currentBranch = await getCurrentBranch(tempDir)
|
|
105
|
-
expect(currentBranch).toBe("agency/feature")
|
|
106
|
-
|
|
107
|
-
// Run merge - should create feature--PR and merge it to main
|
|
108
|
-
await runTestEffect(merge({ silent: true }))
|
|
109
|
-
|
|
110
|
-
// Should be on main branch after merge
|
|
111
|
-
const afterMergeBranch = await getCurrentBranch(tempDir)
|
|
112
|
-
expect(afterMergeBranch).toBe("main")
|
|
113
|
-
|
|
114
|
-
// emit branch should exist
|
|
115
|
-
const prExists = await branchExists(tempDir, "feature")
|
|
116
|
-
expect(prExists).toBe(true)
|
|
117
|
-
|
|
118
|
-
// Main should have the feature work but not AGENTS.md
|
|
119
|
-
const files = await getGitOutput(tempDir, ["ls-files"])
|
|
120
|
-
expect(files).not.toContain("AGENTS.md")
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
test("recreates emit branch if it already exists", async () => {
|
|
124
|
-
if (!hasGitFilterRepo) {
|
|
125
|
-
console.log("Skipping test: git-filter-repo not installed")
|
|
126
|
-
return
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Create emit branch first
|
|
130
|
-
await runTestEffect(emit({ silent: true }))
|
|
131
|
-
|
|
132
|
-
// Go back to feature branch
|
|
133
|
-
await checkoutBranch(tempDir, "agency/feature")
|
|
134
|
-
|
|
135
|
-
// Make additional changes
|
|
136
|
-
await createCommit(tempDir, "More feature work")
|
|
137
|
-
|
|
138
|
-
// Run merge - should recreate emit branch with new changes
|
|
139
|
-
await runTestEffect(merge({ silent: true }))
|
|
140
|
-
|
|
141
|
-
// Should be on main after merge
|
|
142
|
-
const currentBranch = await getCurrentBranch(tempDir)
|
|
143
|
-
expect(currentBranch).toBe("main")
|
|
144
|
-
})
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
describe("merge from emit branch", () => {
|
|
148
|
-
test("merges emit branch directly when run from emit branch", async () => {
|
|
149
|
-
if (!hasGitFilterRepo) {
|
|
150
|
-
console.log("Skipping test: git-filter-repo not installed")
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Create emit branch
|
|
155
|
-
await runTestEffect(emit({ silent: true }))
|
|
156
|
-
|
|
157
|
-
// emit() now stays on source branch, so we need to checkout to emit branch
|
|
158
|
-
await checkoutBranch(tempDir, "feature")
|
|
159
|
-
|
|
160
|
-
// We're on feature--PR now
|
|
161
|
-
const currentBranch = await getCurrentBranch(tempDir)
|
|
162
|
-
expect(currentBranch).toBe("feature")
|
|
163
|
-
|
|
164
|
-
// Run merge - should merge feature--PR to main
|
|
165
|
-
await runTestEffect(merge({ silent: true }))
|
|
166
|
-
|
|
167
|
-
// Should be on main after merge
|
|
168
|
-
const afterMergeBranch = await getCurrentBranch(tempDir)
|
|
169
|
-
expect(afterMergeBranch).toBe("main")
|
|
170
|
-
|
|
171
|
-
// Main should have the feature work but not AGENTS.md
|
|
172
|
-
const files = await getGitOutput(tempDir, ["ls-files"])
|
|
173
|
-
expect(files).not.toContain("AGENTS.md")
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
test("throws error if emit branch has no corresponding source branch", async () => {
|
|
177
|
-
if (!hasGitFilterRepo) {
|
|
178
|
-
console.log("Skipping test: git-filter-repo not installed")
|
|
179
|
-
return
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Create emit branch
|
|
183
|
-
await runTestEffect(emit({ silent: true }))
|
|
184
|
-
|
|
185
|
-
// pr() now stays on source branch, so checkout to emit branch
|
|
186
|
-
await checkoutBranch(tempDir, "feature")
|
|
187
|
-
|
|
188
|
-
// Delete the source branch
|
|
189
|
-
await deleteBranch(tempDir, "agency/feature", true)
|
|
190
|
-
|
|
191
|
-
// Try to merge - should fail (error message may vary since source branch is deleted)
|
|
192
|
-
await expect(runTestEffect(merge({ silent: true }))).rejects.toThrow()
|
|
193
|
-
})
|
|
194
|
-
})
|
|
195
|
-
|
|
196
77
|
describe("error handling", () => {
|
|
197
78
|
test("throws error when not in a git repository", async () => {
|
|
198
79
|
const nonGitDir = await createTempDir()
|
package/src/commands/merge.ts
CHANGED
|
@@ -29,18 +29,13 @@ const mergeBranchEffect = (
|
|
|
29
29
|
) =>
|
|
30
30
|
Effect.gen(function* () {
|
|
31
31
|
const git = yield* GitService
|
|
32
|
-
const args = squash
|
|
33
|
-
? ["git", "merge", "--squash", branch]
|
|
34
|
-
: ["git", "merge", branch]
|
|
35
32
|
|
|
36
|
-
const result = yield* git.
|
|
37
|
-
captureOutput: true,
|
|
38
|
-
})
|
|
33
|
+
const result = yield* git.merge(gitRoot, branch, { squash })
|
|
39
34
|
|
|
40
35
|
if (result.exitCode !== 0) {
|
|
41
36
|
return yield* Effect.fail(
|
|
42
37
|
new GitCommandError({
|
|
43
|
-
command:
|
|
38
|
+
command: `git merge${squash ? " --squash" : ""} ${branch}`,
|
|
44
39
|
exitCode: result.exitCode,
|
|
45
40
|
stderr: result.stderr,
|
|
46
41
|
}),
|
|
@@ -188,11 +183,7 @@ export const merge = (options: MergeOptions = {}) =>
|
|
|
188
183
|
`Pushing ${highlight.branch(baseBranchToMergeInto)} to ${highlight.remote(remote)}...`,
|
|
189
184
|
)
|
|
190
185
|
|
|
191
|
-
const pushResult = yield* git.
|
|
192
|
-
["git", "push", remote, baseBranchToMergeInto],
|
|
193
|
-
gitRoot,
|
|
194
|
-
{ captureOutput: true },
|
|
195
|
-
)
|
|
186
|
+
const pushResult = yield* git.push(gitRoot, remote, baseBranchToMergeInto)
|
|
196
187
|
|
|
197
188
|
if (pushResult.exitCode !== 0) {
|
|
198
189
|
return yield* Effect.fail(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
3
|
+
import { GitService } from "../services/GitService"
|
|
4
|
+
import { ConfigService } from "../services/ConfigService"
|
|
5
|
+
import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
|
|
6
|
+
import { ensureGitRepo } from "../utils/effect"
|
|
7
|
+
|
|
8
|
+
interface PrOptions extends BaseCommandOptions {
|
|
9
|
+
/** Arguments to pass to gh pr (subcommand and flags) */
|
|
10
|
+
args: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const pr = (options: PrOptions) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const git = yield* GitService
|
|
16
|
+
const configService = yield* ConfigService
|
|
17
|
+
|
|
18
|
+
const gitRoot = yield* ensureGitRepo()
|
|
19
|
+
|
|
20
|
+
// Load config
|
|
21
|
+
const config = yield* configService.loadConfig()
|
|
22
|
+
|
|
23
|
+
// Get current branch and resolve the branch pair
|
|
24
|
+
const currentBranch = yield* git.getCurrentBranch(gitRoot)
|
|
25
|
+
const branches = yield* resolveBranchPairWithAgencyJson(
|
|
26
|
+
gitRoot,
|
|
27
|
+
currentBranch,
|
|
28
|
+
config.sourceBranchPattern,
|
|
29
|
+
config.emitBranch,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Build the gh pr command with the emit branch
|
|
33
|
+
const ghArgs = ["gh", "pr", ...options.args, branches.emitBranch]
|
|
34
|
+
|
|
35
|
+
// Run gh pr with stdio inherited so output goes directly to terminal
|
|
36
|
+
const exitCode = yield* Effect.tryPromise({
|
|
37
|
+
try: async () => {
|
|
38
|
+
const proc = Bun.spawn(ghArgs, {
|
|
39
|
+
cwd: gitRoot,
|
|
40
|
+
stdin: "inherit",
|
|
41
|
+
stdout: "inherit",
|
|
42
|
+
stderr: "inherit",
|
|
43
|
+
})
|
|
44
|
+
return proc.exited
|
|
45
|
+
},
|
|
46
|
+
catch: (error) =>
|
|
47
|
+
new Error(
|
|
48
|
+
`Failed to run gh pr: ${error instanceof Error ? error.message : String(error)}`,
|
|
49
|
+
),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
return yield* Effect.fail(
|
|
54
|
+
new Error(`gh pr command exited with code ${exitCode}`),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export const help = `
|
|
60
|
+
Usage: agency pr <subcommand> [flags]
|
|
61
|
+
|
|
62
|
+
Wrapper for 'gh pr' that automatically appends the emitted branch name.
|
|
63
|
+
|
|
64
|
+
This command passes all arguments to 'gh pr' with the emitted branch name
|
|
65
|
+
appended, making it easy to work with PRs for your feature branch without
|
|
66
|
+
needing to remember or type the emit branch name.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
agency pr view --web # gh pr view --web <emit-branch>
|
|
70
|
+
agency pr checks # gh pr checks <emit-branch>
|
|
71
|
+
agency pr status # gh pr status <emit-branch>
|
|
72
|
+
|
|
73
|
+
Notes:
|
|
74
|
+
- Requires gh CLI to be installed and authenticated
|
|
75
|
+
- Uses source and emit patterns from ~/.config/agency/agency.json
|
|
76
|
+
- Respects emitBranch field in agency.json when present
|
|
77
|
+
`
|
package/src/commands/push.ts
CHANGED
|
@@ -121,10 +121,7 @@ const pushCore = (gitRoot: string, options: PushOptions) =>
|
|
|
121
121
|
|
|
122
122
|
// If skipFilter, we skipped emit() so we must create the emit branch manually
|
|
123
123
|
if (options.skipFilter) {
|
|
124
|
-
yield* git.
|
|
125
|
-
["git", "checkout", "-q", "-B", emitBranchName, sourceBranch],
|
|
126
|
-
gitRoot,
|
|
127
|
-
)
|
|
124
|
+
yield* git.createOrResetBranch(gitRoot, emitBranchName, sourceBranch)
|
|
128
125
|
// Switch back to source branch for consistency
|
|
129
126
|
yield* git.checkoutBranch(gitRoot, sourceBranch)
|
|
130
127
|
}
|
|
@@ -207,13 +204,11 @@ const pushBranchToRemoteEffect = (
|
|
|
207
204
|
) =>
|
|
208
205
|
Effect.gen(function* () {
|
|
209
206
|
const git = yield* GitService
|
|
210
|
-
const { force = false
|
|
207
|
+
const { force = false } = options
|
|
211
208
|
|
|
212
209
|
// Try pushing without force first
|
|
213
210
|
const pushResult = yield* git
|
|
214
|
-
.
|
|
215
|
-
captureOutput: !verbose,
|
|
216
|
-
})
|
|
211
|
+
.push(gitRoot, remote, branchName, { setUpstream: true })
|
|
217
212
|
.pipe(
|
|
218
213
|
Effect.catchAll((error: any) =>
|
|
219
214
|
Effect.succeed({
|
|
@@ -240,13 +235,7 @@ const pushBranchToRemoteEffect = (
|
|
|
240
235
|
if (needsForce && force) {
|
|
241
236
|
// User provided --force flag, retry with force
|
|
242
237
|
const forceResult = yield* git
|
|
243
|
-
.
|
|
244
|
-
["git", "push", "-u", "--force", remote, branchName],
|
|
245
|
-
gitRoot,
|
|
246
|
-
{
|
|
247
|
-
captureOutput: !verbose,
|
|
248
|
-
},
|
|
249
|
-
)
|
|
238
|
+
.push(gitRoot, remote, branchName, { setUpstream: true, force: true })
|
|
250
239
|
.pipe(
|
|
251
240
|
Effect.catchAll((error: any) =>
|
|
252
241
|
Effect.succeed({
|
package/src/commands/rebase.ts
CHANGED
|
@@ -60,13 +60,9 @@ const rebaseCore = (gitRoot: string, options: RebaseOptions) =>
|
|
|
60
60
|
verboseLog(`Branch is an agency source branch`)
|
|
61
61
|
|
|
62
62
|
// Check for uncommitted changes
|
|
63
|
-
const
|
|
64
|
-
["git", "status", "--porcelain"],
|
|
65
|
-
gitRoot,
|
|
66
|
-
{ captureOutput: true },
|
|
67
|
-
)
|
|
63
|
+
const statusOutput = yield* git.getStatus(gitRoot)
|
|
68
64
|
|
|
69
|
-
if (
|
|
65
|
+
if (statusOutput && statusOutput.trim().length > 0) {
|
|
70
66
|
return yield* Effect.fail(
|
|
71
67
|
new Error(
|
|
72
68
|
`You have uncommitted changes. Please commit or stash them before rebasing.\n` +
|
|
@@ -114,11 +110,7 @@ const rebaseCore = (gitRoot: string, options: RebaseOptions) =>
|
|
|
114
110
|
const rebaseOperation = Effect.gen(function* () {
|
|
115
111
|
verboseLog(`Running: git rebase ${baseBranch}`)
|
|
116
112
|
|
|
117
|
-
const result = yield* git.
|
|
118
|
-
["git", "rebase", baseBranch],
|
|
119
|
-
gitRoot,
|
|
120
|
-
{ captureOutput: true },
|
|
121
|
-
)
|
|
113
|
+
const result = yield* git.rebase(gitRoot, baseBranch)
|
|
122
114
|
|
|
123
115
|
if (result.exitCode !== 0) {
|
|
124
116
|
return yield* Effect.fail(
|
package/src/commands/status.ts
CHANGED
|
@@ -65,17 +65,13 @@ const readAgencyMetadataFromBranch = (gitRoot: string, branch: string) =>
|
|
|
65
65
|
const git = yield* GitService
|
|
66
66
|
|
|
67
67
|
// Try to read agency.json from the branch using git show
|
|
68
|
-
const
|
|
69
|
-
["git", "show", `${branch}:agency.json`],
|
|
70
|
-
gitRoot,
|
|
71
|
-
{ captureOutput: true },
|
|
72
|
-
)
|
|
68
|
+
const content = yield* git.getFileAtRef(gitRoot, branch, "agency.json")
|
|
73
69
|
|
|
74
|
-
if (
|
|
70
|
+
if (!content) {
|
|
75
71
|
return null
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
return yield* parseAgencyMetadata(
|
|
74
|
+
return yield* parseAgencyMetadata(content)
|
|
79
75
|
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
80
76
|
|
|
81
77
|
/**
|
package/src/commands/tasks.ts
CHANGED
|
@@ -28,17 +28,13 @@ const readAgencyMetadataFromBranch = (gitRoot: string, branch: string) =>
|
|
|
28
28
|
const git = yield* GitService
|
|
29
29
|
|
|
30
30
|
// Try to read agency.json from the branch using git show
|
|
31
|
-
const
|
|
32
|
-
["git", "show", `${branch}:agency.json`],
|
|
33
|
-
gitRoot,
|
|
34
|
-
{ captureOutput: true },
|
|
35
|
-
)
|
|
31
|
+
const content = yield* git.getFileAtRef(gitRoot, branch, "agency.json")
|
|
36
32
|
|
|
37
|
-
if (
|
|
33
|
+
if (!content) {
|
|
38
34
|
return null
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
return yield* parseAgencyMetadata(
|
|
37
|
+
return yield* parseAgencyMetadata(content)
|
|
42
38
|
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
43
39
|
|
|
44
40
|
/**
|
|
@@ -73,20 +69,7 @@ const findAllTaskBranches = (gitRoot: string) =>
|
|
|
73
69
|
const git = yield* GitService
|
|
74
70
|
|
|
75
71
|
// Get all local branches
|
|
76
|
-
const
|
|
77
|
-
["git", "branch", "--format=%(refname:short)"],
|
|
78
|
-
gitRoot,
|
|
79
|
-
{ captureOutput: true },
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
if (branchesResult.exitCode !== 0 || !branchesResult.stdout) {
|
|
83
|
-
return []
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const branches = branchesResult.stdout
|
|
87
|
-
.split("\n")
|
|
88
|
-
.map((b) => b.trim())
|
|
89
|
-
.filter((b) => b.length > 0)
|
|
72
|
+
const branches = yield* git.getAllLocalBranches(gitRoot)
|
|
90
73
|
|
|
91
74
|
// For each branch, try to read agency.json
|
|
92
75
|
const taskBranches: TaskBranchInfo[] = []
|
|
@@ -129,17 +129,13 @@ export const AgencyMetadataServiceLive = Layer.succeed(
|
|
|
129
129
|
const git = yield* GitService
|
|
130
130
|
|
|
131
131
|
// Try to read agency.json from the branch using git show
|
|
132
|
-
const
|
|
133
|
-
["git", "show", `${branch}:agency.json`],
|
|
134
|
-
gitRoot,
|
|
135
|
-
{ captureOutput: true },
|
|
136
|
-
)
|
|
132
|
+
const content = yield* git.getFileAtRef(gitRoot, branch, "agency.json")
|
|
137
133
|
|
|
138
|
-
if (
|
|
134
|
+
if (!content) {
|
|
139
135
|
return null
|
|
140
136
|
}
|
|
141
137
|
|
|
142
|
-
return yield* parseAgencyMetadata(
|
|
138
|
+
return yield* parseAgencyMetadata(content)
|
|
143
139
|
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
144
140
|
|
|
145
141
|
write: (gitRoot: string, metadata: AgencyMetadata) =>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Effect, Data, pipe } from "effect"
|
|
2
|
+
import { spawnProcess } from "../utils/process"
|
|
3
|
+
|
|
4
|
+
// Error types for FilterRepo operations
|
|
5
|
+
class FilterRepoError extends Data.TaggedError("FilterRepoError")<{
|
|
6
|
+
message: string
|
|
7
|
+
cause?: unknown
|
|
8
|
+
}> {}
|
|
9
|
+
|
|
10
|
+
class FilterRepoNotInstalledError extends Data.TaggedError(
|
|
11
|
+
"FilterRepoNotInstalledError",
|
|
12
|
+
)<{
|
|
13
|
+
message: string
|
|
14
|
+
}> {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Service for git-filter-repo operations.
|
|
18
|
+
* git-filter-repo is an external Python tool for rewriting git history.
|
|
19
|
+
*/
|
|
20
|
+
export class FilterRepoService extends Effect.Service<FilterRepoService>()(
|
|
21
|
+
"FilterRepoService",
|
|
22
|
+
{
|
|
23
|
+
sync: () => ({
|
|
24
|
+
/**
|
|
25
|
+
* Check if git-filter-repo is installed.
|
|
26
|
+
* @returns true if installed, false otherwise
|
|
27
|
+
*/
|
|
28
|
+
isInstalled: () =>
|
|
29
|
+
pipe(
|
|
30
|
+
spawnProcess(["which", "git-filter-repo"]),
|
|
31
|
+
Effect.map((result) => result.exitCode === 0),
|
|
32
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
33
|
+
),
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run git-filter-repo with the given arguments.
|
|
37
|
+
* @param gitRoot - The git repository root
|
|
38
|
+
* @param args - Arguments to pass to git-filter-repo
|
|
39
|
+
* @param options - Additional options
|
|
40
|
+
* @returns Object with exitCode, stdout, stderr
|
|
41
|
+
*/
|
|
42
|
+
run: (
|
|
43
|
+
gitRoot: string,
|
|
44
|
+
args: readonly string[],
|
|
45
|
+
options?: {
|
|
46
|
+
readonly env?: Record<string, string>
|
|
47
|
+
},
|
|
48
|
+
) =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
// First check if git-filter-repo is installed
|
|
51
|
+
const installed = yield* pipe(
|
|
52
|
+
spawnProcess(["which", "git-filter-repo"]),
|
|
53
|
+
Effect.map((result) => result.exitCode === 0),
|
|
54
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (!installed) {
|
|
58
|
+
return yield* Effect.fail(
|
|
59
|
+
new FilterRepoNotInstalledError({
|
|
60
|
+
message:
|
|
61
|
+
"git-filter-repo is not installed. Install it with: pip install git-filter-repo",
|
|
62
|
+
}),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fullArgs = ["git-filter-repo", ...args]
|
|
67
|
+
|
|
68
|
+
const result = yield* pipe(
|
|
69
|
+
spawnProcess(fullArgs, {
|
|
70
|
+
cwd: gitRoot,
|
|
71
|
+
env: options?.env,
|
|
72
|
+
}),
|
|
73
|
+
Effect.mapError(
|
|
74
|
+
(error) =>
|
|
75
|
+
new FilterRepoError({
|
|
76
|
+
message: `git-filter-repo failed: ${error.stderr}`,
|
|
77
|
+
cause: error,
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
return yield* Effect.fail(
|
|
84
|
+
new FilterRepoError({
|
|
85
|
+
message: `git-filter-repo failed with exit code ${result.exitCode}: ${result.stderr}`,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result
|
|
91
|
+
}),
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Filter files from repository history.
|
|
95
|
+
* This is a higher-level operation that handles common filtering patterns.
|
|
96
|
+
* @param gitRoot - The git repository root
|
|
97
|
+
* @param options - Filtering options
|
|
98
|
+
*/
|
|
99
|
+
filterFiles: (
|
|
100
|
+
gitRoot: string,
|
|
101
|
+
options: {
|
|
102
|
+
readonly refs?: string
|
|
103
|
+
readonly pathsToRemove?: readonly string[]
|
|
104
|
+
readonly pathRenames?: readonly { from: string; to: string }[]
|
|
105
|
+
readonly force?: boolean
|
|
106
|
+
},
|
|
107
|
+
) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const args: string[] = []
|
|
110
|
+
|
|
111
|
+
if (options.refs) {
|
|
112
|
+
args.push("--refs", options.refs)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (options.pathsToRemove) {
|
|
116
|
+
for (const path of options.pathsToRemove) {
|
|
117
|
+
args.push("--invert-paths", "--path", path)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (options.pathRenames) {
|
|
122
|
+
for (const rename of options.pathRenames) {
|
|
123
|
+
args.push("--path-rename", `${rename.from}:${rename.to}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.force) {
|
|
128
|
+
args.push("--force")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Always use these safety options
|
|
132
|
+
args.push("--prune-empty=always")
|
|
133
|
+
|
|
134
|
+
const filterRepo = yield* FilterRepoService
|
|
135
|
+
|
|
136
|
+
return yield* filterRepo.run(gitRoot, args, {
|
|
137
|
+
env: { GIT_CONFIG_GLOBAL: "" },
|
|
138
|
+
})
|
|
139
|
+
}),
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
) {}
|