@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
|
@@ -874,5 +874,201 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
|
|
|
874
874
|
),
|
|
875
875
|
Effect.catchAll(() => Effect.succeed(false)),
|
|
876
876
|
),
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Get all local branch names.
|
|
880
|
+
* @param gitRoot - The git repository root
|
|
881
|
+
* @returns Array of local branch names
|
|
882
|
+
*/
|
|
883
|
+
getAllLocalBranches: (gitRoot: string) =>
|
|
884
|
+
pipe(
|
|
885
|
+
runGitCommand(["git", "branch", "--format=%(refname:short)"], gitRoot),
|
|
886
|
+
Effect.map((result) => {
|
|
887
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
888
|
+
return result.stdout.trim().split("\n") as readonly string[]
|
|
889
|
+
}
|
|
890
|
+
return [] as readonly string[]
|
|
891
|
+
}),
|
|
892
|
+
Effect.mapError(
|
|
893
|
+
() => new GitError({ message: "Failed to get local branches" }),
|
|
894
|
+
),
|
|
895
|
+
),
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Get branches that have been merged into a target branch.
|
|
899
|
+
* @param gitRoot - The git repository root
|
|
900
|
+
* @param targetBranch - The branch to check merges against
|
|
901
|
+
* @returns Array of merged branch names
|
|
902
|
+
*/
|
|
903
|
+
getMergedBranches: (gitRoot: string, targetBranch: string) =>
|
|
904
|
+
pipe(
|
|
905
|
+
runGitCommand(
|
|
906
|
+
[
|
|
907
|
+
"git",
|
|
908
|
+
"branch",
|
|
909
|
+
"--merged",
|
|
910
|
+
targetBranch,
|
|
911
|
+
"--format=%(refname:short)",
|
|
912
|
+
],
|
|
913
|
+
gitRoot,
|
|
914
|
+
),
|
|
915
|
+
Effect.map((result) => {
|
|
916
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
917
|
+
return result.stdout.trim().split("\n") as readonly string[]
|
|
918
|
+
}
|
|
919
|
+
return [] as readonly string[]
|
|
920
|
+
}),
|
|
921
|
+
Effect.mapError(
|
|
922
|
+
() =>
|
|
923
|
+
new GitError({
|
|
924
|
+
message: `Failed to get branches merged into ${targetBranch}`,
|
|
925
|
+
}),
|
|
926
|
+
),
|
|
927
|
+
),
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Push a branch to a remote.
|
|
931
|
+
* @param gitRoot - The git repository root
|
|
932
|
+
* @param remote - Remote name (e.g., "origin")
|
|
933
|
+
* @param branch - Branch name to push
|
|
934
|
+
* @param options - Push options
|
|
935
|
+
* @returns Object with exitCode, stdout, stderr
|
|
936
|
+
*/
|
|
937
|
+
push: (
|
|
938
|
+
gitRoot: string,
|
|
939
|
+
remote: string,
|
|
940
|
+
branch: string,
|
|
941
|
+
options?: {
|
|
942
|
+
readonly setUpstream?: boolean
|
|
943
|
+
readonly force?: boolean
|
|
944
|
+
},
|
|
945
|
+
) => {
|
|
946
|
+
const args = ["git", "push"]
|
|
947
|
+
if (options?.setUpstream) {
|
|
948
|
+
args.push("-u")
|
|
949
|
+
}
|
|
950
|
+
if (options?.force) {
|
|
951
|
+
args.push("--force")
|
|
952
|
+
}
|
|
953
|
+
args.push(remote, branch)
|
|
954
|
+
|
|
955
|
+
return pipe(
|
|
956
|
+
runGitCommand(args, gitRoot),
|
|
957
|
+
Effect.mapError(
|
|
958
|
+
(error) =>
|
|
959
|
+
new GitError({
|
|
960
|
+
message: `Failed to push ${branch} to ${remote}`,
|
|
961
|
+
cause: error,
|
|
962
|
+
}),
|
|
963
|
+
),
|
|
964
|
+
)
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Merge a branch into the current branch.
|
|
969
|
+
* @param gitRoot - The git repository root
|
|
970
|
+
* @param branch - Branch to merge
|
|
971
|
+
* @param options - Merge options
|
|
972
|
+
* @returns Object with exitCode, stdout, stderr
|
|
973
|
+
*/
|
|
974
|
+
merge: (
|
|
975
|
+
gitRoot: string,
|
|
976
|
+
branch: string,
|
|
977
|
+
options?: {
|
|
978
|
+
readonly squash?: boolean
|
|
979
|
+
readonly noCommit?: boolean
|
|
980
|
+
readonly message?: string
|
|
981
|
+
},
|
|
982
|
+
) => {
|
|
983
|
+
const args = ["git", "merge"]
|
|
984
|
+
if (options?.squash) {
|
|
985
|
+
args.push("--squash")
|
|
986
|
+
}
|
|
987
|
+
if (options?.noCommit) {
|
|
988
|
+
args.push("--no-commit")
|
|
989
|
+
}
|
|
990
|
+
if (options?.message) {
|
|
991
|
+
args.push("-m", options.message)
|
|
992
|
+
}
|
|
993
|
+
args.push(branch)
|
|
994
|
+
|
|
995
|
+
return pipe(
|
|
996
|
+
runGitCommand(args, gitRoot),
|
|
997
|
+
Effect.mapError(
|
|
998
|
+
(error) =>
|
|
999
|
+
new GitError({
|
|
1000
|
+
message: `Failed to merge ${branch}`,
|
|
1001
|
+
cause: error,
|
|
1002
|
+
}),
|
|
1003
|
+
),
|
|
1004
|
+
)
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Rebase current branch onto a base branch.
|
|
1009
|
+
* @param gitRoot - The git repository root
|
|
1010
|
+
* @param baseBranch - Branch to rebase onto
|
|
1011
|
+
* @returns Object with exitCode, stdout, stderr
|
|
1012
|
+
*/
|
|
1013
|
+
rebase: (gitRoot: string, baseBranch: string) =>
|
|
1014
|
+
pipe(
|
|
1015
|
+
runGitCommand(["git", "rebase", baseBranch], gitRoot),
|
|
1016
|
+
Effect.mapError(
|
|
1017
|
+
(error) =>
|
|
1018
|
+
new GitError({
|
|
1019
|
+
message: `Failed to rebase onto ${baseBranch}`,
|
|
1020
|
+
cause: error,
|
|
1021
|
+
}),
|
|
1022
|
+
),
|
|
1023
|
+
),
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Get the working tree status (porcelain format).
|
|
1027
|
+
* @param gitRoot - The git repository root
|
|
1028
|
+
* @returns Status output string
|
|
1029
|
+
*/
|
|
1030
|
+
getStatus: (gitRoot: string) =>
|
|
1031
|
+
pipe(
|
|
1032
|
+
runGitCommand(["git", "status", "--porcelain"], gitRoot),
|
|
1033
|
+
Effect.map((result) => result.stdout),
|
|
1034
|
+
Effect.mapError(
|
|
1035
|
+
() => new GitError({ message: "Failed to get git status" }),
|
|
1036
|
+
),
|
|
1037
|
+
),
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Check if one commit is an ancestor of another.
|
|
1041
|
+
* @param gitRoot - The git repository root
|
|
1042
|
+
* @param potentialAncestor - The commit that might be an ancestor
|
|
1043
|
+
* @param commit - The commit to check against
|
|
1044
|
+
* @returns true if potentialAncestor is an ancestor of commit
|
|
1045
|
+
*/
|
|
1046
|
+
isAncestor: (gitRoot: string, potentialAncestor: string, commit: string) =>
|
|
1047
|
+
pipe(
|
|
1048
|
+
runGitCommand(
|
|
1049
|
+
["git", "merge-base", "--is-ancestor", potentialAncestor, commit],
|
|
1050
|
+
gitRoot,
|
|
1051
|
+
),
|
|
1052
|
+
Effect.map((result) => result.exitCode === 0),
|
|
1053
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
1054
|
+
),
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Create or reset a branch to point to a specific commit/branch.
|
|
1058
|
+
* Uses `git checkout -B` which creates the branch if it doesn't exist
|
|
1059
|
+
* or resets it if it does.
|
|
1060
|
+
* @param gitRoot - The git repository root
|
|
1061
|
+
* @param branchName - Name of the branch to create/reset
|
|
1062
|
+
* @param startPoint - Commit or branch to start from
|
|
1063
|
+
*/
|
|
1064
|
+
createOrResetBranch: (
|
|
1065
|
+
gitRoot: string,
|
|
1066
|
+
branchName: string,
|
|
1067
|
+
startPoint: string,
|
|
1068
|
+
) =>
|
|
1069
|
+
runGitCommandVoid(
|
|
1070
|
+
["git", "checkout", "-q", "-B", branchName, startPoint],
|
|
1071
|
+
gitRoot,
|
|
1072
|
+
),
|
|
877
1073
|
}),
|
|
878
1074
|
}) {}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect"
|
|
2
|
+
import { FilterRepoService } from "./FilterRepoService"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Captured filter-repo call for verification in tests.
|
|
6
|
+
*/
|
|
7
|
+
export interface CapturedFilterRepoCall {
|
|
8
|
+
gitRoot: string
|
|
9
|
+
args: readonly string[]
|
|
10
|
+
env?: Record<string, string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Global state for captured filter-repo calls.
|
|
15
|
+
* Tests can inspect this to verify correct commands were constructed.
|
|
16
|
+
*/
|
|
17
|
+
let capturedCalls: CapturedFilterRepoCall[] = []
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clear all captured filter-repo calls.
|
|
21
|
+
* Call this in beforeEach() to reset state between tests.
|
|
22
|
+
*/
|
|
23
|
+
export function clearCapturedFilterRepoCalls(): void {
|
|
24
|
+
capturedCalls = []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get all captured filter-repo calls.
|
|
29
|
+
* @returns Array of captured calls
|
|
30
|
+
*/
|
|
31
|
+
export function getCapturedFilterRepoCalls(): readonly CapturedFilterRepoCall[] {
|
|
32
|
+
return capturedCalls
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the last captured filter-repo call.
|
|
37
|
+
* @returns The last captured call, or undefined if none
|
|
38
|
+
*/
|
|
39
|
+
export function getLastCapturedFilterRepoCall():
|
|
40
|
+
| CapturedFilterRepoCall
|
|
41
|
+
| undefined {
|
|
42
|
+
return capturedCalls[capturedCalls.length - 1]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mock implementation of FilterRepoService.
|
|
47
|
+
*
|
|
48
|
+
* This mock:
|
|
49
|
+
* - Always returns true for isInstalled()
|
|
50
|
+
* - Captures the arguments passed to run() without executing
|
|
51
|
+
* - Returns a successful result with exit code 0
|
|
52
|
+
*
|
|
53
|
+
* Use getCapturedFilterRepoCalls() to verify the correct commands were constructed.
|
|
54
|
+
*/
|
|
55
|
+
export class MockFilterRepoService extends Effect.Service<MockFilterRepoService>()(
|
|
56
|
+
"FilterRepoService", // Same tag as the real service to replace it
|
|
57
|
+
{
|
|
58
|
+
sync: () => ({
|
|
59
|
+
isInstalled: () => Effect.succeed(true),
|
|
60
|
+
|
|
61
|
+
run: (
|
|
62
|
+
gitRoot: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
options?: {
|
|
65
|
+
readonly env?: Record<string, string>
|
|
66
|
+
},
|
|
67
|
+
) => {
|
|
68
|
+
// Capture the call
|
|
69
|
+
capturedCalls.push({
|
|
70
|
+
gitRoot,
|
|
71
|
+
args,
|
|
72
|
+
env: options?.env,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Return a successful result
|
|
76
|
+
return Effect.succeed({
|
|
77
|
+
exitCode: 0,
|
|
78
|
+
stdout: "",
|
|
79
|
+
stderr: "",
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
filterFiles: (
|
|
84
|
+
gitRoot: string,
|
|
85
|
+
options: {
|
|
86
|
+
readonly refs?: string
|
|
87
|
+
readonly pathsToRemove?: readonly string[]
|
|
88
|
+
readonly pathRenames?: readonly { from: string; to: string }[]
|
|
89
|
+
readonly force?: boolean
|
|
90
|
+
},
|
|
91
|
+
) => {
|
|
92
|
+
// Build the args like the real implementation would
|
|
93
|
+
const args: string[] = []
|
|
94
|
+
|
|
95
|
+
if (options.refs) {
|
|
96
|
+
args.push("--refs", options.refs)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.pathsToRemove) {
|
|
100
|
+
for (const path of options.pathsToRemove) {
|
|
101
|
+
args.push("--invert-paths", "--path", path)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (options.pathRenames) {
|
|
106
|
+
for (const rename of options.pathRenames) {
|
|
107
|
+
args.push("--path-rename", `${rename.from}:${rename.to}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.force) {
|
|
112
|
+
args.push("--force")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
args.push("--prune-empty=always")
|
|
116
|
+
|
|
117
|
+
// Capture the call
|
|
118
|
+
capturedCalls.push({
|
|
119
|
+
gitRoot,
|
|
120
|
+
args,
|
|
121
|
+
env: { GIT_CONFIG_GLOBAL: "" },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Return a successful result
|
|
125
|
+
return Effect.succeed({
|
|
126
|
+
exitCode: 0,
|
|
127
|
+
stdout: "",
|
|
128
|
+
stderr: "",
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -235,6 +235,47 @@ export async function deleteBranch(
|
|
|
235
235
|
await gitRun(cwd, ["branch", flag, branchName])
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Reset a git repo to clean state for test reuse.
|
|
240
|
+
* This is much faster than creating a new repo from scratch.
|
|
241
|
+
* - Checks out main branch
|
|
242
|
+
* - Removes all other branches
|
|
243
|
+
* - Resets to initial commit
|
|
244
|
+
* - Cleans untracked files
|
|
245
|
+
* - Removes agency git config
|
|
246
|
+
*/
|
|
247
|
+
export async function resetGitRepo(cwd: string): Promise<void> {
|
|
248
|
+
// Checkout main
|
|
249
|
+
await gitRun(cwd, ["checkout", "-q", "main"])
|
|
250
|
+
|
|
251
|
+
// Delete all branches except main
|
|
252
|
+
const branchOutput = await getGitOutput(cwd, ["branch", "--list"])
|
|
253
|
+
const branches = branchOutput
|
|
254
|
+
.split("\n")
|
|
255
|
+
.map((b) => b.replace(/^\*?\s*/, "").trim())
|
|
256
|
+
.filter((b) => b && b !== "main")
|
|
257
|
+
|
|
258
|
+
for (const branch of branches) {
|
|
259
|
+
await gitRun(cwd, ["branch", "-D", branch])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reset to first commit (the initial commit from template)
|
|
263
|
+
const firstCommit = (
|
|
264
|
+
await getGitOutput(cwd, ["rev-list", "--max-parents=0", "HEAD"])
|
|
265
|
+
).trim()
|
|
266
|
+
await gitRun(cwd, ["reset", "--hard", firstCommit])
|
|
267
|
+
|
|
268
|
+
// Clean untracked files and directories
|
|
269
|
+
await gitRun(cwd, ["clean", "-fdx"])
|
|
270
|
+
|
|
271
|
+
// Remove agency config
|
|
272
|
+
try {
|
|
273
|
+
await gitRun(cwd, ["config", "--unset", "agency.template"])
|
|
274
|
+
} catch {
|
|
275
|
+
// Ignore if not set
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
238
279
|
/**
|
|
239
280
|
* Rename current branch
|
|
240
281
|
*/
|
|
@@ -340,8 +381,18 @@ import { PromptService } from "./services/PromptService"
|
|
|
340
381
|
import { TemplateService } from "./services/TemplateService"
|
|
341
382
|
import { OpencodeService } from "./services/OpencodeService"
|
|
342
383
|
import { ClaudeService } from "./services/ClaudeService"
|
|
343
|
-
|
|
344
|
-
|
|
384
|
+
import { FilterRepoService } from "./services/FilterRepoService"
|
|
385
|
+
import { MockFilterRepoService } from "./services/MockFilterRepoService"
|
|
386
|
+
|
|
387
|
+
// Re-export mock utilities for tests
|
|
388
|
+
export {
|
|
389
|
+
clearCapturedFilterRepoCalls,
|
|
390
|
+
getCapturedFilterRepoCalls,
|
|
391
|
+
getLastCapturedFilterRepoCall,
|
|
392
|
+
} from "./services/MockFilterRepoService"
|
|
393
|
+
export type { CapturedFilterRepoCall } from "./services/MockFilterRepoService"
|
|
394
|
+
|
|
395
|
+
// Create test layer with all services (real filter-repo)
|
|
345
396
|
const TestLayer = Layer.mergeAll(
|
|
346
397
|
GitService.Default,
|
|
347
398
|
ConfigService.Default,
|
|
@@ -350,6 +401,19 @@ const TestLayer = Layer.mergeAll(
|
|
|
350
401
|
TemplateService.Default,
|
|
351
402
|
OpencodeService.Default,
|
|
352
403
|
ClaudeService.Default,
|
|
404
|
+
FilterRepoService.Default,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
// Create test layer with mock filter-repo (for tests that don't need real filtering)
|
|
408
|
+
const TestLayerWithMockFilterRepo = Layer.mergeAll(
|
|
409
|
+
GitService.Default,
|
|
410
|
+
ConfigService.Default,
|
|
411
|
+
FileSystemService.Default,
|
|
412
|
+
PromptService.Default,
|
|
413
|
+
TemplateService.Default,
|
|
414
|
+
OpencodeService.Default,
|
|
415
|
+
ClaudeService.Default,
|
|
416
|
+
MockFilterRepoService.Default,
|
|
353
417
|
)
|
|
354
418
|
|
|
355
419
|
export async function runTestEffect<A, E>(
|
|
@@ -366,3 +430,22 @@ export async function runTestEffect<A, E>(
|
|
|
366
430
|
|
|
367
431
|
return await Effect.runPromise(program)
|
|
368
432
|
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run a test effect with MockFilterRepoService instead of the real one.
|
|
436
|
+
* Use this for tests that need to verify filter-repo command construction
|
|
437
|
+
* without actually running git-filter-repo.
|
|
438
|
+
*/
|
|
439
|
+
export async function runTestEffectWithMockFilterRepo<A, E>(
|
|
440
|
+
effect: Effect.Effect<A, E, any>,
|
|
441
|
+
): Promise<A> {
|
|
442
|
+
const providedEffect = Effect.provide(
|
|
443
|
+
effect,
|
|
444
|
+
TestLayerWithMockFilterRepo,
|
|
445
|
+
) as Effect.Effect<A, E, never>
|
|
446
|
+
const program = Effect.catchAllDefect(providedEffect, (defect) =>
|
|
447
|
+
Effect.fail(defect instanceof Error ? defect : new Error(String(defect))),
|
|
448
|
+
) as Effect.Effect<A, E | Error, never>
|
|
449
|
+
|
|
450
|
+
return await Effect.runPromise(program)
|
|
451
|
+
}
|
package/src/utils/pr-branch.ts
CHANGED
|
@@ -278,26 +278,18 @@ const findSourceBranchByEmitBranch = (
|
|
|
278
278
|
const git = yield* GitService
|
|
279
279
|
|
|
280
280
|
// Get all local branches
|
|
281
|
-
const
|
|
282
|
-
git.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
{
|
|
286
|
-
captureOutput: true,
|
|
287
|
-
},
|
|
281
|
+
const branches = yield* pipe(
|
|
282
|
+
git.getAllLocalBranches(gitRoot),
|
|
283
|
+
Effect.map((allBranches) =>
|
|
284
|
+
allBranches.filter((b) => b !== currentBranch),
|
|
288
285
|
),
|
|
289
|
-
Effect.catchAll(() => Effect.succeed(
|
|
286
|
+
Effect.catchAll(() => Effect.succeed([] as readonly string[])),
|
|
290
287
|
)
|
|
291
288
|
|
|
292
|
-
if (
|
|
289
|
+
if (branches.length === 0) {
|
|
293
290
|
return null
|
|
294
291
|
}
|
|
295
292
|
|
|
296
|
-
const branches = branchesResult.stdout
|
|
297
|
-
.split("\n")
|
|
298
|
-
.map((b) => b.trim())
|
|
299
|
-
.filter((b) => b.length > 0 && b !== currentBranch)
|
|
300
|
-
|
|
301
293
|
// Search each branch for agency.json with matching emitBranch
|
|
302
294
|
for (const branch of branches) {
|
|
303
295
|
const metadata = yield* readAgencyJsonFromBranch(gitRoot, branch)
|