Skip to content

Commit 2759122

Browse files
committed
restore review completion and support linear JJ integration
1 parent ee61610 commit 2759122

10 files changed

Lines changed: 370 additions & 92 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect-x/lalph": patch
3+
---
4+
5+
add project review-completion setting so successful loops can automatically move issues from review to done, support local JJ target branches such as `main` for local-only linear integration, and serialize project integration so concurrent JJ tasks merge back one at a time.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ for runs, how many tasks can run concurrently, which branch to target, what git
8383
flow to use, whether review is enabled, and whether successful loops should
8484
leave issues in review or automatically move them to done.
8585

86+
For Jujutsu commit mode, the target branch can be a local bookmark such as
87+
`main` for local-only linear integration, or a remote-tracking branch such as
88+
`origin/main` when you also want lalph to fetch and push automatically.
89+
8690
`lalph` runs across all enabled projects in parallel; for single-project
8791
commands, you'll be prompted to choose an active project when needed.
8892

src/GitFlow.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,101 @@ test("GitFlowCommit.postWork rebases jj changes onto the local bookmark for line
558558
"jj git push --remote origin --bookmark master",
559559
])
560560
})
561+
562+
test("GitFlowCommit.postWork supports local-only jj target branches without remote sync", async (t) => {
563+
const { directory, repositoryDirectory } = makeJjDirectory()
564+
t.after(() => {
565+
rmSync(directory, { force: true, recursive: true })
566+
})
567+
568+
const project = new Project({
569+
checkoutMode: "in-place",
570+
concurrency: 1,
571+
enabled: true,
572+
gitFlow: "commit",
573+
id: projectId,
574+
reviewAgent: false,
575+
reviewCompletion: "manual",
576+
targetBranch: Option.some("master"),
577+
})
578+
579+
const commands: Array<string> = []
580+
const worktree = {
581+
directory: repositoryDirectory,
582+
exec: (
583+
template: TemplateStringsArray,
584+
...args: Array<string | number | boolean>
585+
) =>
586+
Effect.sync(() => {
587+
commands.push(String.raw({ raw: template }, ...args))
588+
return 0
589+
}),
590+
repository: {
591+
kind: "jj" as const,
592+
root: repositoryDirectory,
593+
},
594+
} as unknown as Worktree["Service"]
595+
596+
await withCurrentDirectory(repositoryDirectory, () =>
597+
Effect.runPromise(
598+
Effect.gen(function* () {
599+
const gitFlow = yield* GitFlow
600+
yield* gitFlow.postWork({
601+
issueId: "AUT-71",
602+
targetBranch: "master",
603+
worktree,
604+
})
605+
}).pipe(
606+
Effect.provide(GitFlowCommit),
607+
Effect.provideService(CurrentProjectId, CurrentProjectId.of(projectId)),
608+
Effect.provideService(
609+
CurrentWorkerState,
610+
CurrentWorkerState.of({
611+
output: Atom.make(Chunk.empty<string>()),
612+
state: Atom.make(
613+
WorkerState.initial({
614+
id: 1,
615+
projectId,
616+
}),
617+
),
618+
}),
619+
),
620+
Effect.provideService(Settings, settingsWithProjects(project)),
621+
Effect.provideService(
622+
Prd,
623+
Prd.of({
624+
findById: () => Effect.succeed(null),
625+
flagUnmergable: () => Effect.void,
626+
maybeRevertIssue: () => Effect.void,
627+
path: join(repositoryDirectory, ".lalph", "prd.yml"),
628+
revertUpdatedIssues: Effect.void,
629+
setAutoMerge: () => Effect.void,
630+
setChosenIssueId: () => Effect.void,
631+
}),
632+
),
633+
Effect.provideService(
634+
IssueSource,
635+
IssueSource.of({
636+
cancelIssue: () => Effect.void,
637+
cliAgentPresetInfo: () => Effect.void,
638+
createIssue: () => Effect.die("unused"),
639+
ensureInProgress: () => Effect.void,
640+
info: () => Effect.void,
641+
issueCliAgentPreset: () => Effect.succeed(Option.none()),
642+
issues: () => Effect.succeed([]),
643+
reset: Effect.void,
644+
settings: () => Effect.void,
645+
updateCliAgentPreset: () => Effect.die("unused"),
646+
updateIssue: () => Effect.void,
647+
}),
648+
),
649+
Effect.provide(PlatformServices),
650+
),
651+
),
652+
)
653+
654+
assert.deepEqual(commands, [
655+
"jj rebase --branch @ --onto master",
656+
"jj bookmark set master --revision @",
657+
])
658+
})

