@markjaquith/agency 0.5.0 → 0.6.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.
@@ -246,7 +246,7 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
246
246
  gitRoot: string,
247
247
  baseBranch?: string,
248
248
  ) => {
249
- const args = ["git", "checkout", "-b", branchName]
249
+ const args = ["git", "checkout", "--no-track", "-b", branchName]
250
250
  if (baseBranch) {
251
251
  args.push(baseBranch)
252
252
  }
@@ -404,6 +404,66 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
404
404
  setMainBranchConfig: (mainBranch: string, gitRoot: string) =>
405
405
  setGitConfigEffect("agency.mainBranch", mainBranch, gitRoot),
406
406
 
407
+ /**
408
+ * Resolves the main branch, preferring remote branches over local ones.
409
+ *
410
+ * Logic:
411
+ * 1. Get the configured main branch (e.g., "main")
412
+ * 2. If not configured, use findMainBranch() which already prefers remote
413
+ * 3. If configured branch doesn't have a remote prefix, try to find a remote version
414
+ * 4. Check if ${remote}/${branch} exists (e.g., "origin/main")
415
+ * 5. Use remote version if it exists, fall back to local if not
416
+ *
417
+ * This ensures we always use the most up-to-date code from the remote
418
+ * when creating new branches.
419
+ */
420
+ resolveMainBranch: (gitRoot: string) =>
421
+ Effect.gen(function* () {
422
+ // Get the configured main branch
423
+ const configuredBranch = yield* getGitConfigEffect(
424
+ "agency.mainBranch",
425
+ gitRoot,
426
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
427
+
428
+ // If no config, use findMainBranch which already prefers remote
429
+ if (!configuredBranch) {
430
+ return yield* findMainBranchEffect(gitRoot)
431
+ }
432
+
433
+ // Check if the configured branch already has a remote prefix (e.g., "origin/main")
434
+ const hasRemotePrefix = configuredBranch.includes("/")
435
+ if (hasRemotePrefix) {
436
+ // Already a remote branch, use as-is
437
+ return configuredBranch
438
+ }
439
+
440
+ // Try to resolve the configured remote
441
+ const configuredRemote = yield* getGitConfigEffect(
442
+ "agency.remote",
443
+ gitRoot,
444
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
445
+
446
+ // If no configured remote, try to find the default remote
447
+ const remote =
448
+ configuredRemote || (yield* findDefaultRemoteEffect(gitRoot))
449
+
450
+ if (remote) {
451
+ // Check if the remote version of the branch exists
452
+ const remoteBranch = `${remote}/${configuredBranch}`
453
+ const remoteExists = yield* branchExistsEffect(gitRoot, remoteBranch)
454
+ if (remoteExists) {
455
+ return remoteBranch
456
+ }
457
+ }
458
+
459
+ // Fall back to the configured local branch
460
+ return configuredBranch
461
+ }).pipe(
462
+ Effect.mapError(
463
+ () => new GitError({ message: "Failed to resolve main branch" }),
464
+ ),
465
+ ),
466
+
407
467
  getDefaultBaseBranchConfig: (gitRoot: string) =>
408
468
  pipe(
409
469
  getGitConfigEffect("agency.baseBranch", gitRoot),
@@ -0,0 +1,154 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test"
2
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import {
6
+ isGlobPattern,
7
+ matchesGlob,
8
+ expandGlobs,
9
+ dirToGlobPattern,
10
+ getTopLevelDir,
11
+ } from "./glob"
12
+
13
+ describe("isGlobPattern", () => {
14
+ test("returns true for patterns with asterisk", () => {
15
+ expect(isGlobPattern("*.ts")).toBe(true)
16
+ expect(isGlobPattern("**/*.ts")).toBe(true)
17
+ expect(isGlobPattern("plans/*")).toBe(true)
18
+ expect(isGlobPattern("plans/**")).toBe(true)
19
+ })
20
+
21
+ test("returns true for patterns with question mark", () => {
22
+ expect(isGlobPattern("file?.ts")).toBe(true)
23
+ })
24
+
25
+ test("returns true for patterns with character classes", () => {
26
+ expect(isGlobPattern("[abc].ts")).toBe(true)
27
+ expect(isGlobPattern("file[0-9].ts")).toBe(true)
28
+ })
29
+
30
+ test("returns false for plain file paths", () => {
31
+ expect(isGlobPattern("foo.ts")).toBe(false)
32
+ expect(isGlobPattern("plans/README.md")).toBe(false)
33
+ expect(isGlobPattern("src/utils/glob.ts")).toBe(false)
34
+ })
35
+ })
36
+
37
+ describe("matchesGlob", () => {
38
+ test("matches files with simple wildcard", () => {
39
+ expect(matchesGlob("*.ts", "file.ts")).toBe(true)
40
+ expect(matchesGlob("*.ts", "file.js")).toBe(false)
41
+ })
42
+
43
+ test("matches files in directories with **", () => {
44
+ expect(matchesGlob("plans/**", "plans/foo.md")).toBe(true)
45
+ expect(matchesGlob("plans/**", "plans/sub/bar.md")).toBe(true)
46
+ expect(matchesGlob("plans/**", "other/foo.md")).toBe(false)
47
+ })
48
+
49
+ test("matches nested directory patterns", () => {
50
+ expect(matchesGlob("src/**/*.ts", "src/utils/glob.ts")).toBe(true)
51
+ expect(matchesGlob("src/**/*.ts", "src/glob.ts")).toBe(true)
52
+ expect(matchesGlob("src/**/*.ts", "lib/glob.ts")).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe("dirToGlobPattern", () => {
57
+ test("converts directory to glob pattern", () => {
58
+ expect(dirToGlobPattern("plans")).toBe("plans/**")
59
+ expect(dirToGlobPattern("src/components")).toBe("src/components/**")
60
+ })
61
+
62
+ test("removes trailing slash before adding glob", () => {
63
+ expect(dirToGlobPattern("plans/")).toBe("plans/**")
64
+ expect(dirToGlobPattern("plans//")).toBe("plans/**")
65
+ })
66
+ })
67
+
68
+ describe("getTopLevelDir", () => {
69
+ test("returns top-level directory from path", () => {
70
+ expect(getTopLevelDir("plans/foo.md")).toBe("plans")
71
+ expect(getTopLevelDir("plans/sub/bar.md")).toBe("plans")
72
+ expect(getTopLevelDir("src/utils/glob.ts")).toBe("src")
73
+ })
74
+
75
+ test("returns null for root-level files", () => {
76
+ expect(getTopLevelDir("README.md")).toBe(null)
77
+ expect(getTopLevelDir("file.ts")).toBe(null)
78
+ })
79
+ })
80
+
81
+ describe("expandGlobs", () => {
82
+ let tempDir: string
83
+
84
+ beforeAll(async () => {
85
+ // Create temp directory with test files
86
+ tempDir = await mkdtemp(join(tmpdir(), "glob-test-"))
87
+
88
+ // Create directory structure:
89
+ // tempDir/
90
+ // plans/
91
+ // foo.md
92
+ // sub/
93
+ // bar.md
94
+ // other/
95
+ // baz.md
96
+ // root.txt
97
+ await mkdir(join(tempDir, "plans"))
98
+ await mkdir(join(tempDir, "plans", "sub"))
99
+ await mkdir(join(tempDir, "other"))
100
+
101
+ await writeFile(join(tempDir, "plans", "foo.md"), "foo")
102
+ await writeFile(join(tempDir, "plans", "sub", "bar.md"), "bar")
103
+ await writeFile(join(tempDir, "other", "baz.md"), "baz")
104
+ await writeFile(join(tempDir, "root.txt"), "root")
105
+ })
106
+
107
+ afterAll(async () => {
108
+ await rm(tempDir, { recursive: true })
109
+ })
110
+
111
+ test("expands glob pattern to matching files", async () => {
112
+ const files = await expandGlobs(["plans/**"], tempDir)
113
+ expect(files.sort()).toEqual(["plans/foo.md", "plans/sub/bar.md"].sort())
114
+ })
115
+
116
+ test("preserves non-glob paths as-is", async () => {
117
+ const files = await expandGlobs(["root.txt"], tempDir)
118
+ expect(files).toEqual(["root.txt"])
119
+ })
120
+
121
+ test("combines glob and non-glob patterns", async () => {
122
+ const files = await expandGlobs(["plans/**", "root.txt"], tempDir)
123
+ expect(files.sort()).toEqual(
124
+ ["plans/foo.md", "plans/sub/bar.md", "root.txt"].sort(),
125
+ )
126
+ })
127
+
128
+ test("deduplicates results", async () => {
129
+ const files = await expandGlobs(
130
+ ["plans/**", "plans/foo.md", "plans/**"],
131
+ tempDir,
132
+ )
133
+ // Should not have duplicates
134
+ const uniqueFiles = [...new Set(files)]
135
+ expect(files.length).toBe(uniqueFiles.length)
136
+ })
137
+
138
+ test("returns non-glob paths even if they don't exist", async () => {
139
+ const files = await expandGlobs(["nonexistent.txt"], tempDir)
140
+ expect(files).toEqual(["nonexistent.txt"])
141
+ })
142
+
143
+ test("returns empty array for non-matching glob", async () => {
144
+ const files = await expandGlobs(["nonexistent/**"], tempDir)
145
+ expect(files).toEqual([])
146
+ })
147
+
148
+ test("handles multiple glob patterns", async () => {
149
+ const files = await expandGlobs(["plans/**", "other/**"], tempDir)
150
+ expect(files.sort()).toEqual(
151
+ ["plans/foo.md", "plans/sub/bar.md", "other/baz.md"].sort(),
152
+ )
153
+ })
154
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Utilities for glob pattern matching and expansion.
3
+ * Uses Bun's built-in Glob API.
4
+ */
5
+
6
+ /**
7
+ * Check if a string contains glob pattern characters.
8
+ * This detects wildcards like *, **, ?, and character classes like [abc].
9
+ */
10
+ export function isGlobPattern(str: string): boolean {
11
+ return /[*?[\]]/.test(str)
12
+ }
13
+
14
+ /**
15
+ * Check if a path matches a glob pattern.
16
+ * Uses Bun.Glob for pattern matching.
17
+ */
18
+ export function matchesGlob(pattern: string, path: string): boolean {
19
+ const glob = new Bun.Glob(pattern)
20
+ return glob.match(path)
21
+ }
22
+
23
+ /**
24
+ * Expand glob patterns to actual file paths.
25
+ * Non-glob paths are returned as-is.
26
+ *
27
+ * @param patterns - Array of file paths or glob patterns
28
+ * @param cwd - Working directory to resolve patterns against
29
+ * @returns Array of expanded file paths (deduplicated)
30
+ */
31
+ export async function expandGlobs(
32
+ patterns: string[],
33
+ cwd: string,
34
+ ): Promise<string[]> {
35
+ const files = new Set<string>()
36
+
37
+ for (const pattern of patterns) {
38
+ if (isGlobPattern(pattern)) {
39
+ // Expand glob pattern
40
+ const glob = new Bun.Glob(pattern)
41
+ for await (const file of glob.scan({ cwd })) {
42
+ files.add(file)
43
+ }
44
+ } else {
45
+ // Non-glob path, add as-is
46
+ files.add(pattern)
47
+ }
48
+ }
49
+
50
+ return Array.from(files)
51
+ }
52
+
53
+ /**
54
+ * Convert a directory path to a glob pattern.
55
+ * Appends /** to match all files recursively in the directory.
56
+ *
57
+ * @param dirPath - Directory path (e.g., "plans" or "plans/")
58
+ * @returns Glob pattern (e.g., "plans/**")
59
+ */
60
+ export function dirToGlobPattern(dirPath: string): string {
61
+ // Remove trailing slash if present
62
+ const normalized = dirPath.replace(/\/+$/, "")
63
+ return `${normalized}/**`
64
+ }
65
+
66
+ /**
67
+ * Extract the top-level directory from a file path.
68
+ *
69
+ * @param filePath - File path (e.g., "plans/foo/bar.md")
70
+ * @returns Top-level directory name (e.g., "plans") or null if no directory
71
+ */
72
+ export function getTopLevelDir(filePath: string): string | null {
73
+ const parts = filePath.split("/")
74
+ if (parts.length > 1) {
75
+ return parts[0] || null
76
+ }
77
+ return null
78
+ }