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
109 changes: 19 additions & 90 deletions packages/lib/src/usecases/state-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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, pipe } from "effect"
import { Effect } from "effect"
import { runCommandExitCode } from "../shell/command-runner.js"
import { CommandFailedError } from "../shell/errors.js"
import { defaultProjectsRoot } from "./menu-helpers.js"
Expand All @@ -17,17 +17,19 @@ import {
isGitRepo,
successExitCode
} from "./state-repo/git-commands.js"
import {
githubAuthLoginHint,
normalizeOriginUrlIfNeeded,
shouldLogGithubAuthHintForStateSyncFailure
} from "./state-repo/github-auth-state.js"
import type { GitAuthEnv } from "./state-repo/github-auth.js"
import { isGithubHttpsRemote, resolveGithubToken, withGithubAskpassEnv } from "./state-repo/github-auth.js"
import { ensureStateGitignore } from "./state-repo/gitignore.js"
import { runStateSyncOps, runStateSyncWithToken } from "./state-repo/sync-ops.js"

type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor

const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))

const managedRepositoryCachePaths: ReadonlyArray<string> = [".cache/git-mirrors", ".cache/packages"]

const ensureStateIgnoreAndUntrackCaches = (
fs: FileSystem.FileSystem,
path: Path.Path,
Expand All @@ -53,7 +55,6 @@ export const stateSync = (
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const root = resolveStateRoot(path, process.cwd())

const repoExit = yield* _(gitExitCode(root, ["rev-parse", "--is-inside-work-tree"], gitBaseEnv))
if (repoExit !== successExitCode) {
yield* _(Effect.logWarning(`State dir is not a git repository: ${root}`))
Expand All @@ -62,9 +63,7 @@ export const stateSync = (
Effect.fail(new CommandFailedError({ command: "git rev-parse --is-inside-work-tree", exitCode: repoExit }))
)
}

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}`))
Expand All @@ -73,33 +72,38 @@ export const stateSync = (
Effect.fail(new CommandFailedError({ command: "git remote get-url origin", exitCode: originUrlExit }))
)
}
const originUrl = yield* _(
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 syncEffect = token && token.length > 0 && isGithubHttpsRemote(originUrl)
? runStateSyncWithToken(token, root, originUrl, message)
: runStateSyncOps(root, originUrl, message, gitBaseEnv)

yield* _(syncEffect)
yield* _(
syncEffect.pipe(
Effect.tapError((error) =>
shouldLogGithubAuthHintForStateSyncFailure(originUrl, token, error)
? Effect.logWarning(githubAuthLoginHint)
: Effect.void
)
)
)
}).pipe(Effect.asVoid)

export const autoSyncState = (message: string): 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 = isAutoSyncEnabled(process.env[autoSyncEnvKey], originOk)
if (!enabled) {
return
}

const strictValue = process.env[autoSyncStrictEnvKey]
const strict = strictValue !== undefined && strictValue.trim().length > 0 ? isTruthyEnv(strictValue) : false
const effect = stateSync(message)
Expand Down Expand Up @@ -243,80 +247,5 @@ export const stateInit = (
: doInit(gitBaseEnv)
}

export const stateStatus = Effect.gen(function*(_) {
const path = yield* _(Path.Path)
const root = resolveStateRoot(path, process.cwd())
const output = yield* _(gitCapture(root, ["status", "-sb", "--porcelain=v1"], gitBaseEnv))
yield* _(Effect.log(output.trim().length > 0 ? output.trimEnd() : "(clean)"))
}).pipe(Effect.asVoid)

export const statePull = Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const root = resolveStateRoot(path, process.cwd())
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
if (originUrlExit !== successExitCode) {
yield* _(git(root, ["pull", "--rebase"], gitBaseEnv))
return
}
const originUrl = yield* _(
gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim()))
)
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)

export const statePush = Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const root = resolveStateRoot(path, process.cwd())
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
if (originUrlExit !== successExitCode) {
yield* _(git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv))
return
}
const originUrl = yield* _(
gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim()))
)
const token = yield* _(resolveGithubToken(fs, path, root))
const effect = token && token.length > 0 && isGithubHttpsRemote(originUrl)
? withGithubAskpassEnv(
token,
(env) =>
pipe(
gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env),
Effect.map((value) => value.trim()),
Effect.map((branch) => (branch === "HEAD" ? "main" : branch)),
Effect.flatMap((branch) => git(root, ["push", "--no-verify", originUrl, `HEAD:refs/heads/${branch}`], env))
)
)
: git(root, ["push", "--no-verify", "-u", "origin", "HEAD"], gitBaseEnv)
yield* _(effect)
}).pipe(Effect.asVoid)

export const stateCommit = (
message: string
): 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))

if (diffExit === successExitCode) {
yield* _(Effect.log("Nothing to commit."))
return
}

yield* _(git(root, ["commit", "-m", message], gitBaseEnv))
}).pipe(Effect.asVoid)
export { stateCommit, stateStatus } from "./state-repo/local-ops.js"
export { statePull, statePush } from "./state-repo/pull-push.js"
69 changes: 69 additions & 0 deletions packages/lib/src/usecases/state-repo/github-auth-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
import type { PlatformError } from "@effect/platform/Error"
import type * as FileSystem from "@effect/platform/FileSystem"
import type * as Path from "@effect/platform/Path"
import { Effect } from "effect"
import type { CommandFailedError } from "../../shell/errors.js"
import { git, gitBaseEnv, gitCapture } from "./git-commands.js"
import {
isGithubHttpsRemote,
normalizeGithubHttpsRemote,
requiresGithubAuthHint,
resolveGithubToken
} from "./github-auth.js"

export const githubAuthLoginHint =
"GitHub is not authorized for docker-git. To use state sync, run: docker-git auth github login --web"

export const normalizeOriginUrlIfNeeded = (
root: string,
originUrl: string
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
const normalized = normalizeGithubHttpsRemote(originUrl)
if (normalized === null || normalized === originUrl) {
return originUrl
}
yield* _(git(root, ["remote", "set-url", "origin", normalized], gitBaseEnv))
return normalized
})

export const resolveStateGithubContext = (
fs: FileSystem.FileSystem,
path: Path.Path,
root: string
): Effect.Effect<
{ readonly originUrl: string; readonly token: string | null; readonly authHintNeeded: boolean },
CommandFailedError | PlatformError,
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
> =>
Effect.gen(function*(_) {
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))
return {
originUrl,
token,
authHintNeeded: requiresGithubAuthHint(originUrl, token)
}
})

export const shouldLogGithubAuthHintForStateSyncFailure = (
originUrl: string,
token: string | null,
error: CommandFailedError | PlatformError
): boolean =>
requiresGithubAuthHint(originUrl, token) ||
(isGithubHttpsRemote(originUrl) &&
error._tag === "CommandFailedError" &&
error.command === "git fetch origin --prune")

export const withGithubAuthHintOnFailure = <A, E, R>(
effect: Effect.Effect<A, E, R>,
enabled: boolean
): Effect.Effect<A, E, R> =>
effect.pipe(
Effect.tapError(() => enabled ? Effect.logWarning(githubAuthLoginHint) : Effect.void)
)
15 changes: 13 additions & 2 deletions packages/lib/src/usecases/state-repo/github-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { gitBaseEnv } from "./git-commands.js"

const githubTokenKey = "GITHUB_TOKEN"

const githubHttpsRemoteRe = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/
const githubHttpsRemoteRe = /^https:\/\/(?:[^/]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/
const githubSshRemoteRe = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/
const githubSshUrlRemoteRe = /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/

Expand Down Expand Up @@ -43,7 +43,18 @@ export const tryBuildGithubCompareUrl = (
}?expand=1`
}