src/GitFlow.ts

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "effect"
1111
import type { PlatformError } from "effect/PlatformError"
1212
import { Atom, AtomRegistry } from "effect/unstable/reactivity"
13+
import type { ChildProcessSpawner } from "effect/unstable/process"
1314
import {
1415
HookCommandFailedError,
1516
Hooks,
@@ -21,10 +22,11 @@ import { CurrentProjectId } from "./Settings.ts"
2122
import { projectById } from "./Projects.ts"
2223
import type { Worktree } from "./Worktree.ts"
2324
import { CurrentWorkerState } from "./Workers.ts"
24-
import { parseBranch } from "./shared/git.ts"
2525
import {
2626
getCurrentRepository,
27+
resolveTargetBranch,
2728
targetBranchToJjBookmark,
29+
targetBranchToJjRevision,
2830
type VcsKind,
2931
} from "./shared/vcs.ts"
3032

@@ -52,7 +54,10 @@ export class GitFlow extends ServiceMap.Service<
5254
}) => Effect.Effect<
5355
void,
5456
IssueSourceError | PlatformError | GitFlowError,
55-
Prd | IssueSource | CurrentProjectId
57+
| Prd
58+
| IssueSource
59+
| CurrentProjectId
60+
| ChildProcessSpawner.ChildProcessSpawner
5661
>
5762
readonly autoMerge: (options: {
5863
readonly targetBranch: string | undefined
@@ -320,10 +325,15 @@ But you **do not** need to push your changes or switch workspaces, and you shoul
320325
}
321326
const prd = yield* Prd
322327

323-
const parsed = parseBranch(targetBranch)
328+
const parsed = yield* resolveTargetBranch({
329+
repository: worktree.repository,
330+
targetBranch,
331+
})
324332

325333
if (worktree.repository.kind === "git") {
326-
yield* worktree.exec`git fetch ${parsed.remote}`
334+
if (Option.isSome(parsed.remote)) {
335+
yield* worktree.exec`git fetch ${parsed.remote.value}`
336+
}
327337
yield* worktree.exec`git restore --worktree .`
328338

329339
const rebaseResult =
@@ -335,25 +345,29 @@ But you **do not** need to push your changes or switch workspaces, and you shoul
335345
})
336346
}
337347

338-
const pushResult =
339-
yield* worktree.exec`git push ${parsed.remote} ${`HEAD:${parsed.branch}`}`
340-
if (pushResult !== 0) {
341-
yield* prd.flagUnmergable({ issueId })
342-
return yield* new GitFlowError({
343-
message: `Failed to push changes to ${parsed.branchWithRemote}. Aborting task.`,
344-
})
348+
if (Option.isSome(parsed.remote)) {
349+
const pushResult =
350+
yield* worktree.exec`git push ${parsed.remote.value} ${`HEAD:${parsed.branch}`}`
351+
if (pushResult !== 0) {
352+
yield* prd.flagUnmergable({ issueId })
353+
return yield* new GitFlowError({
354+
message: `Failed to push changes to ${parsed.branchWithRemote}. Aborting task.`,
355+
})
356+
}
345357
}
346358
return
347359
}
348360

