@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.
- package/README.md +15 -4
- package/cli.ts +35 -22
- package/package.json +5 -1
- package/src/commands/emit.test.ts +1 -1
- package/src/commands/emit.ts +16 -5
- package/src/commands/push.test.ts +1 -1
- package/src/commands/push.ts +8 -5
- package/src/commands/rebase.test.ts +521 -0
- package/src/commands/rebase.ts +243 -0
- package/src/commands/save.test.ts +8 -8
- package/src/commands/task-branching.test.ts +312 -13
- package/src/commands/task-continue.test.ts +311 -0
- package/src/commands/task-edit.test.ts +4 -4
- package/src/commands/task-main.test.ts +57 -32
- package/src/commands/task.ts +371 -79
- package/src/services/AgencyMetadataService.ts +9 -1
- package/src/services/GitService.ts +61 -1
- package/src/utils/glob.test.ts +154 -0
- package/src/utils/glob.ts +78 -0
|
@@ -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
|
+
}
|