export const isGithubHttpsRemote = (url: string): boolean => /^https:\/\/github\.com\//.test(url.trim())
export const isGithubHttpsRemote = (url: string): boolean => /^https:\/\/(?:[^/]+@)?github\.com\//.test(url.trim())

export const normalizeGithubHttpsRemote = (url: string): string | null => {
if (!isGithubHttpsRemote(url)) {
return null
}
const parts = tryParseGithubRemoteParts(url)
return parts === null ? null : `https://github.com/${parts.owner}/${parts.repo}.git`
}

export const requiresGithubAuthHint = (originUrl: string, token: string | null | undefined): boolean =>
isGithubHttpsRemote(originUrl) && (token?.trim() ?? "").length === 0

const resolveTokenFromProcessEnv = (): string | null => {
const github = process.env["GITHUB_TOKEN"]
Expand Down
53 changes: 53 additions & 0 deletions packages/lib/src/usecases/state-repo/local-ops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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 type { CommandFailedError } from "../../shell/errors.js"
import { defaultProjectsRoot } from "../menu-helpers.js"
import { git, gitBaseEnv, gitCapture, gitExitCode, successExitCode } from "./git-commands.js"
import { ensureStateGitignore } from "./gitignore.js"

type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor

const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))

const managedRepositoryCachePaths: ReadonlyArray<string> = [".cache/git-mirrors", ".cache/packages"]

