diff --git a/README.md b/README.md index 372defa1..56fb39a1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Key goals: - Functional Core, Imperative Shell implementation (pure templates + typed orchestration). - Per-project `.orch/` directory (env + local state), while still allowing shared credentials across containers. +- Shared package caches (`pnpm`/`npm`/`yarn`) across all project containers. - Optional Playwright MCP + Chromium sidecar so Codex can do browser automation. ## Quickstart @@ -63,6 +64,9 @@ Structure (simplified): auth/ codex/ # shared Codex auth/config (when CODEX_SHARE_AUTH=1) gh/ # GH CLI auth cache for OAuth login container + .cache/ + git-mirrors/ # shared git clone mirrors + packages/ # shared pnpm/npm/yarn caches // docker-compose.yml Dockerfile @@ -115,6 +119,9 @@ Common toggles: - `DOCKER_GIT_ZSH_AUTOSUGGEST=1|0` (default: `1`) - `MCP_PLAYWRIGHT_ISOLATED=1|0` (default: `1`) - `MCP_PLAYWRIGHT_CDP_ENDPOINT=http://...` (override CDP endpoint if needed) +- `PNPM_STORE_DIR=/home/dev/.docker-git/.cache/packages/pnpm/store` (default shared store) +- `NPM_CONFIG_CACHE=/home/dev/.docker-git/.cache/packages/npm` (default shared cache) +- `YARN_CACHE_FOLDER=/home/dev/.docker-git/.cache/packages/yarn` (default shared cache) ## Troubleshooting diff --git a/packages/app/src/docker-git/menu-render-select.ts b/packages/app/src/docker-git/menu-render-select.ts index 555e024e..5b97ba02 100644 --- a/packages/app/src/docker-git/menu-render-select.ts +++ b/packages/app/src/docker-git/menu-render-select.ts @@ -219,7 +219,7 @@ export const renderSelectDetails = ( ? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.") : el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."), el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`), - el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).") + el(Text, { wrap: "wrap" }, "Removes project folder and runs docker compose down -v.") ]), Match.orElse(() => renderDefaultDetails(el, context)) ) diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 7ce3321e..65f245e3 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -165,6 +165,6 @@ export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: { id: { _tag: "Logs" }, label: "docker compose logs --tail=200" }, { id: { _tag: "Down" }, label: "docker compose down" }, { id: { _tag: "DownAll" }, label: "docker compose down (ALL projects)" }, - { id: { _tag: "Delete" }, label: "Delete project (remove folder)" }, + { id: { _tag: "Delete" }, label: "Delete project (folder + container)" }, { id: { _tag: "Quit" }, label: "Quit" } ] diff --git a/packages/docker-git/src/server/codex.ts b/packages/docker-git/src/server/codex.ts index 8700b34b..f2db0427 100644 --- a/packages/docker-git/src/server/codex.ts +++ b/packages/docker-git/src/server/codex.ts @@ -80,7 +80,7 @@ ${codexConfigLine} [features] shell_snapshot = true -collab = true +multi_agent = true apps = true [projects."/home/dev"] @@ -101,7 +101,7 @@ web_search = "live" [features] shell_snapshot = true -collab = true +multi_agent = true apps = true ` diff --git a/packages/docker-git/tests/core/templates.test.ts b/packages/docker-git/tests/core/templates.test.ts index 9d1f6ae0..6702e56b 100644 --- a/packages/docker-git/tests/core/templates.test.ts +++ b/packages/docker-git/tests/core/templates.test.ts @@ -74,6 +74,10 @@ describe("planFiles", () => { ) expect(entrypointSpec.contents).toContain("token=\"$GITHUB_TOKEN\"") expect(entrypointSpec.contents).toContain("CACHE_ROOT=\"/home/dev/.docker-git/.cache/git-mirrors\"") + expect(entrypointSpec.contents).toContain("PACKAGE_CACHE_ROOT=\"/home/dev/.docker-git/.cache/packages\"") + expect(entrypointSpec.contents).toContain("npm_config_store_dir") + expect(entrypointSpec.contents).toContain("NPM_CONFIG_CACHE") + expect(entrypointSpec.contents).toContain("YARN_CACHE_FOLDER") expect(entrypointSpec.contents).toContain("CLONE_CACHE_ARGS=\"--reference-if-able '$CACHE_REPO_DIR' --dissociate\"") expect(entrypointSpec.contents).toContain("[clone-cache] using mirror: $CACHE_REPO_DIR") expect(entrypointSpec.contents).toContain("git clone --progress $CLONE_CACHE_ARGS") diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index dfa37c85..2cddd820 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -6,6 +6,7 @@ import { renderEntrypointDockerSocket, renderEntrypointHeader, renderEntrypointInputRc, + renderEntrypointPackageCache, renderEntrypointSshd, renderEntrypointZshShell, renderEntrypointZshUserRc @@ -32,6 +33,7 @@ import { export const renderEntrypoint = (config: TemplateConfig): string => [ renderEntrypointHeader(config), + renderEntrypointPackageCache(config), renderEntrypointAuthorizedKeys(config), renderEntrypointCodexHome(config), renderEntrypointCodexSharedAuth(config), diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index 42313460..db8771b9 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -42,6 +42,31 @@ docker_git_upsert_ssh_env() { chown 1000:1000 "$SSH_ENV_PATH" || true }` +export const renderEntrypointPackageCache = (config: TemplateConfig): string => + `# Share package manager caches across all docker-git containers +PACKAGE_CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/packages" +PACKAGE_PNPM_STORE="\${npm_config_store_dir:-\${PNPM_STORE_DIR:-$PACKAGE_CACHE_ROOT/pnpm/store}}" +PACKAGE_NPM_CACHE="\${npm_config_cache:-\${NPM_CONFIG_CACHE:-$PACKAGE_CACHE_ROOT/npm}}" +PACKAGE_YARN_CACHE="\${YARN_CACHE_FOLDER:-$PACKAGE_CACHE_ROOT/yarn}" + +mkdir -p "$PACKAGE_PNPM_STORE" "$PACKAGE_NPM_CACHE" "$PACKAGE_YARN_CACHE" +chown -R 1000:1000 "$PACKAGE_CACHE_ROOT" || true + +cat < /etc/profile.d/docker-git-package-cache.sh +export PNPM_STORE_DIR="$PACKAGE_PNPM_STORE" +export npm_config_store_dir="$PACKAGE_PNPM_STORE" +export NPM_CONFIG_CACHE="$PACKAGE_NPM_CACHE" +export npm_config_cache="$PACKAGE_NPM_CACHE" +export YARN_CACHE_FOLDER="$PACKAGE_YARN_CACHE" +EOF +chmod 0644 /etc/profile.d/docker-git-package-cache.sh + +docker_git_upsert_ssh_env "PNPM_STORE_DIR" "$PACKAGE_PNPM_STORE" +docker_git_upsert_ssh_env "npm_config_store_dir" "$PACKAGE_PNPM_STORE" +docker_git_upsert_ssh_env "NPM_CONFIG_CACHE" "$PACKAGE_NPM_CACHE" +docker_git_upsert_ssh_env "npm_config_cache" "$PACKAGE_NPM_CACHE" +docker_git_upsert_ssh_env "YARN_CACHE_FOLDER" "$PACKAGE_YARN_CACHE"` + export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string => `# 1) Authorized keys are mounted from host at /authorized_keys mkdir -p /home/${config.sshUser}/.ssh diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 12395a65..ea190009 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -65,7 +65,7 @@ web_search = "live" [features] shell_snapshot = true -collab = true +multi_agent = true apps = true shell_tool = true EOF diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 790ce51a..083038ab 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -32,7 +32,7 @@ const defaultCodexConfig = [ "", "[features]", "shell_snapshot = true", - "collab = true", + "multi_agent = true", "apps = true", "shell_tool = true" ].join("\n") diff --git a/packages/lib/src/usecases/projects-delete.ts b/packages/lib/src/usecases/projects-delete.ts index 849e05f5..3774de65 100644 --- a/packages/lib/src/usecases/projects-delete.ts +++ b/packages/lib/src/usecases/projects-delete.ts @@ -1,11 +1,12 @@ -import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" import { deriveRepoPathParts } from "../core/domain.js" -import { runDockerComposeDown } from "../shell/docker.js" -import type { DockerCommandError } from "../shell/errors.js" +import { runCommandWithExitCodes } from "../shell/command-runner.js" +import { runDockerComposeDownVolumes } from "../shell/docker.js" +import { CommandFailedError, type DockerCommandError } from "../shell/errors.js" import { renderError } from "./errors.js" import { defaultProjectsRoot } from "./menu-helpers.js" import type { ProjectItem } from "./projects-core.js" @@ -25,19 +26,52 @@ const isWithinProjectsRoot = (path: Path.Path, root: string, target: string): bo return true } +const removeContainerByName = ( + cwd: string, + containerName: string +): Effect.Effect => + runCommandWithExitCodes( + { + cwd, + command: "docker", + args: ["rm", "-f", containerName] + }, + [0], + (exitCode) => new CommandFailedError({ command: `docker rm -f ${containerName}`, exitCode }) + ).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning(`docker rm -f fallback failed for ${containerName}: ${renderError(error)}`), + onSuccess: () => Effect.log(`Removed container: ${containerName}`) + }), + Effect.asVoid + ) + +const removeContainersFallback = ( + item: ProjectItem +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(removeContainerByName(item.projectDir, item.containerName)) + yield* _(removeContainerByName(item.projectDir, `${item.containerName}-browser`)) + }) + // CHANGE: delete a docker-git project directory (state) selected in the TUI // WHY: allow removing unwanted projects without rewriting git history (just delete the folder) // QUOTE(ТЗ): "Сделай возможность так же удалять мусорный для меня контейнер... Не нужно чистить гит историю. Пусть просто папку с ним удалит" // REF: user-request-2026-02-09-delete-project // SOURCE: n/a -// FORMAT THEOREM: forall p: delete(p) -> !exists(projectDir(p)) +// FORMAT THEOREM: forall p: delete(p) -> !exists(projectDir(p)) && !container_exists(p) // PURITY: SHELL // EFFECT: Effect // INVARIANT: never deletes paths outside the projects root // COMPLEXITY: O(docker + fs) export const deleteDockerGitProject = ( item: ProjectItem -): Effect.Effect => +): Effect.Effect< + void, + PlatformError | DockerCommandError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) @@ -56,14 +90,17 @@ export const deleteDockerGitProject = ( return } - // Best-effort: stop the container if possible before removing the compose dir. + // Best-effort: remove compose containers and volumes before deleting the project folder. yield* _( - runDockerComposeDown(targetDir).pipe( - Effect.catchTag( - "DockerCommandError", - (error: DockerCommandError) => - Effect.logWarning(`docker compose down failed before delete: ${renderError(error)}`) - ) + runDockerComposeDownVolumes(targetDir).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.gen(function*(_) { + yield* _(Effect.logWarning(`docker compose down -v failed before delete: ${renderError(error)}`)) + yield* _(removeContainersFallback(item)) + }), + onSuccess: () => Effect.void + }) ) ) diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index c55ab5a3..5806ca17 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -24,6 +24,19 @@ type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandE const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) +const managedRepositoryCachePaths: ReadonlyArray = [".cache/git-mirrors", ".cache/packages"] + +const ensureStateIgnoreAndUntrackCaches = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(ensureStateGitignore(fs, path, root)) + // Best-effort idempotent cleanup: keep cache artifacts out of git history. + yield* _(git(root, ["rm", "-r", "--cached", "--ignore-unmatch", ...managedRepositoryCachePaths], gitBaseEnv)) + }).pipe(Effect.asVoid) + export const statePath: Effect.Effect = Effect.gen(function*(_) { const path = yield* _(Path.Path) const cwd = process.cwd() @@ -48,6 +61,8 @@ export const stateSync = ( ) } + yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root)) + const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv)) if (originUrlExit !== successExitCode) { yield* _(Effect.logWarning(`State dir has no origin remote: ${root}`)) @@ -269,11 +284,17 @@ export const statePush = Effect.gen(function*(_) { export const stateCommit = ( message: string -): Effect.Effect => +): Effect.Effect< + void, + CommandFailedError | PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) + yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root)) yield* _(git(root, ["add", "-A"], gitBaseEnv)) const diffExit = yield* _(gitExitCode(root, ["diff", "--cached", "--quiet"], gitBaseEnv)) diff --git a/packages/lib/src/usecases/state-repo/gitignore.ts b/packages/lib/src/usecases/state-repo/gitignore.ts index b16b3e1d..c4c39dd2 100644 --- a/packages/lib/src/usecases/state-repo/gitignore.ts +++ b/packages/lib/src/usecases/state-repo/gitignore.ts @@ -18,7 +18,8 @@ const volatileCodexIgnorePatterns: ReadonlyArray = [ ] const repositoryCacheIgnorePatterns: ReadonlyArray = [ - ".cache/git-mirrors/" + ".cache/git-mirrors/", + ".cache/packages/" ] const defaultStateGitignore = [ @@ -26,7 +27,7 @@ const defaultStateGitignore = [ "# NOTE: this repo intentionally tracks EVERYTHING under the state dir, including .orch/env and .orch/auth.", "# Keep the remote private; treat it as sensitive infrastructure state.", "", - "# Shared git mirrors cache (do not commit)", + "# Shared repository caches (do not commit)", ...repositoryCacheIgnorePatterns, "", "# Volatile Codex artifacts (do not commit)", @@ -58,7 +59,7 @@ const appendManagedBlocks = ( ): string => { const blocks = [ missing.repositoryCache.length > 0 - ? `# Shared git mirrors cache (do not commit)\n${missing.repositoryCache.join("\n")}` + ? `# Shared repository caches (do not commit)\n${missing.repositoryCache.join("\n")}` : "", missing.volatileCodex.length > 0 ? `# Volatile Codex artifacts (do not commit)\n${missing.volatileCodex.join("\n")}` diff --git a/packages/lib/tests/usecases/projects-delete.test.ts b/packages/lib/tests/usecases/projects-delete.test.ts new file mode 100644 index 00000000..78b73602 --- /dev/null +++ b/packages/lib/tests/usecases/projects-delete.test.ts @@ -0,0 +1,202 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import type { ProjectItem } from "../../src/usecases/projects-core.js" +import { deleteDockerGitProject } from "../../src/usecases/projects-delete.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const includesArgsInOrder = ( + args: ReadonlyArray, + expectedSequence: ReadonlyArray +): boolean => { + let searchFrom = 0 + for (const expected of expectedSequence) { + const foundAt = args.indexOf(expected, searchFrom) + if (foundAt === -1) { + return false + } + searchFrom = foundAt + 1 + } + return true +} + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-delete-project-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const withProjectsRootEnv = ( + projectsRoot: string, + effect: Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.acquireRelease( + Effect.sync(() => { + const prev = process.env["DOCKER_GIT_PROJECTS_ROOT"] + process.env["DOCKER_GIT_PROJECTS_ROOT"] = projectsRoot + return prev + }), + (prev) => + Effect.sync(() => { + if (prev === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = prev + } + }) + ).pipe(Effect.flatMap(() => effect)) + ) + +const makeProjectItem = (root: string, path: Path.Path): ProjectItem => { + const projectDir = path.join(root, "org", "repo") + return { + projectDir, + displayName: "org/repo", + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + containerName: "dg-org-repo", + serviceName: "dg-org-repo", + sshUser: "dev", + sshPort: 2222, + targetDir: "/home/dev/org/repo", + sshCommand: "ssh -p 2222 dev@localhost", + sshKeyPath: null, + authorizedKeysPath: path.join(root, "authorized_keys"), + authorizedKeysExists: false, + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(projectDir, ".orch/env/project.env"), + codexAuthPath: path.join(projectDir, ".orch/auth/codex"), + codexHome: "/home/dev/.codex" + } +} + +const makeFakeExecutor = ( + recorded: Array, + failComposeDownVolumes: boolean +): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + for (const entry of flattened) { + recorded.push({ command: entry.command, args: entry.args }) + } + + const last = flattened[flattened.length - 1]! + const shouldFailComposeDownVolumes = failComposeDownVolumes && + last.command === "docker" && + includesArgsInOrder(last.args, ["compose", "down", "-v"]) + const exit = shouldFailComposeDownVolumes ? 1 : 0 + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(exit)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout: Stream.empty, + toJSON: () => ({ _tag: "DeleteProjectTestProcess", command: last.command, args: last.args, exit }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "DeleteProjectTestProcess", + command: last.command, + args: last.args + }), + toString: () => `[DeleteProjectTestProcess ${last.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +describe("deleteDockerGitProject", () => { + it.effect("runs docker compose down -v before deleting the project directory", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const item = makeProjectItem(root, path) + const recorded: Array = [] + const executor = makeFakeExecutor(recorded, false) + + yield* _(fs.makeDirectory(item.projectDir, { recursive: true })) + + yield* _( + withProjectsRootEnv( + root, + deleteDockerGitProject(item).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor) + ) + ) + ) + + const existsAfter = yield* _(fs.exists(item.projectDir)) + expect(existsAfter).toBe(false) + expect( + recorded.some( + (entry) => + entry.command === "docker" && + includesArgsInOrder(entry.args, ["compose", "down", "-v"]) + ) + ).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("falls back to docker rm -f when docker compose down -v fails", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const item = makeProjectItem(root, path) + const recorded: Array = [] + const executor = makeFakeExecutor(recorded, true) + + yield* _(fs.makeDirectory(item.projectDir, { recursive: true })) + + yield* _( + withProjectsRootEnv( + root, + deleteDockerGitProject(item).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor) + ) + ) + ) + + const existsAfter = yield* _(fs.exists(item.projectDir)) + expect(existsAfter).toBe(false) + + const rmInvocations = recorded.filter( + (entry) => + entry.command === "docker" && + entry.args[0] === "rm" && + entry.args[1] === "-f" + ) + expect(rmInvocations.map((entry) => entry.args[2])).toContain(item.containerName) + expect(rmInvocations.map((entry) => entry.args[2])).toContain(`${item.containerName}-browser`) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/lib/tests/usecases/state-repo-gitignore.test.ts b/packages/lib/tests/usecases/state-repo-gitignore.test.ts index a44d1078..ea8a1e87 100644 --- a/packages/lib/tests/usecases/state-repo-gitignore.test.ts +++ b/packages/lib/tests/usecases/state-repo-gitignore.test.ts @@ -33,6 +33,7 @@ describe("ensureStateGitignore", () => { const gitignore = yield* _(fs.readFileString(path.join(root, ".gitignore"))) expect(gitignore).toContain("# docker-git state repository") expect(gitignore).toContain(".cache/git-mirrors/") + expect(gitignore).toContain(".cache/packages/") expect(gitignore).toContain("**/.orch/auth/codex/models_cache.json") }) ).pipe(Effect.provide(NodeContext.layer))) @@ -57,8 +58,9 @@ describe("ensureStateGitignore", () => { const gitignore = yield* _(fs.readFileString(gitignorePath)) expect(gitignore).toContain("custom-ignore/") - expect(gitignore).toContain("# Shared git mirrors cache (do not commit)") + expect(gitignore).toContain("# Shared repository caches (do not commit)") expect(gitignore).toContain(".cache/git-mirrors/") + expect(gitignore).toContain(".cache/packages/") }) ).pipe(Effect.provide(NodeContext.layer))) })