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: 14 additions & 8 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +32,7 @@ import {
sessionGistView
} from "@effect-template/lib/usecases/session-gists"
import {
autoPullState,
stateCommit,
stateInit,
statePath,
Expand Down Expand Up @@ -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<void, AppError, FileSystem | Path | CommandExecutor>
// 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)),
Expand Down
61 changes: 60 additions & 1 deletion packages/lib/src/usecases/state-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -134,6 +141,58 @@ export const autoSyncState = (message: string): Effect.Effect<void, never, State
Effect.asVoid
)

// CHANGE: add autoPullState to perform git pull on .docker-git at startup
// WHY: ensure local .docker-git state is up-to-date every time the docker-git command runs
// QUOTE(ТЗ): "Сделать что бы когда вызывается команда docker-git то происходит git pull для .docker-git папки"
// REF: issue-178
// PURITY: SHELL
// EFFECT: Effect<void, never, StateRepoEnv>
// INVARIANT: never fails — errors are logged as warnings; does not block CLI execution
// COMPLEXITY: O(1) network round-trip
export const autoPullState: Effect.Effect<void, never, StateRepoEnv> = 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<void, CommandFailedError | PlatformError, StateRepoEnv> =>
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
Expand Down
17 changes: 16 additions & 1 deletion packages/lib/src/usecases/state-repo/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Loading