Skip to content

Commit 5715919

Browse files
committed
feat(core): isolate issue/pr workspaces for parallel work
1 parent 5c19076 commit 5715919

9 files changed

Lines changed: 118 additions & 27 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,21 @@ pnpm run docker-git
2020
# Clone a repo into its own container (creates under ~/.docker-git)
2121
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force
2222

23+
# Clone an issue URL (creates isolated workspace + issue branch)
24+
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force
25+
2326
# Same, but also enable Playwright MCP + Chromium sidecar for Codex
2427
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --mcp-playwright
2528
```
2629

30+
## Parallel Issues / PRs
31+
32+
When you clone GitHub issue or PR URLs, docker-git creates isolated project paths and container names:
33+
- `.../issues/123` -> `<projectsRoot>/<owner>/<repo>/issue-123` (branch `issue-123`)
34+
- `.../pull/45` -> `<projectsRoot>/<owner>/<repo>/pr-45` (ref `refs/pull/45/head`)
35+
36+
This lets you run multiple issues/PRs for the same repository in parallel without container/path collisions.
37+
2738
## Projects Root Layout
2839

2940
The projects root is:

packages/app/src/docker-git/cli/parser-clone.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,23 @@ import {
1313
import { parseRawOptions } from "./parser-options.js"
1414
import { splitPositionalRepo } from "./parser-shared.js"
1515

16-
const applyCloneDefaults = (raw: RawOptions, repoUrl: string): RawOptions => {
17-
const repoPath = deriveRepoPathParts(repoUrl).pathParts.join("/")
16+
const resolveCloneRepoPath = (resolvedRepo: ReturnType<typeof resolveRepoInput>): string => {
17+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
18+
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
19+
return projectParts.join("/")
20+
}
21+
22+
const applyCloneDefaults = (
23+
raw: RawOptions,
24+
rawRepoUrl: string,
25+
resolvedRepo: ReturnType<typeof resolveRepoInput>
26+
): RawOptions => {
27+
const repoPath = resolveCloneRepoPath(resolvedRepo)
1828
const sshUser = raw.sshUser?.trim() ?? defaultTemplateConfig.sshUser
1929
const homeDir = `/home/${sshUser}`
2030
return {
2131
...raw,
22-
repoUrl,
32+
repoUrl: rawRepoUrl,
2333
outDir: raw.outDir ?? `.docker-git/${repoPath}`,
2434
targetDir: raw.targetDir ?? `${homeDir}/${repoPath}`
2535
}
@@ -42,7 +52,7 @@ export const parseClone = (args: ReadonlyArray<string>): Either.Either<Command,
4252
const raw = yield* _(parseRawOptions(restArgs))
4353
const rawRepoUrl = yield* _(nonEmpty("--repo-url", raw.repoUrl ?? positionalRepoUrl))
4454
const resolvedRepo = resolveRepoInput(rawRepoUrl)
45-
const withDefaults = applyCloneDefaults(raw, resolvedRepo.repoUrl)
55+
const withDefaults = applyCloneDefaults(raw, rawRepoUrl, resolvedRepo)
4656
const withRef = resolvedRepo.repoRef !== undefined && raw.repoRef === undefined
4757
? { ...withDefaults, repoRef: resolvedRepo.repoRef }
4858
: withDefaults

packages/app/src/docker-git/cli/parser-shared.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,19 @@ export const parseProjectDirWithOptions = (
2424
const { positionalRepoUrl, restArgs } = splitPositionalRepo(args)
2525
const raw = yield* _(parseRawOptions(restArgs))
2626
const rawRepoUrl = raw.repoUrl ?? positionalRepoUrl
27-
const resolvedRepo = rawRepoUrl ? resolveRepoInput(rawRepoUrl).repoUrl : null
27+
const resolvedRepo = rawRepoUrl ? resolveRepoInput(rawRepoUrl) : null
28+
const repoPath = resolvedRepo
29+
? (() => {
30+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
31+
const projectParts = resolvedRepo.workspaceSuffix
32+
? [...baseParts, resolvedRepo.workspaceSuffix]
33+
: baseParts
34+
return projectParts.join("/")
35+
})()
36+
: null
2837
const projectDir = raw.projectDir ??
29-
(resolvedRepo
30-
? `.docker-git/${deriveRepoPathParts(resolvedRepo).pathParts.join("/")}`
38+
(repoPath
39+
? `.docker-git/${repoPath}`
3140
: defaultProjectDir)
3241

3342
return { projectDir, raw }

packages/app/src/docker-git/cli/usage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Commands:
3030
Options:
3131
--repo-ref <ref> Git ref/branch (default: main)
3232
--branch, -b <ref> Alias for --repo-ref
33-
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: /home/dev/<org>/<repo>)
33+
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: /home/dev/<org>/<repo>[/issue-<id>|/pr-<id>])
3434
--ssh-port <port> Local SSH port (default: 2222)
3535
--ssh-user <user> SSH user inside container (default: dev)
3636
--container-name <name> Docker container name (default: dg-<repo>)
@@ -42,7 +42,7 @@ Options:
4242
--env-project <path> Host path to project env file (default: ./.orch/env/project.env)
4343
--codex-auth <path> Host path for Codex auth cache (default: <projectsRoot>/.orch/auth/codex)
4444
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
45-
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>)
45+
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
4646
--project-dir <path> Project directory for attach (default: .)
4747
--lines <n> Tail last N lines for sessions logs (default: 200)
4848
--include-default Show default/system processes in sessions list

packages/app/src/docker-git/menu-create.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type CreateCommand, deriveRepoPathParts } from "@effect-template/lib/core/domain"
1+
import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain"
22
import { createProject } from "@effect-template/lib/usecases/actions"
33
import type { AppError } from "@effect-template/lib/usecases/errors"
44
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
@@ -91,21 +91,24 @@ const joinPath = (...parts: ReadonlyArray<string>): string => {
9191
}
9292

9393
const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
94-
const repoPath = deriveRepoPathParts(repoUrl).pathParts
95-
return joinPath(defaultProjectsRoot(cwd), ...repoPath)
94+
const resolvedRepo = resolveRepoInput(repoUrl)
95+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
96+
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
97+
return joinPath(defaultProjectsRoot(cwd), ...projectParts)
9698
}
9799

98100
export const resolveCreateInputs = (
99101
cwd: string,
100102
values: Partial<CreateInputs>
101103
): CreateInputs => {
102104
const repoUrl = values.repoUrl ?? ""
105+
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
103106
const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
104107
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")
105108

106109
return {
107110
repoUrl,
108-
repoRef: values.repoRef ?? "main",
111+
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
109112
outDir,
110113
secretsRoot,
111114
runUp: values.runUp !== false,

packages/app/tests/docker-git/parser.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ describe("parseArgs", () => {
4545
expect(command.config.sshPort).toBe(defaultTemplateConfig.sshPort)
4646
}))
4747

48+
it.effect("parses create command with issue url into isolated defaults", () =>
49+
expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => {
50+
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
51+
expect(command.config.repoRef).toBe("issue-9")
52+
expect(command.outDir).toBe(".docker-git/org/repo/issue-9")
53+
expect(command.config.containerName).toBe("dg-repo-issue-9")
54+
expect(command.config.serviceName).toBe("dg-repo-issue-9")
55+
expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
56+
}))
57+
4858
it.effect("fails on missing repo url", () =>
4959
Effect.sync(() => {
5060
Either.match(parseArgs(["create"]), {
@@ -76,6 +86,37 @@ describe("parseArgs", () => {
7686
expect(command.config.targetDir).toBe("/home/dev/agiens/crm")
7787
}))
7888

89+
it.effect("parses GitHub issue url as isolated project + issue branch", () =>
90+
expectCreateCommand(["clone", "https://github.com/org/repo/issues/5"], (command) => {
91+
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
92+
expect(command.config.repoRef).toBe("issue-5")
93+
expect(command.outDir).toBe(".docker-git/org/repo/issue-5")
94+
expect(command.config.targetDir).toBe("/home/dev/org/repo/issue-5")
95+
expect(command.config.containerName).toBe("dg-repo-issue-5")
96+
expect(command.config.serviceName).toBe("dg-repo-issue-5")
97+
expect(command.config.volumeName).toBe("dg-repo-issue-5-home")
98+
}))
99+
100+
it.effect("parses GitHub PR url as isolated project", () =>
101+
expectCreateCommand(["clone", "https://github.com/org/repo/pull/42"], (command) => {
102+
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
103+
expect(command.config.repoRef).toBe("refs/pull/42/head")
104+
expect(command.outDir).toBe(".docker-git/org/repo/pr-42")
105+
expect(command.config.targetDir).toBe("/home/dev/org/repo/pr-42")
106+
expect(command.config.containerName).toBe("dg-repo-pr-42")
107+
expect(command.config.serviceName).toBe("dg-repo-pr-42")
108+
expect(command.config.volumeName).toBe("dg-repo-pr-42-home")
109+
}))
110+
111+
it.effect("parses attach with GitHub issue url into issue workspace", () =>
112+
Effect.sync(() => {
113+
const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"])
114+
if (command._tag !== "Attach") {
115+
throw new Error("expected Attach command")
116+
}
117+
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
118+
}))
119+
79120
it.effect("parses down-all command", () =>
80121
Effect.sync(() => {
81122
const command = parseOrThrow(["down-all"])

packages/lib/src/core/command-builders.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/"
5050
type RepoBasics = {
5151
readonly repoUrl: string
5252
readonly repoSlug: string
53+
readonly projectSlug: string
5354
readonly repoPath: string
5455
readonly repoRef: string
5556
readonly targetDir: string
@@ -63,7 +64,10 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either<RepoBasics, ParseErro
6364
const resolvedRepo = resolveRepoInput(rawRepoUrl)
6465
const repoUrl = resolvedRepo.repoUrl
6566
const repoSlug = deriveRepoSlug(repoUrl)
66-
const repoPath = deriveRepoPathParts(repoUrl).pathParts.join("/")
67+
const repoPathParts = deriveRepoPathParts(repoUrl).pathParts
68+
const workspaceSuffix = resolvedRepo.workspaceSuffix
69+
const projectSlug = workspaceSuffix ? `${repoSlug}-${workspaceSuffix}` : repoSlug
70+
const repoPath = workspaceSuffix ? [...repoPathParts, workspaceSuffix].join("/") : repoPathParts.join("/")
6771
const repoRef = yield* _(
6872
nonEmpty("--repo-ref", raw.repoRef ?? resolvedRepo.repoRef, defaultTemplateConfig.repoRef)
6973
)
@@ -73,7 +77,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either<RepoBasics, ParseErro
7377
const sshUser = yield* _(nonEmpty("--ssh-user", raw.sshUser, defaultTemplateConfig.sshUser))
7478
const sshPort = yield* _(parsePort(raw.sshPort ?? String(defaultTemplateConfig.sshPort)))
7579

76-
return { repoUrl, repoSlug, repoPath, repoRef, targetDir, sshUser, sshPort }
80+
return { repoUrl, repoSlug, projectSlug, repoPath, repoRef, targetDir, sshUser, sshPort }
7781
})
7882

7983
type NameConfig = {
@@ -84,12 +88,12 @@ type NameConfig = {
8488

8589
const resolveNames = (
8690
raw: RawOptions,
87-
repoSlug: string
91+
projectSlug: string
8892
): Either.Either<NameConfig, ParseError> =>
8993
Either.gen(function*(_) {
90-
const derivedContainerName = `dg-${repoSlug}`
91-
const derivedServiceName = `dg-${repoSlug}`
92-
const derivedVolumeName = `dg-${repoSlug}-home`
94+
const derivedContainerName = `dg-${projectSlug}`
95+
const derivedServiceName = `dg-${projectSlug}`
96+
const derivedVolumeName = `dg-${projectSlug}-home`
9397
const containerName = yield* _(
9498
nonEmpty("--container-name", raw.containerName, derivedContainerName)
9599
)
@@ -111,7 +115,7 @@ type PathConfig = {
111115

112116
const resolvePaths = (
113117
raw: RawOptions,
114-
repoSlug: string,
118+
projectSlug: string,
115119
repoPath: string
116120
): Either.Either<PathConfig, ParseError> =>
117121
Either.gen(function*(_) {
@@ -127,7 +131,7 @@ const resolvePaths = (
127131
: `${normalizedSecretsRoot}/global.env`
128132
const defaultEnvProjectPath = normalizedSecretsRoot === undefined
129133
? defaultTemplateConfig.envProjectPath
130-
: `${normalizedSecretsRoot}/${repoSlug}.env`
134+
: `${normalizedSecretsRoot}/${projectSlug}.env`
131135
const defaultCodexAuthPath = normalizedSecretsRoot === undefined
132136
? defaultTemplateConfig.codexAuthPath
133137
: `${normalizedSecretsRoot}/codex`
@@ -163,8 +167,8 @@ export const buildCreateCommand = (
163167
): Either.Either<CreateCommand, ParseError> =>
164168
Either.gen(function*(_) {
165169
const repo = yield* _(resolveRepoBasics(raw))
166-
const names = yield* _(resolveNames(raw, repo.repoSlug))
167-
const paths = yield* _(resolvePaths(raw, repo.repoSlug, repo.repoPath))
170+
const names = yield* _(resolveNames(raw, repo.projectSlug))
171+
const paths = yield* _(resolvePaths(raw, repo.projectSlug, repo.repoPath))
168172
const runUp = raw.up ?? true
169173
const force = raw.force ?? false
170174
const enableMcpPlaywright = raw.enableMcpPlaywright ?? false

packages/lib/src/core/repo.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,10 @@ export const parseGithubRepoUrl = (input: string): GithubRepo | null => {
205205
return { owner, repo }
206206
}
207207

208-
type ResolvedRepoInput = {
208+
export type ResolvedRepoInput = {
209209
readonly repoUrl: string
210210
readonly repoRef?: string
211+
readonly workspaceSuffix?: string
211212
}
212213

213214
type GithubRefParts = {
@@ -244,9 +245,11 @@ const parseGithubPrUrl = (input: string): ResolvedRepoInput | null => {
244245
}
245246

246247
const repo = stripGitSuffix(parsed.repoRaw)
248+
const workspaceSuffix = `pr-${slugify(parsed.ref)}`
247249
return {
248250
repoUrl: `https://github.com/${parsed.owner}/${repo}.git`,
249-
repoRef: `refs/pull/${parsed.ref}/head`
251+
repoRef: `refs/pull/${parsed.ref}/head`,
252+
workspaceSuffix
250253
}
251254
}
252255

