diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 4369ed1..82f1e82 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -19,7 +19,11 @@ import { import type { AppError } from "@effect-template/lib/usecases/errors" import { renderError } from "@effect-template/lib/usecases/errors" import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" -import { applyAllDockerGitProjects, downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects" +import { + applyAllDockerGitProjects, + downAllDockerGitProjects, + listProjectStatus +} from "@effect-template/lib/usecases/projects" import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" import { sessionGistBackup, @@ -28,6 +32,7 @@ import { sessionGistView } from "@effect-template/lib/usecases/session-gists" import { + autoPullState, stateCommit, stateInit, statePath, @@ -124,18 +129,19 @@ const handleNonBaseCommand = (command: NonBaseCommand) => Match.exhaustive ) -// CHANGE: compose CLI program with typed errors and shell effects -// WHY: keep a thin entry layer over pure parsing and template generation -// QUOTE(ТЗ): "CLI команду... создавать докер образы" -// REF: user-request-2026-01-07 +// CHANGE: compose CLI program with typed errors and shell effects; auto-pull .docker-git on startup +// WHY: keep a thin entry layer over pure parsing and template generation; ensure state is fresh +// QUOTE(ТЗ): "Сделать что бы когда вызывается команда docker-git то происходит git pull для .docker-git папки" +// REF: issue-178 // SOURCE: n/a -// FORMAT THEOREM: forall cmd: handle(cmd) terminates with typed outcome +// FORMAT THEOREM: forall cmd: autoPull() *> handle(cmd) terminates with typed outcome // PURITY: SHELL // EFFECT: Effect -// INVARIANT: help is printed without side effects beyond logs +// INVARIANT: auto-pull never blocks command execution; help is printed without side effects beyond logs // COMPLEXITY: O(n) where n = |files| export const program = pipe( - readCommand, + autoPullState, + Effect.flatMap(() => readCommand), Effect.flatMap((command: Command) => Match.value(command).pipe( Match.when({ _tag: "Help" }, ({ message }) => Effect.log(message)), diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index 72a8199..6c0b202 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -7,7 +7,14 @@ import { runCommandExitCode } from "../shell/command-runner.js" import { CommandFailedError } from "../shell/errors.js" import { defaultProjectsRoot } from "./menu-helpers.js" import { adoptRemoteHistoryIfOrphan } from "./state-repo/adopt-remote.js" -import { autoSyncEnvKey, autoSyncStrictEnvKey, isAutoSyncEnabled, isTruthyEnv } from "./state-repo/env.js" +import { + autoPullEnvKey, + autoSyncEnvKey, + autoSyncStrictEnvKey, + isAutoPullEnabled, + isAutoSyncEnabled, + isTruthyEnv +} from "./state-repo/env.js" import { git, gitBaseEnv, @@ -134,6 +141,58 @@ export const autoSyncState = (message: string): Effect.Effect +// INVARIANT: never fails — errors are logged as warnings; does not block CLI execution +// COMPLEXITY: O(1) network round-trip +export const autoPullState: Effect.Effect = Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const root = resolveStateRoot(path, process.cwd()) + const repoOk = yield* _(isGitRepo(root)) + if (!repoOk) { + return + } + const originOk = yield* _(hasOriginRemote(root)) + const enabled = isAutoPullEnabled(process.env[autoPullEnvKey], originOk) + if (!enabled) { + return + } + yield* _(statePullInternal(root)) +}).pipe( + Effect.matchEffect({ + onFailure: (error) => Effect.logWarning(`State auto-pull failed: ${String(error)}`), + onSuccess: () => Effect.void + }), + Effect.asVoid +) + +// Internal pull that takes an already-resolved root, reusing auth logic from pull-push. +const statePullInternal = ( + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv)) + if (originUrlExit !== successExitCode) { + yield* _(git(root, ["pull", "--rebase"], gitBaseEnv)) + return + } + const rawOriginUrl = yield* _( + gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim())) + ) + const originUrl = yield* _(normalizeOriginUrlIfNeeded(root, rawOriginUrl)) + const token = yield* _(resolveGithubToken(fs, path, root)) + const effect = token && token.length > 0 && isGithubHttpsRemote(originUrl) + ? withGithubAskpassEnv(token, (env) => git(root, ["pull", "--rebase"], env)) + : git(root, ["pull", "--rebase"], gitBaseEnv) + yield* _(effect) + }).pipe(Effect.asVoid) + type StateInitInput = { readonly repoUrl: string readonly repoRef: string diff --git a/packages/lib/src/usecases/state-repo/env.ts b/packages/lib/src/usecases/state-repo/env.ts index de50f8b..30d10a7 100644 --- a/packages/lib/src/usecases/state-repo/env.ts +++ b/packages/lib/src/usecases/state-repo/env.ts @@ -8,12 +8,21 @@ export const isFalsyEnv = (value: string): boolean => { return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off" } +export const autoPullEnvKey = "DOCKER_GIT_STATE_AUTO_PULL" export const autoSyncEnvKey = "DOCKER_GIT_STATE_AUTO_SYNC" export const autoSyncStrictEnvKey = "DOCKER_GIT_STATE_AUTO_SYNC_STRICT" export const defaultSyncMessage = "chore(state): sync" -export const isAutoSyncEnabled = (envValue: string | undefined, hasRemote: boolean): boolean => { +// CHANGE: extract shared predicate for env-controlled feature flags with remote fallback +// WHY: both auto-pull and auto-sync use the same opt-in/opt-out logic; avoid lint duplication warning +// QUOTE(ТЗ): "Сделать что бы когда вызывается команда docker-git то происходит git pull для .docker-git папки" +// REF: issue-178 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns true when remote exists and env var is not explicitly disabled +// COMPLEXITY: O(1) +const isFeatureEnabled = (envValue: string | undefined, hasRemote: boolean): boolean => { if (envValue === undefined) { return hasRemote } @@ -29,3 +38,9 @@ export const isAutoSyncEnabled = (envValue: string | undefined, hasRemote: boole // Non-empty values default to enabled. return true } + +export const isAutoPullEnabled = (envValue: string | undefined, hasRemote: boolean): boolean => + isFeatureEnabled(envValue, hasRemote) + +export const isAutoSyncEnabled = (envValue: string | undefined, hasRemote: boolean): boolean => + isFeatureEnabled(envValue, hasRemote) diff --git a/packages/lib/tests/usecases/state-repo-auto-pull.test.ts b/packages/lib/tests/usecases/state-repo-auto-pull.test.ts new file mode 100644 index 0000000..ac5f851 --- /dev/null +++ b/packages/lib/tests/usecases/state-repo-auto-pull.test.ts @@ -0,0 +1,276 @@ +// CHANGE: integration tests for autoPullState — git pull on .docker-git at startup +// WHY: ensure docker-git performs git pull on .docker-git folder every time it is invoked +// QUOTE(ТЗ): "Сделать что бы когда вызывается команда docker-git то происходит git pull для .docker-git папки" +// REF: issue-178 +// PURITY: SHELL (integration tests using real git) +// INVARIANT: each test uses an isolated temp dir and a local bare repo as fake remote + +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, pipe } from "effect" +import * as Chunk from "effect/Chunk" +import * as Stream from "effect/Stream" + +import { autoPullState } from "../../src/usecases/state-repo.js" + +// --------------------------------------------------------------------------- +// Helpers (same pattern as state-repo-init.test.ts) +// --------------------------------------------------------------------------- + +const seedEnv: Record = { GIT_CONFIG_NOSYSTEM: "1" } + +const collectUint8Array = (chunks: Chunk.Chunk): Uint8Array => + Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => { + const next = new Uint8Array(acc.length + curr.length) + next.set(acc) + next.set(curr, acc.length) + return next + }) + +const captureGit = ( + args: ReadonlyArray, + cwd: string +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + const cmd = pipe( + Command.make("git", ...args), + Command.workingDirectory(cwd), + Command.env(seedEnv), + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.stdin("pipe") + ) + const proc = yield* _(executor.start(cmd)) + const bytes = yield* _( + pipe(proc.stdout, Stream.runCollect, Effect.map((c) => collectUint8Array(c))) + ) + const exitCode = yield* _(proc.exitCode) + if (Number(exitCode) !== 0) { + return yield* _(Effect.fail(new Error(`git ${args.join(" ")} exited with ${String(exitCode)}`))) + } + return new TextDecoder("utf-8").decode(bytes).trim() + }) + ) + +const runShell = ( + script: string, + cwd: string +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + const cmd = pipe( + Command.make("sh", "-c", script), + Command.workingDirectory(cwd), + Command.env(seedEnv), + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.stdin("pipe") + ) + const proc = yield* _(executor.start(cmd)) + const bytes = yield* _( + pipe(proc.stdout, Stream.runCollect, Effect.map((c) => collectUint8Array(c))) + ) + const exitCode = yield* _(proc.exitCode) + if (Number(exitCode) !== 0) { + return yield* _(Effect.fail(new Error(`sh -c '${script}' exited with ${String(exitCode)}`))) + } + return new TextDecoder("utf-8").decode(bytes).trim() + }) + ) + +const makeFakeRemote = ( + p: Path.Path, + baseDir: string, + withInitialCommit: boolean +): Effect.Effect => + Effect.gen(function*(_) { + const remotePath = p.join(baseDir, "remote.git") + yield* _(runShell( + `git init --bare --initial-branch=main "${remotePath}" 2>/dev/null || git init --bare "${remotePath}"`, + baseDir + )) + + if (withInitialCommit) { + const seedDir = p.join(baseDir, "seed") + yield* _(runShell( + `git init --initial-branch=main "${seedDir}" 2>/dev/null || git init "${seedDir}"`, + baseDir + )) + yield* _(captureGit(["config", "user.email", "test@example.com"], seedDir)) + yield* _(captureGit(["config", "user.name", "Test"], seedDir)) + yield* _(captureGit(["remote", "add", "origin", remotePath], seedDir)) + yield* _(runShell(`echo "# .docker-git" > "${seedDir}/README.md"`, seedDir)) + yield* _(captureGit(["add", "-A"], seedDir)) + yield* _(captureGit(["commit", "-m", "initial"], seedDir)) + yield* _(captureGit(["push", "origin", "HEAD:refs/heads/main"], seedDir)) + } + + return remotePath + }) + +const withTempStateRoot = ( + use: (opts: { tempBase: string; stateRoot: string }) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const p = yield* _(Path.Path) + const tempBase = yield* _( + fs.makeTempDirectoryScoped({ prefix: "docker-git-auto-pull-" }) + ) + const stateRoot = p.join(tempBase, "state") + + const previous = process.env["DOCKER_GIT_PROJECTS_ROOT"] + yield* _( + Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = previous + } + }) + ) + ) + process.env["DOCKER_GIT_PROJECTS_ROOT"] = stateRoot + + return yield* _(use({ tempBase, stateRoot })) + }) + ) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("autoPullState", () => { + it.effect("pulls new commits from remote into .docker-git", () => + withTempStateRoot(({ tempBase, stateRoot }) => + Effect.gen(function*(_) { + const p = yield* _(Path.Path) + const remoteUrl = yield* _(makeFakeRemote(p, tempBase, true)) + + // Clone the remote into stateRoot (simulating an existing .docker-git) + yield* _(runShell( + `git clone "${remoteUrl}" "${stateRoot}"`, + tempBase + )) + const headBefore = yield* _(captureGit(["rev-parse", "HEAD"], stateRoot)) + + // Push a new commit to the remote from a separate working copy + const pusherDir = p.join(tempBase, "pusher") + yield* _(runShell(`git clone "${remoteUrl}" "${pusherDir}"`, tempBase)) + yield* _(captureGit(["config", "user.email", "test@example.com"], pusherDir)) + yield* _(captureGit(["config", "user.name", "Test"], pusherDir)) + yield* _(runShell(`echo "new content" > "${pusherDir}/new-file.txt"`, pusherDir)) + yield* _(captureGit(["add", "-A"], pusherDir)) + yield* _(captureGit(["commit", "-m", "add new file"], pusherDir)) + yield* _(captureGit(["push", "origin", "HEAD:refs/heads/main"], pusherDir)) + + const remoteHead = yield* _(captureGit(["rev-parse", "HEAD"], pusherDir)) + expect(remoteHead).not.toBe(headBefore) + + // Run autoPullState — it should pull the new commit + yield* _(autoPullState) + + const headAfter = yield* _(captureGit(["rev-parse", "HEAD"], stateRoot)) + expect(headAfter).toBe(remoteHead) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does nothing when .docker-git is not a git repo", () => + withTempStateRoot(({ stateRoot }) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + yield* _(fs.makeDirectory(stateRoot, { recursive: true })) + yield* _(fs.writeFileString(`${stateRoot}/some-file.txt`, "content\n")) + + // Should not fail even though the dir is not a git repo + yield* _(autoPullState) + + // Directory is unchanged + const content = yield* _(fs.readFileString(`${stateRoot}/some-file.txt`)) + expect(content).toBe("content\n") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does nothing when .docker-git has no origin remote", () => + withTempStateRoot(({ stateRoot }) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + yield* _(fs.makeDirectory(stateRoot, { recursive: true })) + yield* _(runShell( + `git init --initial-branch=main "${stateRoot}" 2>/dev/null || git init "${stateRoot}"`, + stateRoot + )) + yield* _(captureGit(["config", "user.email", "test@example.com"], stateRoot)) + yield* _(captureGit(["config", "user.name", "Test"], stateRoot)) + yield* _(runShell(`echo "data" > "${stateRoot}/file.txt"`, stateRoot)) + yield* _(captureGit(["add", "-A"], stateRoot)) + yield* _(captureGit(["commit", "-m", "init"], stateRoot)) + + const headBefore = yield* _(captureGit(["rev-parse", "HEAD"], stateRoot)) + + // Should not fail and not change HEAD + yield* _(autoPullState) + + const headAfter = yield* _(captureGit(["rev-parse", "HEAD"], stateRoot)) + expect(headAfter).toBe(headBefore) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("respects DOCKER_GIT_STATE_AUTO_PULL=false to skip pull", () => + withTempStateRoot(({ tempBase, stateRoot }) => + Effect.gen(function*(_) { + const p = yield* _(Path.Path) + const remoteUrl = yield* _(makeFakeRemote(p, tempBase, true)) + + yield* _(runShell(`git clone "${remoteUrl}" "${stateRoot}"`, tempBase)) + const headBefore = yield* _(captureGit(["rev-parse", "HEAD"], stateRoot)) + + // Push a new commit + const pusherDir = p.join(tempBase, "pusher") + yield* _(runShell(`git clone "${remoteUrl}" "${pusherDir}"`, tempBase)) + yield* _(captureGit(["config", "user.email", "test@example.com"], pusherDir)) + yield* _(captureGit(["config", "user.name", "Test"], pusherDir)) + yield* _(runShell(`echo "new" > "${pusherDir}/new.txt"`, pusherDir)) + yield* _(captureGit(["add", "-A"], pusherDir)) + yield* _(captureGit(["commit", "-m", "new commit"], pusherDir)) + yield* _(captureGit(["push", "origin", "HEAD:refs/heads/main"], pusherDir)) + + // Disable auto-pull via env var and restore after + const prevAutoPull = process.env["DOCKER_GIT_STATE_AUTO_PULL"] + process.env["DOCKER_GIT_STATE_AUTO_PULL"] = "false" + yield* _( + Effect.ensuring( + autoPullState, + Effect.sync(() => { + if (prevAutoPull === undefined) { + delete process.env["DOCKER_GIT_STATE_AUTO_PULL"] + } else { + process.env["DOCKER_GIT_STATE_AUTO_PULL"] = prevAutoPull + } + }) + ) + ) + + // HEAD should NOT have changed because auto-pull was disabled + const headAfter = yield* _(captureGit(["rev-parse", "HEAD"], stateRoot)) + expect(headAfter).toBe(headBefore) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not fail when .docker-git directory does not exist", () => + withTempStateRoot(() => + Effect.gen(function*(_) { + // stateRoot does not exist at all — autoPullState should silently succeed + yield* _(autoPullState) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/lib/tests/usecases/state-repo/auto-pull-env.test.ts b/packages/lib/tests/usecases/state-repo/auto-pull-env.test.ts new file mode 100644 index 0000000..01e136c --- /dev/null +++ b/packages/lib/tests/usecases/state-repo/auto-pull-env.test.ts @@ -0,0 +1,69 @@ +// CHANGE: unit tests for isAutoPullEnabled env predicate +// WHY: ensure auto-pull can be controlled via DOCKER_GIT_STATE_AUTO_PULL env var +// QUOTE(ТЗ): "Сделать что бы когда вызывается команда docker-git то происходит git pull для .docker-git папки" +// REF: issue-178 +// PURITY: CORE (pure predicate tests) +// INVARIANT: behaviour mirrors isAutoSyncEnabled — enabled by default when remote exists + +import { describe, expect, it } from "@effect/vitest" +import { isAutoPullEnabled } from "../../../src/usecases/state-repo/env.js" + +describe("isAutoPullEnabled", () => { + it("returns true when env is undefined and remote exists", () => { + expect(isAutoPullEnabled(undefined, true)).toBe(true) + }) + + it("returns false when env is undefined and no remote", () => { + expect(isAutoPullEnabled(undefined, false)).toBe(false) + }) + + it("returns true when env is empty string and remote exists", () => { + expect(isAutoPullEnabled("", true)).toBe(true) + }) + + it("returns false when env is empty string and no remote", () => { + expect(isAutoPullEnabled("", false)).toBe(false) + }) + + it("returns false when env is '0'", () => { + expect(isAutoPullEnabled("0", true)).toBe(false) + }) + + it("returns false when env is 'false'", () => { + expect(isAutoPullEnabled("false", true)).toBe(false) + }) + + it("returns false when env is 'no'", () => { + expect(isAutoPullEnabled("no", true)).toBe(false) + }) + + it("returns false when env is 'off'", () => { + expect(isAutoPullEnabled("off", true)).toBe(false) + }) + + it("returns true when env is '1'", () => { + expect(isAutoPullEnabled("1", false)).toBe(true) + }) + + it("returns true when env is 'true'", () => { + expect(isAutoPullEnabled("true", false)).toBe(true) + }) + + it("returns true when env is 'yes'", () => { + expect(isAutoPullEnabled("yes", false)).toBe(true) + }) + + it("returns true when env is 'on'", () => { + expect(isAutoPullEnabled("on", false)).toBe(true) + }) + + it("is case-insensitive for truthy values", () => { + expect(isAutoPullEnabled("TRUE", false)).toBe(true) + expect(isAutoPullEnabled("Yes", false)).toBe(true) + }) + + it("is case-insensitive for falsy values", () => { + expect(isAutoPullEnabled("FALSE", true)).toBe(false) + expect(isAutoPullEnabled("No", true)).toBe(false) + }) +})