Skip to content

Commit 9df826d

Browse files
skulidropekclaude
andcommitted
fix(state-repo): pass auth token through stateInit and sync correctly with remote
- Pass GitHub token from ensureStateDotDockerGitRepo through stateInit to all git operations (clone, fetch, adopt-remote), fixing auth failures on private repos - Replace git reset --soft with stash → hard reset → stash pop in sync flow so remote is always pulled first and local changes overlay on top without deleting remote files - Resolve stash pop conflicts by keeping local version (--theirs) - Add env parameter to adoptRemoteHistoryIfOrphan, cloneStateRepo, initRepoIfNeeded, ensureOriginRemote, checkoutBranchBestEffort Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b87ae3e commit 9df826d

4 files changed

Lines changed: 83 additions & 58 deletions

File tree

packages/lib/src/usecases/state-repo-github.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export const ensureStateDotDockerGitRepo = (
123123
}
124124

125125
yield* _(Effect.log(`Initializing state repository: ${cloneUrl}`))
126-
yield* _(stateInit({ repoUrl: cloneUrl, repoRef: defaultStateRef }))
126+
yield* _(stateInit({ repoUrl: cloneUrl, repoRef: defaultStateRef, token }))
127127
})
128128
).pipe(
129129
Effect.matchEffect({

packages/lib/src/usecases/state-repo.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
isGitRepo,
1818
successExitCode
1919
} from "./state-repo/git-commands.js"
20+
import type { GitAuthEnv } from "./state-repo/github-auth.js"
2021
import { isGithubHttpsRemote, resolveGithubToken, withGithubAskpassEnv } from "./state-repo/github-auth.js"
2122
import { ensureStateGitignore } from "./state-repo/gitignore.js"
2223
import { runStateSyncOps, runStateSyncWithToken } from "./state-repo/sync-ops.js"
@@ -132,16 +133,18 @@ export const autoSyncState = (message: string): Effect.Effect<void, never, State
132133
type StateInitInput = {
133134
readonly repoUrl: string
134135
readonly repoRef: string
136+
readonly token?: string
135137
}
136138

137139
const cloneStateRepo = (
138140
root: string,
139-
input: StateInitInput
141+
input: StateInitInput,
142+
env: GitAuthEnv
140143
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
141144
Effect.gen(function*(_) {
142145
const cloneWithBranch = ["clone", "--branch", input.repoRef, input.repoUrl, root]
143146
const cloneBranchExit = yield* _(
144-
runCommandExitCode({ cwd: root, command: "git", args: cloneWithBranch, env: gitBaseEnv })
147+
runCommandExitCode({ cwd: root, command: "git", args: cloneWithBranch, env })
145148
)
146149
if (cloneBranchExit === successExitCode) {
147150
return
@@ -156,7 +159,7 @@ const cloneStateRepo = (
156159
)
157160
const cloneDefault = ["clone", input.repoUrl, root]
158161
const cloneDefaultExit = yield* _(
159-
runCommandExitCode({ cwd: root, command: "git", args: cloneDefault, env: gitBaseEnv })
162+
runCommandExitCode({ cwd: root, command: "git", args: cloneDefault, env })
160163
)
161164
if (cloneDefaultExit !== successExitCode) {
162165
return yield* _(Effect.fail(new CommandFailedError({ command: "git clone", exitCode: cloneDefaultExit })))
@@ -167,7 +170,8 @@ const initRepoIfNeeded = (
167170
fs: FileSystem.FileSystem,
168171
path: Path.Path,
169172
root: string,
170-
input: StateInitInput
173+
input: StateInitInput,
174+
env: GitAuthEnv
171175
): Effect.Effect<void, CommandFailedError | PlatformError, StateRepoEnv> =>
172176
Effect.gen(function*(_) {
173177
yield* _(fs.makeDirectory(root, { recursive: true }))
@@ -180,32 +184,34 @@ const initRepoIfNeeded = (
180184

181185
const entries = yield* _(fs.readDirectory(root))
182186
if (entries.length === 0) {
183-
yield* _(cloneStateRepo(root, input))
187+
yield* _(cloneStateRepo(root, input, env))
184188
yield* _(Effect.log(`State dir cloned: ${root}`))
185189
return
186190
}
187191

188-
yield* _(git(root, ["init", "--initial-branch=main"], gitBaseEnv))
192+
yield* _(git(root, ["init", "--initial-branch=main"], env))
189193
}).pipe(Effect.asVoid)
190194

191195
const ensureOriginRemote = (
192196
root: string,
193-
repoUrl: string
197+
repoUrl: string,
198+
env: GitAuthEnv
194199
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
195200
Effect.gen(function*(_) {
196-
const setUrlExit = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], gitBaseEnv))
201+
const setUrlExit = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], env))
197202
if (setUrlExit === successExitCode) {
198203
return
199204
}
200-
yield* _(git(root, ["remote", "add", "origin", repoUrl], gitBaseEnv))
205+
yield* _(git(root, ["remote", "add", "origin", repoUrl], env))
201206
})
202207

203208
const checkoutBranchBestEffort = (
204209
root: string,
205-
repoRef: string
210+
repoRef: string,
211+
env: GitAuthEnv
206212
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
207213
Effect.gen(function*(_) {
208-
const checkoutExit = yield* _(gitExitCode(root, ["checkout", "-B", repoRef], gitBaseEnv))
214+
const checkoutExit = yield* _(gitExitCode(root, ["checkout", "-B", repoRef], env))
209215
if (checkoutExit === successExitCode) {
210216
return
211217
}
@@ -214,21 +220,28 @@ const checkoutBranchBestEffort = (
214220

215221
export const stateInit = (
216222
input: StateInitInput
217-
): Effect.Effect<void, CommandFailedError | PlatformError, StateRepoEnv> =>
218-
Effect.gen(function*(_) {
219-
const fs = yield* _(FileSystem.FileSystem)
220-
const path = yield* _(Path.Path)
221-
const root = resolveStateRoot(path, process.cwd())
222-
223-
yield* _(initRepoIfNeeded(fs, path, root, input))
224-
yield* _(ensureOriginRemote(root, input.repoUrl))
225-
yield* _(adoptRemoteHistoryIfOrphan(root, input.repoRef))
226-
yield* _(checkoutBranchBestEffort(root, input.repoRef))
227-
yield* _(ensureStateGitignore(fs, path, root))
228-
229-
yield* _(Effect.log(`State dir ready: ${root}`))
230-
yield* _(Effect.log(`Remote: ${input.repoUrl}`))
231-
}).pipe(Effect.asVoid)
223+
): Effect.Effect<void, CommandFailedError | PlatformError, StateRepoEnv> => {
224+
const doInit = (env: GitAuthEnv) =>
225+
Effect.gen(function*(_) {
226+
const fs = yield* _(FileSystem.FileSystem)
227+
const path = yield* _(Path.Path)
228+
const root = resolveStateRoot(path, process.cwd())
229+
230+
yield* _(initRepoIfNeeded(fs, path, root, input, env))
231+
yield* _(ensureOriginRemote(root, input.repoUrl, env))
232+
yield* _(adoptRemoteHistoryIfOrphan(root, input.repoRef, env))
233+
yield* _(checkoutBranchBestEffort(root, input.repoRef, env))
234+
yield* _(ensureStateGitignore(fs, path, root))
235+
236+
yield* _(Effect.log(`State dir ready: ${root}`))
237+
yield* _(Effect.log(`Remote: ${input.repoUrl}`))
238+
}).pipe(Effect.asVoid)
239+
240+
const token = input.token?.trim() ?? ""
241+
return token.length > 0 && isGithubHttpsRemote(input.repoUrl)
242+
? withGithubAskpassEnv(token, doInit)
243+
: doInit(gitBaseEnv)
244+
}
232245

233246
export const stateStatus = Effect.gen(function*(_) {
234247
const path = yield* _(Path.Path)

packages/lib/src/usecases/state-repo/adopt-remote.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22
import type { PlatformError } from "@effect/platform/Error"
33
import { Effect } from "effect"
44
import type { CommandFailedError } from "../../shell/errors.js"
5-
import { git, gitBaseEnv, gitExitCode, successExitCode } from "./git-commands.js"
5+
import { git, gitExitCode, successExitCode } from "./git-commands.js"
6+
import type { GitAuthEnv } from "./github-auth.js"
67

78
// CHANGE: align local history with remote when histories have no common ancestor
89
// WHY: prevents creation of new branches when local repo was git-init'd without cloning (divergent root commits)
@@ -14,48 +15,52 @@ import { git, gitBaseEnv, gitExitCode, successExitCode } from "./git-commands.js
1415
// COMPLEXITY: O(1) git operations
1516
export const adoptRemoteHistoryIfOrphan = (
1617
root: string,
17-
repoRef: string
18+
repoRef: string,
19+
env: GitAuthEnv
1820
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
1921
Effect.gen(function*(_) {
2022
// Fetch remote history first — required for merge-base and reset
21-
const fetchExit = yield* _(gitExitCode(root, ["fetch", "origin", repoRef], gitBaseEnv))
23+
const fetchExit = yield* _(gitExitCode(root, ["fetch", "origin", repoRef], env))
2224
if (fetchExit !== successExitCode) {
2325
yield* _(Effect.logWarning(`git fetch origin ${repoRef} failed (exit ${fetchExit}); starting fresh history`))
2426
return
2527
}
2628
const remoteRef = `origin/${repoRef}`
2729
const hasRemoteExit = yield* _(
28-
gitExitCode(root, ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteRef}`], gitBaseEnv)
30+
gitExitCode(root, ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteRef}`], env)
2931
)
3032
if (hasRemoteExit !== successExitCode) {
3133
return // Remote branch does not exist yet (brand-new repo)
3234
}
3335

3436
// Case 1: orphan branch (no local commits at all)
35-
const revParseExit = yield* _(gitExitCode(root, ["rev-parse", "HEAD"], gitBaseEnv))
37+
const revParseExit = yield* _(gitExitCode(root, ["rev-parse", "HEAD"], env))
3638
if (revParseExit !== successExitCode) {
37-
yield* _(git(root, ["reset", "--soft", remoteRef], gitBaseEnv))
39+
// Mixed reset: moves HEAD and updates index to match remote (working tree untouched)
40+
yield* _(git(root, ["reset", remoteRef], env))
41+
// Populate working tree with remote files, skipping files that already exist locally
42+
yield* _(gitExitCode(root, ["checkout-index", "--all"], env))
3843
yield* _(Effect.log(`Adopted remote history from ${remoteRef}`))
3944
return
4045
}
4146

4247
// Case 2: local commits exist but histories share no common ancestor
4348
// (e.g. git-init without cloning produced a divergent root commit)
44-
const mergeBaseExit = yield* _(gitExitCode(root, ["merge-base", "HEAD", remoteRef], gitBaseEnv))
49+
const mergeBaseExit = yield* _(gitExitCode(root, ["merge-base", "HEAD", remoteRef], env))
4550
if (mergeBaseExit === successExitCode) {
46-
return // Histories are related — normal rebase in stateSync will handle it
51+
return // Histories are related — sync will reset --soft onto the remote tip
4752
}
4853

4954
// Merge unrelated histories so both are preserved; abort on conflict — stateSync will open a PR
5055
yield* _(Effect.logWarning(`Local history has no common ancestor with ${remoteRef}; merging unrelated histories`))
5156
const mergeExit = yield* _(
52-
gitExitCode(root, ["merge", "--allow-unrelated-histories", "--no-edit", remoteRef], gitBaseEnv)
57+
gitExitCode(root, ["merge", "--allow-unrelated-histories", "--no-edit", remoteRef], env)
5358
)
5459
if (mergeExit === successExitCode) {
5560
yield* _(Effect.log(`Merged unrelated histories from ${remoteRef}`))
5661
return
5762
}
5863
// Conflict — abort and leave resolution to stateSync (which will push a branch and log a PR URL)
59-
yield* _(gitExitCode(root, ["merge", "--abort"], gitBaseEnv))
64+
yield* _(gitExitCode(root, ["merge", "--abort"], env))
6065
yield* _(Effect.logWarning(`Merge conflict with ${remoteRef}; sync will open a PR for manual resolution`))
6166
})

packages/lib/src/usecases/state-repo/sync-ops.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,17 @@ const sanitizeBranchComponent = (value: string): string =>
5252
.replaceAll("^", "-")
5353
.replaceAll("~", "-")
5454

55-
const rebaseOntoOriginIfPossible = (
55+
// CHANGE: stash local changes → hard reset to remote → restore local changes on top
56+
// WHY: remote is source of truth; local changes must overlay latest remote without losing remote updates
57+
// PURITY: SHELL
58+
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor>
59+
// INVARIANT: after pull, working tree == origin/{baseBranch} ∧ local modifications restored on top
60+
const pullRemoteAndRestoreLocal = (
5661
root: string,
5762
baseBranch: string,
5863
env: GitAuthEnv
59-
): Effect.Effect<"ok" | "skipped" | "conflict", CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
64+
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
6065
Effect.gen(function*(_) {
61-
// Ensure we see the latest remote branch tip before attempting to rebase.
6266
const fetchExit = yield* _(gitExitCode(root, ["fetch", "origin", "--prune"], env))
6367
if (fetchExit !== successExitCode) {
6468
return yield* _(Effect.fail(new CommandFailedError({ command: "git fetch origin --prune", exitCode: fetchExit })))
@@ -67,17 +71,26 @@ const rebaseOntoOriginIfPossible = (
6771
const remoteRef = `refs/remotes/origin/${baseBranch}`
6872
const hasRemoteBranchExit = yield* _(gitExitCode(root, ["show-ref", "--verify", "--quiet", remoteRef], env))
6973
if (hasRemoteBranchExit !== successExitCode) {
70-
return "skipped"
74+
return // Remote branch does not exist yet (brand-new repo)
7175
}
7276

73-
const rebaseExit = yield* _(gitExitCode(root, ["rebase", `origin/${baseBranch}`], env))
74-
if (rebaseExit === successExitCode) {
75-
return "ok"
77+
// Stash local uncommitted changes (including untracked files)
78+
yield* _(git(root, ["add", "-A"], env))
79+
const stashExit = yield* _(gitExitCode(root, ["stash", "--include-untracked"], env))
80+
81+
// Hard reset: working tree + index + HEAD = exact remote state
82+
yield* _(git(root, ["reset", "--hard", `origin/${baseBranch}`], env))
83+
84+
// Restore local changes on top of remote
85+
if (stashExit === successExitCode) {
86+
const popExit = yield* _(gitExitCode(root, ["stash", "pop"], env))
87+
if (popExit !== successExitCode) {
88+
// Resolve conflicts by keeping local (stashed) version — local changes always win
89+
yield* _(gitExitCode(root, ["checkout", "--theirs", "--", "."], env))
90+
yield* _(git(root, ["add", "-A"], env))
91+
yield* _(gitExitCode(root, ["stash", "drop"], env))
92+
}
7693
}
77-
78-
// Best-effort: avoid leaving the repo in a rebase-in-progress state.
79-
yield* _(gitExitCode(root, ["rebase", "--abort"], env))
80-
return "conflict"
8194
})
8295

8396
const pushToNewBranch = (
@@ -116,20 +129,14 @@ export const runStateSyncOps = (
116129
const originPushUrlOverride = options?.originPushUrlOverride ?? null
117130
const originPushTarget = resolveOriginPushTarget(originPushUrlOverride)
118131
yield* _(normalizeLegacyStateProjects(root))
119-
yield* _(commitAllIfNeeded(root, resolveSyncMessage(message), env))
120132

121133
const branch = yield* _(getCurrentBranch(root, env))
122134
const baseBranch = resolveBaseBranch(branch)
123135

124-
const rebaseResult = yield* _(rebaseOntoOriginIfPossible(root, baseBranch, env))
125-
if (rebaseResult === "conflict") {
126-
const prBranch = yield* _(pushToNewBranch(root, baseBranch, originPushTarget, env))
127-
const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch)
128-
129-
yield* _(Effect.logWarning(`State sync needs manual merge: pushed changes to branch '${prBranch}'.`))
130-
yield* _(logOpenPr(originUrl, baseBranch, prBranch, compareUrl))
131-
return
132-
}
136+
// First: pull latest remote state, stashing and restoring local changes
137+
yield* _(pullRemoteAndRestoreLocal(root, baseBranch, env))
138+
// Then: commit local changes on top of remote
139+
yield* _(commitAllIfNeeded(root, resolveSyncMessage(message), env))
133140

134141
const pushExit = yield* _(
135142
gitExitCode(root, ["push", "--no-verify", originPushTarget, `HEAD:refs/heads/${baseBranch}`], env)

0 commit comments

Comments
 (0)