@@ -278,7 +281,7 @@ const parseGithubTreeUrl = (input: string): ResolvedRepoInput | null => {
278281
// FORMAT THEOREM: ∀u: issue(u) → repo(u)
279282
// PURITY: CORE
280283
// EFFECT: n/a
281-
// INVARIANT: issue URL yields repoUrl without repoRef
284+
// INVARIANT: issue URL yields repoUrl + deterministic issue branch
282285
// COMPLEXITY: O(n) where n = |url|
283286
const parseGithubIssueUrl = (input: string): ResolvedRepoInput | null => {
284287
const parsed = parseGithubRefParts(input)
@@ -287,7 +290,12 @@ const parseGithubIssueUrl = (input: string): ResolvedRepoInput | null => {
287290
}
288291

289292
const repo = stripGitSuffix(parsed.repoRaw)
290-
return { repoUrl: `https://github.com/${parsed.owner}/${repo}.git` }
293+
const workspaceSuffix = `issue-${slugify(parsed.ref)}`
294+
return {
295+
repoUrl: `https://github.com/${parsed.owner}/${repo}.git`,
296+
repoRef: workspaceSuffix,
297+
workspaceSuffix
298+
}
291299
}
292300

293301
// CHANGE: normalize repo input and PR/issue URLs into repo + ref

packages/lib/src/core/templates-entrypoint/tasks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ const renderCloneBodyRef = (config: TemplateConfig): string =>
7171
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then
7272
echo "[clone] git clone failed for $REPO_URL"
7373
CLONE_OK=0
74+
elif [[ "$REPO_REF" == issue-* ]]; then
75+
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then
76+
echo "[clone] failed to create local branch '$REPO_REF'"
77+
CLONE_OK=0
78+
fi
7479
fi
7580
else
7681
echo "[clone] git clone failed for $REPO_URL"

0 commit comments

Comments
 (0)