@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.
@@ -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()
@@ -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.runGitCommand(args, gitRoot, {
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: args.join(" "),
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.runGitCommand(
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
+ `
@@ -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.runGitCommand(
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, verbose = false } = options
207
+ const { force = false } = options
211
208
 
212
209
  // Try pushing without force first
213
210
  const pushResult = yield* git
214
- .runGitCommand(["git", "push", "-u", remote, branchName], gitRoot, {
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
- .runGitCommand(
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({
@@ -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 statusResult = yield* git.runGitCommand(
64
- ["git", "status", "--porcelain"],
65
- gitRoot,
66
- { captureOutput: true },
67
- )
63
+ const statusOutput = yield* git.getStatus(gitRoot)
68
64
 
69
- if (statusResult.stdout && statusResult.stdout.trim().length > 0) {
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.runGitCommand(
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(
@@ -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 result = yield* git.runGitCommand(
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 (result.exitCode !== 0 || !result.stdout) {
70
+ if (!content) {
75
71
  return null
76
72
  }
77
73
 
78
- return yield* parseAgencyMetadata(result.stdout)
74
+ return yield* parseAgencyMetadata(content)
79
75
  }).pipe(Effect.catchAll(() => Effect.succeed(null)))
80
76
 
81
77
  /**
@@ -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 result = yield* git.runGitCommand(
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 (result.exitCode !== 0 || !result.stdout) {
33
+ if (!content) {
38
34
  return null
39
35
  }
40
36
 
41
- return yield* parseAgencyMetadata(result.stdout)
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 branchesResult = yield* git.runGitCommand(
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 result = yield* git.runGitCommand(
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 (result.exitCode !== 0 || !result.stdout) {
134
+ if (!content) {
139
135
  return null
140
136
  }
141
137
 
142
- return yield* parseAgencyMetadata(result.stdout)
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
+ ) {}