Skip to content

Commit 215e4c2

Browse files
authored
feat: parallel issue/PR workspaces in one repository (#9)
* feat(core): isolate issue/pr workspaces for parallel work * fix(app): remove duplicate workspace path logic * feat(shell): add issue workspace AGENTS context * feat(cli): add --force-env soft reset mode * fix(lib): resolve lint regressions in templates and file writer * refactor(lib): split long functions for lint constraints * refactor(lib): replace force flags params with options objects * refactor(lib): remove forbidden casts in create-project options * fix(lib): harden issue workspace env reset flow * fix(lib): split auth sync copy helpers for lint * fix(lib): manage AGENTS blocks without full overwrite --------- Co-authored-by: skulidropek <skulidropek@users.noreply.github.com>
1 parent 961e7ab commit 215e4c2

File tree

25 files changed

+640
-214
lines changed

25 files changed

+640
-214
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,32 @@ 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+
26+
# Reset only project env defaults (keep workspace volume/data)
27+
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env
28+
2329
# Same, but also enable Playwright MCP + Chromium sidecar for Codex
2430
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --mcp-playwright
2531
```
2632

33+
## Parallel Issues / PRs
34+
35+
When you clone GitHub issue or PR URLs, docker-git creates isolated project paths and container names:
36+
- `.../issues/123` -> `<projectsRoot>/<owner>/<repo>/issue-123` (branch `issue-123`)
37+
- `.../pull/45` -> `<projectsRoot>/<owner>/<repo>/pr-45` (ref `refs/pull/45/head`)
38+
39+
This lets you run multiple issues/PRs for the same repository in parallel without container/path collisions.
40+
41+
Force modes:
42+
- `--force`: overwrite managed files and wipe compose volumes (`docker compose down -v`).
43+
- `--force-env`: reset only project env defaults and recreate containers without wiping volumes.
44+
45+
Agent context for issue workspaces:
46+
- Global `${CODEX_HOME}/AGENTS.md` includes workspace path + issue/PR context.
47+
- For `issue-*` workspaces, docker-git creates `${TARGET_DIR}/AGENTS.md` (if missing) with issue context and auto-adds it to `.git/info/exclude`.
48+
2749
## Projects Root Layout
2850

2951
The projects root is:

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,24 @@ import type { RawOptions } from "@effect-template/lib/core/command-options"
55
import {
66
type Command,
77
defaultTemplateConfig,
8-
deriveRepoPathParts,
98
type ParseError,
109
resolveRepoInput
1110
} from "@effect-template/lib/core/domain"
1211

1312
import { parseRawOptions } from "./parser-options.js"
14-
import { splitPositionalRepo } from "./parser-shared.js"
13+
import { resolveWorkspaceRepoPath, splitPositionalRepo } from "./parser-shared.js"
1514

16-
const applyCloneDefaults = (raw: RawOptions, repoUrl: string): RawOptions => {
17-
const repoPath = deriveRepoPathParts(repoUrl).pathParts.join("/")
15+
const applyCloneDefaults = (
16+
raw: RawOptions,
17+
rawRepoUrl: string,
18+
resolvedRepo: ReturnType<typeof resolveRepoInput>
19+
): RawOptions => {
20+
const repoPath = resolveWorkspaceRepoPath(resolvedRepo)
1821
const sshUser = raw.sshUser?.trim() ?? defaultTemplateConfig.sshUser
1922
const homeDir = `/home/${sshUser}`
2023
return {
2124
...raw,
22-
repoUrl,
25+
repoUrl: rawRepoUrl,
2326
outDir: raw.outDir ?? `.docker-git/${repoPath}`,
2427
targetDir: raw.targetDir ?? `${homeDir}/${repoPath}`
2528
}
@@ -42,7 +45,7 @@ export const parseClone = (args: ReadonlyArray<string>): Either.Either<Command,
4245
const raw = yield* _(parseRawOptions(restArgs))
4346
const rawRepoUrl = yield* _(nonEmpty("--repo-url", raw.repoUrl ?? positionalRepoUrl))
4447
const resolvedRepo = resolveRepoInput(rawRepoUrl)
45-
const withDefaults = applyCloneDefaults(raw, resolvedRepo.repoUrl)
48+
const withDefaults = applyCloneDefaults(raw, rawRepoUrl, resolvedRepo)
4649
const withRef = resolvedRepo.repoRef !== undefined && raw.repoRef === undefined
4750
? { ...withDefaults, repoRef: resolvedRepo.repoRef }
4851
: withDefaults

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
6666
"--up": (raw) => ({ ...raw, up: true }),
6767
"--no-up": (raw) => ({ ...raw, up: false }),
6868
"--force": (raw) => ({ ...raw, force: true }),
69+
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
6970
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
7071
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
7172
"--web": (raw) => ({ ...raw, authWeb: true }),

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ type PositionalRepo = {
99
readonly restArgs: ReadonlyArray<string>
1010
}
1111

12+
export const resolveWorkspaceRepoPath = (
13+
resolvedRepo: ReturnType<typeof resolveRepoInput>
14+
): string => {
15+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
16+
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
17+
return projectParts.join("/")
18+
}
19+
1220
export const splitPositionalRepo = (args: ReadonlyArray<string>): PositionalRepo => {
1321
const first = args[0]
1422
const positionalRepoUrl = first !== undefined && !first.startsWith("-") ? first : undefined
@@ -24,10 +32,10 @@ export const parseProjectDirWithOptions = (
2432
const { positionalRepoUrl, restArgs } = splitPositionalRepo(args)
2533
const raw = yield* _(parseRawOptions(restArgs))
2634
const rawRepoUrl = raw.repoUrl ?? positionalRepoUrl
27-
const resolvedRepo = rawRepoUrl ? resolveRepoInput(rawRepoUrl).repoUrl : null
35+
const repoPath = rawRepoUrl ? resolveWorkspaceRepoPath(resolveRepoInput(rawRepoUrl)) : null
2836
const projectDir = raw.projectDir ??
29-
(resolvedRepo
30-
? `.docker-git/${deriveRepoPathParts(resolvedRepo).pathParts.join("/")}`
37+
(repoPath
38+
? `.docker-git/${repoPath}`
3139
: defaultProjectDir)
3240

3341
return { projectDir, raw }

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

Lines changed: 3 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,13 +42,14 @@ 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
4949
--up | --no-up Run docker compose up after init (default: --up)
5050
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
5151
--force Overwrite existing files and wipe compose volumes (docker compose down -v)
52+
--force-env Reset project env defaults only (keep workspace volume/data)
5253
-h, --help Show this help
5354
5455
Container runtime env (set via .orch/env/project.env):

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

Lines changed: 12 additions & 5 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"
@@ -59,6 +59,9 @@ export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
5959
if (input.force) {
6060
args.push("--force")
6161
}
62+
if (input.forceEnv) {
63+
args.push("--force-env")
64+
}
6265
return args
6366
}
6467

@@ -91,26 +94,30 @@ const joinPath = (...parts: ReadonlyArray<string>): string => {
9194
}
9295

9396
const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
94-
const repoPath = deriveRepoPathParts(repoUrl).pathParts
95-
return joinPath(defaultProjectsRoot(cwd), ...repoPath)
97+
const resolvedRepo = resolveRepoInput(repoUrl)
98+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
99+
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
100+
return joinPath(defaultProjectsRoot(cwd), ...projectParts)
96101
}
97102

98103
export const resolveCreateInputs = (
99104
cwd: string,
100105
values: Partial<CreateInputs>
101106
): CreateInputs => {
102107
const repoUrl = values.repoUrl ?? ""
108+
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
103109
const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
104110
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")
105111

106112
return {
107113
repoUrl,
108-
repoRef: values.repoRef ?? "main",
114+
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
109115
outDir,
110116
secretsRoot,
111117
runUp: values.runUp !== false,
112118
enableMcpPlaywright: values.enableMcpPlaywright === true,
113-
force: values.force === true
119+
force: values.force === true,
120+
forceEnv: values.forceEnv === true
114121
}
115122
}
116123

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type CreateInputs = {
5050
readonly runUp: boolean
5151
readonly enableMcpPlaywright: boolean
5252
readonly force: boolean
53+
readonly forceEnv: boolean
5354
}
5455

5556
export type CreateStep =

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const expectCreateDefaults = (command: CreateCommand) => {
3333
expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef)
3434
expect(command.outDir).toBe(".docker-git/org/repo")
3535
expect(command.runUp).toBe(true)
36+
expect(command.forceEnv).toBe(false)
3637
}
3738

3839
describe("parseArgs", () => {
@@ -45,6 +46,16 @@ describe("parseArgs", () => {
4546
expect(command.config.sshPort).toBe(defaultTemplateConfig.sshPort)
4647
}))
4748

49+
it.effect("parses create command with issue url into isolated defaults", () =>
50+
expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => {
51+
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
52+
expect(command.config.repoRef).toBe("issue-9")
53+
expect(command.outDir).toBe(".docker-git/org/repo/issue-9")
54+
expect(command.config.containerName).toBe("dg-repo-issue-9")
55+
expect(command.config.serviceName).toBe("dg-repo-issue-9")
56+
expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
57+
}))
58+
4859
it.effect("fails on missing repo url", () =>
4960
Effect.sync(() => {
5061
Either.match(parseArgs(["create"]), {
@@ -68,6 +79,18 @@ describe("parseArgs", () => {
6879
expect(command.config.repoRef).toBe("feature-x")
6980
}))
7081

82+
it.effect("parses force-env flag for clone", () =>
83+
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => {
84+
expect(command.force).toBe(false)
85+
expect(command.forceEnv).toBe(true)
86+
}))
87+
88+
it.effect("supports force + force-env together", () =>
89+
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force", "--force-env"], (command) => {
90+
expect(command.force).toBe(true)
91+
expect(command.forceEnv).toBe(true)
92+
}))
93+
7194
it.effect("parses GitHub tree url as repo + ref", () =>
7295
expectCreateCommand(["clone", "https://github.com/agiens/crm/tree/vova-fork"], (command) => {
7396
expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git")
@@ -76,6 +99,37 @@ describe("parseArgs", () => {
7699
expect(command.config.targetDir).toBe("/home/dev/agiens/crm")
77100
}))
78101

102+
it.effect("parses GitHub issue url as isolated project + issue branch", () =>
103+
expectCreateCommand(["clone", "https://github.com/org/repo/issues/5"], (command) => {
104+
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
105+
expect(command.config.repoRef).toBe("issue-5")
106+
expect(command.outDir).toBe(".docker-git/org/repo/issue-5")
107+
expect(command.config.targetDir).toBe("/home/dev/org/repo/issue-5")
108+
expect(command.config.containerName).toBe("dg-repo-issue-5")
109+
expect(command.config.serviceName).toBe("dg-repo-issue-5")
110+
expect(command.config.volumeName).toBe("dg-repo-issue-5-home")
111+
}))
112+
113+
it.effect("parses GitHub PR url as isolated project", () =>
114+
expectCreateCommand(["clone", "https://github.com/org/repo/pull/42"], (command) => {
115+
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
116+
expect(command.config.repoRef).toBe("refs/pull/42/head")
117+
expect(command.outDir).toBe(".docker-git/org/repo/pr-42")
118+
expect(command.config.targetDir).toBe("/home/dev/org/repo/pr-42")
119+
expect(command.config.containerName).toBe("dg-repo-pr-42")
120+
expect(command.config.serviceName).toBe("dg-repo-pr-42")
121+
expect(command.config.volumeName).toBe("dg-repo-pr-42-home")
122+
}))
123+
124+
it.effect("parses attach with GitHub issue url into issue workspace", () =>
125+
Effect.sync(() => {
126+
const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"])
127+
if (command._tag !== "Attach") {
128+
throw new Error("expected Attach command")
129+
}
130+
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
131+
}))
132+
79133
it.effect("parses down-all command", () =>
80134
Effect.sync(() => {
81135
const command = parseOrThrow(["down-all"])

packages/docker-git/src/server/http.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor
11161116
outDir: project.directory,
11171117
runUp: false,
11181118
force: true,
1119+
forceEnv: false,
11191120
waitForClone: false
11201121
}))
11211122
yield* _(syncProjectCodexAuth(projectsRoot, project))
@@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)}
14551456
outDir: project.directory,
14561457
runUp: false,
14571458
force: true,
1459+
forceEnv: false,
14581460
waitForClone: false
14591461
}))
14601462
yield* _(syncProjectCodexAuth(projectsRoot, project))

packages/docker-git/tests/core/templates.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,39 @@ describe("planFiles", () => {
9595
expect(browserDockerfile !== undefined && browserDockerfile._tag === "File").toBe(true)
9696
expect(browserScript !== undefined && browserScript._tag === "File").toBe(true)
9797
}))
98+
99+
it.effect("embeds issue workspace AGENTS context in entrypoint", () =>
100+
Effect.sync(() => {
101+
const config: TemplateConfig = {
102+
containerName: "dg-repo-issue-5",
103+
serviceName: "dg-repo-issue-5",
104+
sshUser: "dev",
105+
sshPort: 2222,
106+
repoUrl: "https://github.com/org/repo.git",
107+
repoRef: "issue-5",
108+
targetDir: "/home/dev/org/repo/issue-5",
109+
volumeName: "dg-repo-issue-5-home",
110+
authorizedKeysPath: "./authorized_keys",
111+
envGlobalPath: "./.orch/env/global.env",
112+
envProjectPath: "./.orch/env/project.env",
113+
codexAuthPath: "./.orch/auth/codex",
114+
codexSharedAuthPath: "../../.orch/auth/codex",
115+
codexHome: "/home/dev/.codex",
116+
enableMcpPlaywright: false,
117+
pnpmVersion: "10.27.0"
118+
}
119+
120+
const specs = planFiles(config)
121+
const entrypointSpec = specs.find(
122+
(spec) => spec._tag === "File" && spec.relativePath === "entrypoint.sh"
123+
)
124+
expect(entrypointSpec !== undefined && entrypointSpec._tag === "File").toBe(true)
125+
if (entrypointSpec && entrypointSpec._tag === "File") {
126+
expect(entrypointSpec.contents).toContain("Доступные workspace пути:")
127+
expect(entrypointSpec.contents).toContain("Контекст workspace:")
128+
expect(entrypointSpec.contents).toContain("Issue AGENTS.md:")
129+
expect(entrypointSpec.contents).toContain("ISSUE_AGENTS_PATH=\"$TARGET_DIR/AGENTS.md\"")
130+
expect(entrypointSpec.contents).toContain("grep -qx \"AGENTS.md\" \"$EXCLUDE_PATH\"")
131+
}
132+
}))
98133
})

0 commit comments

Comments
 (0)