Skip to content

Commit 093b72c

Browse files
committed
feat(core): allow empty docker-git create workspaces
1 parent 9aeaf0c commit 093b72c

6 files changed

Lines changed: 82 additions & 47 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# docker-git
22

3-
`docker-git` generates a disposable Docker development environment per repository and stores it under a single projects root (default: `~/.docker-git`).
3+
`docker-git` generates a disposable Docker development environment per repository (or empty workspace) and stores it under a single projects root (default: `~/.docker-git`).
44

55
Key goals:
66
- Functional Core, Imperative Shell implementation (pure templates + typed orchestration).
@@ -18,6 +18,9 @@ pnpm install
1818
# Interactive TUI menu (default)
1919
pnpm run docker-git
2020

21+
# Create an empty workspace container (no git clone)
22+
pnpm run docker-git create
23+
2124
# Clone a repo into its own container (creates under ~/.docker-git)
2225
pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force
2326

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Match } from "effect"
33
import type { ParseError } from "@effect-template/lib/core/domain"
44

55
export const usageText = `docker-git menu
6-
docker-git create --repo-url <url> [options]
6+
docker-git create [--repo-url <url>] [options]
77
docker-git clone <url> [options]
88
docker-git apply [<url>] [options]
99
docker-git mcp-playwright [<url>] [options]
@@ -20,7 +20,7 @@ docker-git state <action> [options]
2020
2121
Commands:
2222
menu Interactive menu (default when no args)
23-
create, init Generate docker development environment
23+
create, init Generate docker development environment (repo URL optional)
2424
clone Create + run container and clone repo
2525
apply Apply docker-git config to an existing project/container (current dir by default)
2626
mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir
@@ -34,6 +34,7 @@ Commands:
3434
state Manage docker-git state directory via git (sync across machines)
3535
3636
Options:
37+
--repo-url <url> Repository URL (create: optional; clone: required via positional arg or flag)
3738
--repo-ref <ref> Git ref/branch (default: main)
3839
--branch, -b <ref> Alias for --repo-ref
3940
--target-dir <path> Target dir inside container (create default: /home/dev/app, clone default: ~/workspaces/<org>/<repo>[/issue-<id>|/pr-<id>])

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,16 @@ type CreateReturnContext = CreateContext & {
4646
}
4747

4848
export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
49-
const args: Array<string> = ["create", "--repo-url", input.repoUrl]
49+
const args: Array<string> = ["create"]
50+
if (input.repoUrl.length > 0) {
51+
args.push("--repo-url", input.repoUrl)
52+
}
5053
if (input.repoRef.length > 0) {
5154
args.push("--repo-ref", input.repoRef)
5255
}
53-
args.push("--out-dir", input.outDir)
56+
if (input.outDir.length > 0) {
57+
args.push("--out-dir", input.outDir)
58+
}
5459
if (!input.runUp) {
5560
args.push("--no-up")
5661
}
@@ -106,8 +111,8 @@ export const resolveCreateInputs = (
106111
values: Partial<CreateInputs>
107112
): CreateInputs => {
108113
const repoUrl = values.repoUrl ?? ""
109-
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
110-
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")
114+
const resolvedRepoRef = resolveRepoInput(repoUrl).repoRef
115+
const outDir = values.outDir ?? resolveDefaultOutDir(cwd, repoUrl)
111116

112117
return {
113118
repoUrl,
@@ -179,10 +184,6 @@ const applyCreateStep = (input: {
179184
}): boolean =>
180185
Match.value(input.step).pipe(
181186
Match.when("repoUrl", () => {
182-
if (input.buffer.length === 0) {
183-
input.setMessage("Repo URL is required.")
184-
return false
185-
}
186187
input.nextValues.repoUrl = input.buffer
187188
input.nextValues.outDir = resolveDefaultOutDir(input.cwd, input.buffer)
188189
return true
@@ -222,11 +223,6 @@ const finalizeCreateFlow = (input: {
222223
readonly setActiveDir: (dir: string | null) => void
223224
}) => {
224225
const inputs = resolveCreateInputs(input.state.cwd, input.nextValues)
225-
if (inputs.repoUrl.length === 0) {
226-
input.setMessage("Repo URL is required.")
227-
return
228-
}
229-
230226
const parsed = parseArgs(buildCreateArgs(inputs))
231227
if (Either.isLeft(parsed)) {
232228
input.setMessage(formatParseError(parsed.left))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { createSteps, menuItems } from "./menu-types.js"
2828

2929
export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): string =>
3030
Match.value(step).pipe(
31-
Match.when("repoUrl", () => "Repo URL"),
31+
Match.when("repoUrl", () => "Repo URL (optional for empty workspace)"),
3232
Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`),
3333
Match.when("outDir", () => `Output dir [${defaults.outDir}]`),
3434
Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`),

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,20 @@ describe("parseArgs", () => {
101101
expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
102102
}))
103103

104-
it.effect("fails on missing repo url", () => expectParseErrorTag(["create"], "MissingRequiredOption"))
104+
it.effect("parses create command without repo url into empty workspace defaults", () =>
105+
expectCreateCommand(["create"], (command) => {
106+
expect(command.config.repoUrl).toBe("")
107+
expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef)
108+
expect(command.outDir).toBe(".docker-git/app")
109+
expect(command.openSsh).toBe(false)
110+
expect(command.waitForClone).toBe(false)
111+
expect(command.config.containerName).toBe("dg-app")
112+
expect(command.config.serviceName).toBe("dg-app")
113+
expect(command.config.volumeName).toBe("dg-app-home")
114+
expect(command.config.targetDir).toBe(expandDefaultTargetDir(defaultTemplateConfig.targetDir))
115+
}))
116+
117+
it.effect("fails clone when repo url is missing", () => expectParseErrorTag(["clone"], "MissingRequiredOption"))
105118

106119
it.effect("parses clone command with positional repo url", () =>
107120
expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => {

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

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ type RepoBasics = {
7777

7878
const resolveRepoBasics = (raw: RawOptions): Either.Either<RepoBasics, ParseError> =>
7979
Either.gen(function*(_) {
80-
const rawRepoUrl = yield* _(nonEmpty("--repo-url", raw.repoUrl))
80+
const rawRepoUrl = raw.repoUrl?.trim() ?? ""
8181
const resolvedRepo = resolveRepoInput(rawRepoUrl)
8282
const repoUrl = resolvedRepo.repoUrl
8383
const repoSlug = deriveRepoSlug(repoUrl)
@@ -200,6 +200,47 @@ const resolvePaths = (
200200
}
201201
})
202202

203+
type BuildTemplateConfigInput = {
204+
readonly raw: RawOptions
205+
readonly repo: RepoBasics
206+
readonly names: NameConfig
207+
readonly paths: PathConfig
208+
readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"]
209+
readonly dockerSharedNetworkName: string
210+
}
211+
212+
const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => {
213+
const enableMcpPlaywright = input.raw.enableMcpPlaywright ?? false
214+
const gitTokenLabel = normalizeGitTokenLabel(input.raw.gitTokenLabel)
215+
const codexAuthLabel = normalizeAuthLabel(input.raw.codexTokenLabel)
216+
const claudeAuthLabel = normalizeAuthLabel(input.raw.claudeTokenLabel)
217+
218+
return {
219+
containerName: input.names.containerName,
220+
serviceName: input.names.serviceName,
221+
sshUser: input.repo.sshUser,
222+
sshPort: input.repo.sshPort,
223+
repoUrl: input.repo.repoUrl,
224+
repoRef: input.repo.repoRef,
225+
gitTokenLabel,
226+
codexAuthLabel,
227+
claudeAuthLabel,
228+
targetDir: input.repo.targetDir,
229+
volumeName: input.names.volumeName,
230+
dockerGitPath: input.paths.dockerGitPath,
231+
authorizedKeysPath: input.paths.authorizedKeysPath,
232+
envGlobalPath: input.paths.envGlobalPath,
233+
envProjectPath: input.paths.envProjectPath,
234+
codexAuthPath: input.paths.codexAuthPath,
235+
codexSharedAuthPath: input.paths.codexSharedAuthPath,
236+
codexHome: input.paths.codexHome,
237+
dockerNetworkMode: input.dockerNetworkMode,
238+
dockerSharedNetworkName: input.dockerSharedNetworkName,
239+
enableMcpPlaywright,
240+
pnpmVersion: defaultTemplateConfig.pnpmVersion
241+
}
242+
}
243+
203244
// CHANGE: build a typed create command from raw options (CLI or API)
204245
// WHY: share deterministic command construction across CLI and server
205246
// QUOTE(ТЗ): "В lib ты оставляешь бизнес логику, а все CLI морду хранишь в app"
@@ -221,14 +262,18 @@ export const buildCreateCommand = (
221262
const openSsh = raw.openSsh ?? false
222263
const force = raw.force ?? false
223264
const forceEnv = raw.forceEnv ?? false
224-
const enableMcpPlaywright = raw.enableMcpPlaywright ?? false
225-
const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel)
226-
const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel)
227-
const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel)
228265
const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode))
229266
const dockerSharedNetworkName = yield* _(
230267
nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
231268
)
269+
const config = buildTemplateConfig({
270+
raw,
271+
repo,
272+
names,
273+
paths,
274+
dockerNetworkMode,
275+
dockerSharedNetworkName
276+
})
232277

233278
return {
234279
_tag: "Create",
@@ -238,29 +283,6 @@ export const buildCreateCommand = (
238283
force,
239284
forceEnv,
240285
waitForClone: false,
241-
config: {
242-
containerName: names.containerName,
243-
serviceName: names.serviceName,
244-
sshUser: repo.sshUser,
245-
sshPort: repo.sshPort,
246-
repoUrl: repo.repoUrl,
247-
repoRef: repo.repoRef,
248-
gitTokenLabel,
249-
codexAuthLabel,
250-
claudeAuthLabel,
251-
targetDir: repo.targetDir,
252-
volumeName: names.volumeName,
253-
dockerGitPath: paths.dockerGitPath,
254-
authorizedKeysPath: paths.authorizedKeysPath,
255-
envGlobalPath: paths.envGlobalPath,
256-
envProjectPath: paths.envProjectPath,
257-
codexAuthPath: paths.codexAuthPath,
258-
codexSharedAuthPath: paths.codexSharedAuthPath,
259-
codexHome: paths.codexHome,
260-
dockerNetworkMode,
261-
dockerSharedNetworkName,
262-
enableMcpPlaywright,
263-
pnpmVersion: defaultTemplateConfig.pnpmVersion
264-
}
286+
config
265287
}
266288
})

0 commit comments

Comments
 (0)