349-
yield* worktree.exec`jj git fetch --remote ${parsed.remote} --branch ${parsed.branch}`
350-
yield* worktree.exec`jj bookmark track ${parsed.branch} --remote ${parsed.remote}`
361+
if (Option.isSome(parsed.remote)) {
362+
yield* worktree.exec`jj git fetch --remote ${parsed.remote.value} --branch ${parsed.branch}`
363+
yield* worktree.exec`jj bookmark track ${parsed.branch} --remote ${parsed.remote.value}`
364+
}
351365
const rebaseResult =
352-
yield* worktree.exec`jj rebase --branch ${"@"} --onto ${targetBranchToJjBookmark(targetBranch)}`
366+
yield* worktree.exec`jj rebase --branch ${"@"} --onto ${targetBranchToJjBookmark(parsed)}`
353367
if (rebaseResult !== 0) {
354368
yield* prd.flagUnmergable({ issueId })
355369
return yield* new GitFlowError({
356-
message: `Failed to rebase onto ${targetBranchToJjBookmark(targetBranch)}. Aborting task.`,
370+
message: `Failed to rebase onto ${targetBranchToJjBookmark(parsed)}. Aborting task.`,
357371
})
358372
}
359373
const setBookmarkResult =
@@ -365,13 +379,15 @@ But you **do not** need to push your changes or switch workspaces, and you shoul
365379
})
366380
}
367381

