diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index cb366bd5..72a8199f 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -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" @@ -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 = [".cache/git-mirrors", ".cache/packages"] - const ensureStateIgnoreAndUntrackCaches = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -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}`)) @@ -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}`)) @@ -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 => 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) @@ -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" diff --git a/packages/lib/src/usecases/state-repo/github-auth-state.ts b/packages/lib/src/usecases/state-repo/github-auth-state.ts new file mode 100644 index 00000000..9bdc1279 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/github-auth-state.ts @@ -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 => + 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 = ( + effect: Effect.Effect, + enabled: boolean +): Effect.Effect => + effect.pipe( + Effect.tapError(() => enabled ? Effect.logWarning(githubAuthLoginHint) : Effect.void) + ) diff --git a/packages/lib/src/usecases/state-repo/github-auth.ts b/packages/lib/src/usecases/state-repo/github-auth.ts index 35b4883e..7f068667 100644 --- a/packages/lib/src/usecases/state-repo/github-auth.ts +++ b/packages/lib/src/usecases/state-repo/github-auth.ts @@ -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)?$/ @@ -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"] diff --git a/packages/lib/src/usecases/state-repo/local-ops.ts b/packages/lib/src/usecases/state-repo/local-ops.ts new file mode 100644 index 00000000..724495f3 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/local-ops.ts @@ -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 = [".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)) + 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) diff --git a/packages/lib/src/usecases/state-repo/pull-push.ts b/packages/lib/src/usecases/state-repo/pull-push.ts new file mode 100644 index 00000000..062df8bf --- /dev/null +++ b/packages/lib/src/usecases/state-repo/pull-push.ts @@ -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) diff --git a/packages/lib/tests/usecases/state-repo-github-auth.test.ts b/packages/lib/tests/usecases/state-repo-github-auth.test.ts new file mode 100644 index 00000000..60ff0b02 --- /dev/null +++ b/packages/lib/tests/usecases/state-repo-github-auth.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + isGithubHttpsRemote, + normalizeGithubHttpsRemote, + requiresGithubAuthHint, + tryBuildGithubCompareUrl +} from "../../src/usecases/state-repo/github-auth.js" + +describe("state-repo github auth helpers", () => { + it("treats https remotes with embedded user info as GitHub https remotes", () => { + expect(isGithubHttpsRemote("https://x-access-token@github.com/acme/demo.git")).toBe(true) + }) + + it("normalizes https remotes with embedded user info to the canonical GitHub URL", () => { + expect(normalizeGithubHttpsRemote("https://x-access-token@github.com/acme/demo.git")).toBe( + "https://github.com/acme/demo.git" + ) + }) + + it("keeps compare URL generation working for https remotes with embedded user info", () => { + expect(tryBuildGithubCompareUrl("https://x-access-token@github.com/acme/demo.git", "main", "feature/fix")).toBe( + "https://github.com/acme/demo/compare/main...feature%2Ffix?expand=1" + ) + }) + + it("requires an auth hint only for GitHub https remotes without a usable token", () => { + expect(requiresGithubAuthHint("https://github.com/acme/demo.git", null)).toBe(true) + expect(requiresGithubAuthHint("https://github.com/acme/demo.git", " ")).toBe(true) + expect(requiresGithubAuthHint("https://github.com/acme/demo.git", "ghp_valid")).toBe(false) + expect(requiresGithubAuthHint("git@github.com:acme/demo.git", null)).toBe(false) + }) +})