Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,32 @@ pnpm run docker-git
# Clone a repo into its own container (creates under ~/.docker-git)
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force

# Clone an issue URL (creates isolated workspace + issue branch)
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force

# Reset only project env defaults (keep workspace volume/data)
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env

# Same, but also enable Playwright MCP + Chromium sidecar for Codex
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --mcp-playwright
```

## Parallel Issues / PRs

When you clone GitHub issue or PR URLs, docker-git creates isolated project paths and container names:
- `.../issues/123` -> `<projectsRoot>/<owner>/<repo>/issue-123` (branch `issue-123`)
- `.../pull/45` -> `<projectsRoot>/<owner>/<repo>/pr-45` (ref `refs/pull/45/head`)

This lets you run multiple issues/PRs for the same repository in parallel without container/path collisions.

Force modes:
- `--force`: overwrite managed files and wipe compose volumes (`docker compose down -v`).
- `--force-env`: reset only project env defaults and recreate containers without wiping volumes.

Agent context for issue workspaces:
- Global `${CODEX_HOME}/AGENTS.md` includes workspace path + issue/PR context.
- For `issue-*` workspaces, docker-git creates `${TARGET_DIR}/AGENTS.md` (if missing) with issue context and auto-adds it to `.git/info/exclude`.

## Projects Root Layout

The projects root is:
Expand Down
15 changes: 9 additions & 6 deletions packages/app/src/docker-git/cli/parser-clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@ import type { RawOptions } from "@effect-template/lib/core/command-options"
import {
type Command,
defaultTemplateConfig,
deriveRepoPathParts,
type ParseError,
resolveRepoInput
} from "@effect-template/lib/core/domain"

import { parseRawOptions } from "./parser-options.js"
import { splitPositionalRepo } from "./parser-shared.js"
import { resolveWorkspaceRepoPath, splitPositionalRepo } from "./parser-shared.js"

const applyCloneDefaults = (raw: RawOptions, repoUrl: string): RawOptions => {
const repoPath = deriveRepoPathParts(repoUrl).pathParts.join("/")
const applyCloneDefaults = (
raw: RawOptions,
rawRepoUrl: string,
resolvedRepo: ReturnType<typeof resolveRepoInput>
): RawOptions => {
const repoPath = resolveWorkspaceRepoPath(resolvedRepo)
const sshUser = raw.sshUser?.trim() ?? defaultTemplateConfig.sshUser
const homeDir = `/home/${sshUser}`
return {
...raw,
repoUrl,
repoUrl: rawRepoUrl,
outDir: raw.outDir ?? `.docker-git/${repoPath}`,
targetDir: raw.targetDir ?? `${homeDir}/${repoPath}`
}
Expand All @@ -42,7 +45,7 @@ export const parseClone = (args: ReadonlyArray<string>): Either.Either<Command,
const raw = yield* _(parseRawOptions(restArgs))
const rawRepoUrl = yield* _(nonEmpty("--repo-url", raw.repoUrl ?? positionalRepoUrl))
const resolvedRepo = resolveRepoInput(rawRepoUrl)
const withDefaults = applyCloneDefaults(raw, resolvedRepo.repoUrl)
const withDefaults = applyCloneDefaults(raw, rawRepoUrl, resolvedRepo)
const withRef = resolvedRepo.repoRef !== undefined && raw.repoRef === undefined
? { ...withDefaults, repoRef: resolvedRepo.repoRef }
: withDefaults
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
"--up": (raw) => ({ ...raw, up: true }),
"--no-up": (raw) => ({ ...raw, up: false }),
"--force": (raw) => ({ ...raw, force: true }),
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
"--web": (raw) => ({ ...raw, authWeb: true }),
Expand Down
14 changes: 11 additions & 3 deletions packages/app/src/docker-git/cli/parser-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ type PositionalRepo = {
readonly restArgs: ReadonlyArray<string>
}

export const resolveWorkspaceRepoPath = (
resolvedRepo: ReturnType<typeof resolveRepoInput>
): string => {
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
return projectParts.join("/")
}

export const splitPositionalRepo = (args: ReadonlyArray<string>): PositionalRepo => {
const first = args[0]
const positionalRepoUrl = first !== undefined && !first.startsWith("-") ? first : undefined
Expand All @@ -24,10 +32,10 @@ export const parseProjectDirWithOptions = (
const { positionalRepoUrl, restArgs } = splitPositionalRepo(args)
const raw = yield* _(parseRawOptions(restArgs))
const rawRepoUrl = raw.repoUrl ?? positionalRepoUrl
const resolvedRepo = rawRepoUrl ? resolveRepoInput(rawRepoUrl).repoUrl : null
const repoPath = rawRepoUrl ? resolveWorkspaceRepoPath(resolveRepoInput(rawRepoUrl)) : null
const projectDir = raw.projectDir ??
(resolvedRepo
? `.docker-git/${deriveRepoPathParts(resolvedRepo).pathParts.join("/")}`
(repoPath
? `.docker-git/${repoPath}`
: defaultProjectDir)

return { projectDir, raw }
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Commands:
Options:
--repo-ref <ref> Git ref/branch (default: main)
--branch, -b <ref> Alias for --repo-ref
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: /home/dev/<org>/<repo>)
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: /home/dev/<org>/<repo>[/issue-<id>|/pr-<id>])
--ssh-port <port> Local SSH port (default: 2222)
--ssh-user <user> SSH user inside container (default: dev)
--container-name <name> Docker container name (default: dg-<repo>)
Expand All @@ -42,13 +42,14 @@ Options:
--env-project <path> Host path to project env file (default: ./.orch/env/project.env)
--codex-auth <path> Host path for Codex auth cache (default: <projectsRoot>/.orch/auth/codex)
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>)
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
--project-dir <path> Project directory for attach (default: .)
--lines <n> Tail last N lines for sessions logs (default: 200)
--include-default Show default/system processes in sessions list
--up | --no-up Run docker compose up after init (default: --up)
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
--force Overwrite existing files and wipe compose volumes (docker compose down -v)
--force-env Reset project env defaults only (keep workspace volume/data)
-h, --help Show this help

Container runtime env (set via .orch/env/project.env):
Expand Down
17 changes: 12 additions & 5 deletions packages/app/src/docker-git/menu-create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type CreateCommand, deriveRepoPathParts } from "@effect-template/lib/core/domain"
import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain"
import { createProject } from "@effect-template/lib/usecases/actions"
import type { AppError } from "@effect-template/lib/usecases/errors"
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
Expand Down Expand Up @@ -59,6 +59,9 @@ export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
if (input.force) {
args.push("--force")
}
if (input.forceEnv) {
args.push("--force-env")
}
return args
}

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

const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
const repoPath = deriveRepoPathParts(repoUrl).pathParts
return joinPath(defaultProjectsRoot(cwd), ...repoPath)
const resolvedRepo = resolveRepoInput(repoUrl)
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
return joinPath(defaultProjectsRoot(cwd), ...projectParts)
}

export const resolveCreateInputs = (
cwd: string,
values: Partial<CreateInputs>
): CreateInputs => {
const repoUrl = values.repoUrl ?? ""
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")

return {
repoUrl,
repoRef: values.repoRef ?? "main",
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
outDir,
secretsRoot,
runUp: values.runUp !== false,
enableMcpPlaywright: values.enableMcpPlaywright === true,
force: values.force === true
force: values.force === true,
forceEnv: values.forceEnv === true
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type CreateInputs = {
readonly runUp: boolean
readonly enableMcpPlaywright: boolean
readonly force: boolean
readonly forceEnv: boolean
}

export type CreateStep =
Expand Down
54 changes: 54 additions & 0 deletions packages/app/tests/docker-git/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const expectCreateDefaults = (command: CreateCommand) => {
expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef)
expect(command.outDir).toBe(".docker-git/org/repo")
expect(command.runUp).toBe(true)
expect(command.forceEnv).toBe(false)
}

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

it.effect("parses create command with issue url into isolated defaults", () =>
expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => {
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
expect(command.config.repoRef).toBe("issue-9")
expect(command.outDir).toBe(".docker-git/org/repo/issue-9")
expect(command.config.containerName).toBe("dg-repo-issue-9")
expect(command.config.serviceName).toBe("dg-repo-issue-9")
expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
}))

it.effect("fails on missing repo url", () =>
Effect.sync(() => {
Either.match(parseArgs(["create"]), {
Expand All @@ -68,6 +79,18 @@ describe("parseArgs", () => {
expect(command.config.repoRef).toBe("feature-x")
}))

it.effect("parses force-env flag for clone", () =>
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => {
expect(command.force).toBe(false)
expect(command.forceEnv).toBe(true)
}))

it.effect("supports force + force-env together", () =>
expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force", "--force-env"], (command) => {
expect(command.force).toBe(true)
expect(command.forceEnv).toBe(true)
}))

it.effect("parses GitHub tree url as repo + ref", () =>
expectCreateCommand(["clone", "https://github.com/agiens/crm/tree/vova-fork"], (command) => {
expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git")
Expand All @@ -76,6 +99,37 @@ describe("parseArgs", () => {
expect(command.config.targetDir).toBe("/home/dev/agiens/crm")
}))

it.effect("parses GitHub issue url as isolated project + issue branch", () =>
expectCreateCommand(["clone", "https://github.com/org/repo/issues/5"], (command) => {
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
expect(command.config.repoRef).toBe("issue-5")
expect(command.outDir).toBe(".docker-git/org/repo/issue-5")
expect(command.config.targetDir).toBe("/home/dev/org/repo/issue-5")
expect(command.config.containerName).toBe("dg-repo-issue-5")
expect(command.config.serviceName).toBe("dg-repo-issue-5")
expect(command.config.volumeName).toBe("dg-repo-issue-5-home")
}))

it.effect("parses GitHub PR url as isolated project", () =>
expectCreateCommand(["clone", "https://github.com/org/repo/pull/42"], (command) => {
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
expect(command.config.repoRef).toBe("refs/pull/42/head")
expect(command.outDir).toBe(".docker-git/org/repo/pr-42")
expect(command.config.targetDir).toBe("/home/dev/org/repo/pr-42")
expect(command.config.containerName).toBe("dg-repo-pr-42")
expect(command.config.serviceName).toBe("dg-repo-pr-42")
expect(command.config.volumeName).toBe("dg-repo-pr-42-home")
}))

it.effect("parses attach with GitHub issue url into issue workspace", () =>
Effect.sync(() => {
const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"])
if (command._tag !== "Attach") {
throw new Error("expected Attach command")
}
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
}))

it.effect("parses down-all command", () =>
Effect.sync(() => {
const command = parseOrThrow(["down-all"])
Expand Down
2 changes: 2 additions & 0 deletions packages/docker-git/src/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor
outDir: project.directory,
runUp: false,
force: true,
forceEnv: false,
waitForClone: false
}))
yield* _(syncProjectCodexAuth(projectsRoot, project))
Expand Down Expand Up @@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)}
outDir: project.directory,
runUp: false,
force: true,
forceEnv: false,
waitForClone: false
}))
yield* _(syncProjectCodexAuth(projectsRoot, project))
Expand Down
35 changes: 35 additions & 0 deletions packages/docker-git/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,39 @@ describe("planFiles", () => {
expect(browserDockerfile !== undefined && browserDockerfile._tag === "File").toBe(true)
expect(browserScript !== undefined && browserScript._tag === "File").toBe(true)
}))

it.effect("embeds issue workspace AGENTS context in entrypoint", () =>
Effect.sync(() => {
const config: TemplateConfig = {
containerName: "dg-repo-issue-5",
serviceName: "dg-repo-issue-5",
sshUser: "dev",
sshPort: 2222,
repoUrl: "https://github.com/org/repo.git",
repoRef: "issue-5",
targetDir: "/home/dev/org/repo/issue-5",
volumeName: "dg-repo-issue-5-home",
authorizedKeysPath: "./authorized_keys",
envGlobalPath: "./.orch/env/global.env",
envProjectPath: "./.orch/env/project.env",
codexAuthPath: "./.orch/auth/codex",
codexSharedAuthPath: "../../.orch/auth/codex",
codexHome: "/home/dev/.codex",
enableMcpPlaywright: false,
pnpmVersion: "10.27.0"
}

const specs = planFiles(config)
const entrypointSpec = specs.find(
(spec) => spec._tag === "File" && spec.relativePath === "entrypoint.sh"
)
expect(entrypointSpec !== undefined && entrypointSpec._tag === "File").toBe(true)
if (entrypointSpec && entrypointSpec._tag === "File") {
expect(entrypointSpec.contents).toContain("Доступные workspace пути:")
expect(entrypointSpec.contents).toContain("Контекст workspace:")
expect(entrypointSpec.contents).toContain("Issue AGENTS.md:")
expect(entrypointSpec.contents).toContain("ISSUE_AGENTS_PATH=\"$TARGET_DIR/AGENTS.md\"")
expect(entrypointSpec.contents).toContain("grep -qx \"AGENTS.md\" \"$EXCLUDE_PATH\"")
}
}))
})
Loading