const ensureStateIgnoreAndUntrackCaches = (
fs: FileSystem.FileSystem,
path: Path.Path,
root: string
): Effect.Effect<void, CommandFailedError | PlatformError, StateRepoEnv> =>
Effect.gen(function*(_) {
yield* _(ensureStateGitignore(fs, path, root))
yield* _(git(root, ["rm", "-r", "--cached", "--ignore-unmatch", ...managedRepositoryCachePaths], gitBaseEnv))
}).pipe(Effect.asVoid)

export const stateStatus = Effect.gen(function*(_) {
const path = yield* _(Path.Path)
const root = resolveStateRoot(path, process.cwd())
const output = yield* _(gitCapture(root, ["status", "-sb", "--porcelain=v1"], gitBaseEnv))
yield* _(Effect.log(output.trim().length > 0 ? output.trimEnd() : "(clean)"))
}).pipe(Effect.asVoid)

export const stateCommit = (
message: string
): 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))
if (diffExit === successExitCode) {
yield* _(Effect.log("Nothing to commit."))
return
}
yield* _(git(root, ["commit", "-m", message], gitBaseEnv))
}).pipe(Effect.asVoid)
63 changes: 63 additions & 0 deletions packages/lib/src/usecases/state-repo/pull-push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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, pipe } from "effect"
import type { CommandFailedError } from "../../shell/errors.js"
import { defaultProjectsRoot } from "../menu-helpers.js"
import { git, gitBaseEnv, gitCapture, gitExitCode, successExitCode } from "./git-commands.js"
import { resolveStateGithubContext, withGithubAuthHintOnFailure } from "./github-auth-state.js"
import { isGithubHttpsRemote, withGithubAskpassEnv } from "./github-auth.js"

const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))

export const statePull: 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())
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
if (originUrlExit !== successExitCode) {
yield* _(git(root, ["pull", "--rebase"], gitBaseEnv))
return
}
const auth = yield* _(resolveStateGithubContext(fs, path, root))
const effect = auth.token && auth.token.length > 0 && isGithubHttpsRemote(auth.originUrl)
? withGithubAskpassEnv(auth.token, (env) => git(root, ["pull", "--rebase"], env))
: git(root, ["pull", "--rebase"], gitBaseEnv)
yield* _(withGithubAuthHintOnFailure(effect, auth.authHintNeeded))
}).pipe(Effect.asVoid)

export const statePush: 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())
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
if (originUrlExit !== successExitCode) {
yield* _(git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv))
return
}
const auth = yield* _(resolveStateGithubContext(fs, path, root))
const effect = auth.token && auth.token.length > 0 && isGithubHttpsRemote(auth.originUrl)
? withGithubAskpassEnv(
auth.token,
(env) =>
pipe(
gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env),
Effect.map((value) => value.trim()),
Effect.map((branch) => (branch === "HEAD" ? "main" : branch)),
Effect.flatMap((branch) =>
git(root, ["push", "--no-verify", auth.originUrl, `HEAD:refs/heads/${branch}`], env)
)
)
)
: git(root, ["push", "--no-verify", "-u", "origin", "HEAD"], gitBaseEnv)
yield* _(withGithubAuthHintOnFailure(effect, auth.authHintNeeded))
}).pipe(Effect.asVoid)
Loading
Loading