368-
const pushResult =
369-
yield* worktree.exec`jj git push --remote ${parsed.remote} --bookmark ${parsed.branch}`
370-
if (pushResult !== 0) {
371-
yield* prd.flagUnmergable({ issueId })
372-
return yield* new GitFlowError({
373-
message: `Failed to push jj bookmark ${parsed.branch}@${parsed.remote}. Aborting task.`,
374-
})
382+
if (Option.isSome(parsed.remote)) {
383+
const pushResult =
384+
yield* worktree.exec`jj git push --remote ${parsed.remote.value} --bookmark ${parsed.branch}`
385+
if (pushResult !== 0) {
386+
yield* prd.flagUnmergable({ issueId })
387+
return yield* new GitFlowError({
388+
message: `Failed to push jj bookmark ${targetBranchToJjRevision(parsed)}. Aborting task.`,
389+
})
390+
}
375391
}
376392
}),
377393
autoMerge: Effect.fnUntraced(function* (options) {

src/Projects.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export const addOrUpdateProject = Effect.fnUntraced(function* (
103103
})
104104
const targetBranch = pipe(
105105
yield* Prompt.text({
106-
message: "Target branch (leave empty to use HEAD)",
106+
message:
107+
"Target branch (e.g. main or origin/main; leave empty to use HEAD)",
107108
default: existing
108109
? Option.getOrElse(existing.targetBranch, () => "")
109110
: "",

src/Worktree.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const seedGitCommit = (directory: string) => {
4545
cwd: directory,
4646
stdio: "pipe",
4747
})
48+
execFileSync("git", ["config", "commit.gpgsign", "false"], {
49+
cwd: directory,
50+
stdio: "pipe",
51+
})
4852
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], {
4953
cwd: directory,
5054
stdio: "pipe",
@@ -73,6 +77,10 @@ const makeJjDirectory = (branches: ReadonlyArray<string> = []) => {
7377
cwd: seedDirectory,
7478
stdio: "pipe",
7579
})
80+
execFileSync("git", ["config", "commit.gpgsign", "false"], {
81+
cwd: seedDirectory,
82+
stdio: "pipe",
83+
})
7684

7785
writeFileSync(join(seedDirectory, "README.md"), "init\n")
7886
execFileSync("git", ["add", "README.md"], {
@@ -555,6 +563,85 @@ test(
555563
},
556564
)
557565

566+
test(
567+
"Worktree.layer creates jj workspaces from a local target bookmark without fetching a remote",
568+
{ concurrency: false },
569+
async (t) => {
570+
const { directory, repositoryDirectory } = makeJjDirectory(["release"])
571+
t.after(() => {
572+
rmSync(directory, { force: true, recursive: true })
573+
})
574+
575+
execFileSync(
576+
"jj",
577+
["bookmark", "set", "release", "--revision", "release@origin"],
578+
{
579+
cwd: repositoryDirectory,
580+
stdio: "pipe",
581+
},
582+
)
583+
584+
mkdirSync(join(repositoryDirectory, ".lalph"), { recursive: true })
585+
writeFileSync(
586+
join(repositoryDirectory, ".lalph", "hooks.yml"),
587+
`hooks:
588+
post-create:
589+
capture: >-
590+
printf '%s:%s\\n' '{{ workspace }}' "$LALPH_TARGET_BRANCH" >> .hook-log
591+
`,
592+
)
593+
594+
const jjProjectId = Schema.decodeUnknownSync(ProjectId)("AUT-79")
595+
const project = new Project({
596+
checkoutMode: "worktree",
597+
concurrency: 1,
598+
enabled: true,
599+
gitFlow: "commit",
600+
id: jjProjectId,
601+
reviewAgent: false,
602+
reviewCompletion: "manual",
603+
targetBranch: Option.some("release"),
604+
})
605+
606+
const result = await withCurrentDirectory(repositoryDirectory, () =>
607+
Effect.runPromise(
608+
Effect.scoped(
609+
Effect.gen(function* () {
610+
const worktree = yield* Worktree
611+
const parentDescription = execFileSync(
612+
"jj",
613+
["log", "-r", "@-", "--no-graph", "-T", 'description ++ "\\n"'],
614+
{
615+
cwd: worktree.directory,
616+
encoding: "utf8",
617+
stdio: "pipe",
618+
},
619+
)
620+
return {
621+
hookLog: readFileSync(
622+
join(worktree.directory, ".hook-log"),
623+
"utf8",
624+
),
625+
parentDescription,
626+
} as const
627+
}).pipe(
628+
Effect.provide(Worktree.layer),
629+
Effect.provideService(
630+
CurrentProjectId,
631+
CurrentProjectId.of(jjProjectId),
632+
),
633+
Effect.provideService(Settings, settingsWithProjects(project)),
634+
Effect.provide(PlatformServices),
635+
),
636+
),
637+
),
638+
)
639+
640+
assert.equal(result.hookLog.endsWith(":release\n"), true)
641+
assert.equal(result.parentDescription.trimEnd(), "release")
642+
},
643+
)
644+
558645
test(
559646
"Worktree.layerWorktree creates jj workspaces even when the project uses in-place mode",
560647
{ concurrency: false },

src/Worktree.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
import { projectById } from "./Projects.ts"
2727
import { CurrentProjectId } from "./Settings.ts"
2828
import { constWorkerMaxOutputChunks, CurrentWorkerState } from "./Workers.ts"
29-
import { parseBranch } from "./shared/git.ts"
3029
import {
3130
resolveLalphDirectory,
3231
syncLalphDirectory,
@@ -37,6 +36,7 @@ import {
3736
getCurrentRepository,
3837
getGithubRepository,
3938
makeJjWorkspaceName,
39+
resolveTargetBranch,
4040
targetBranchToJjRevision,
4141
} from "./shared/vcs.ts"
4242

@@ -237,18 +237,25 @@ export const setupWorktree = Effect.fnUntraced(function* (options: {
237237
const targetBranch = yield* getTargetBranch
238238

239239
if (Option.isSome(targetBranch)) {
240-
const parsed = parseBranch(targetBranch.value)
240+
const parsed = yield* resolveTargetBranch({
241+
repository: options.repository,
242+
targetBranch: targetBranch.value,
243+
})
241244

242245
if (options.repository.kind === "git") {
243-
yield* options.exec`git fetch ${parsed.remote}`
246+
if (Option.isSome(parsed.remote)) {
247+
yield* options.exec`git fetch ${parsed.remote.value}`
248+
}
244249
const code = yield* options.exec`git checkout ${parsed.branchWithRemote}`
245-
if (code !== 0) {
250+
if (code !== 0 && Option.isSome(parsed.remote)) {
246251
yield* options.exec`git checkout -b ${parsed.branch}`
247-
yield* options.exec`git push -u ${parsed.remote} ${parsed.branch}`
252+
yield* options.exec`git push -u ${parsed.remote.value} ${parsed.branch}`
248253
}
249254
} else {
250-
yield* options.exec`jj git fetch --remote ${parsed.remote} --branch ${parsed.branch}`
251-
yield* options.exec`jj new ${targetBranchToJjRevision(targetBranch.value)}`
255+
if (Option.isSome(parsed.remote)) {
256+
yield* options.exec`jj git fetch --remote ${parsed.remote.value} --branch ${parsed.branch}`
257+
}
258+
yield* options.exec`jj new ${targetBranchToJjRevision(parsed)}`
252259
}
253260
}
254261

0 commit comments

Comments
 (0)