diff --git a/server/src/index.ts b/server/src/index.ts index 4ed51088..78c1230a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,7 +5,7 @@ import { createBunWebSocket } from "hono/bun" import { existsSync } from "node:fs" import { execFile, execFileSync, spawn } from "node:child_process" import { promisify } from "node:util" -import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, discardUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding, dismissOnboarding, deviceFlowStart, deviceFlowPoll } from "./loops" +import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, discardUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, continueMergePersonal, forcePushPersonalToRemote, resetPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding, dismissOnboarding, deviceFlowStart, deviceFlowPoll } from "./loops" import { getEphemeralHostPort, probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer, ensureSandboxImage, buildPodmanExecArgs, ensureContainer, containerName, V_LOOP_WORKDIR } from "./podman" import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, mcpRequiredEnvs, parseTemplateVars, type OAuthSupport } from "./mcp-oauth" import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults" @@ -1328,6 +1328,30 @@ app.post("/api/personal/push", requireAuth, async (c) => { return c.json({ ok: true, message: r.message }) }) +// Continue a manually resolved personal repo merge, then push the merge commit. +app.post("/api/personal/merge-continue", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await continueMergePersonal(userId) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true, message: r.message }) +}) + +// Destructive recovery: overwrite remote personal repo with local HEAD. +app.post("/api/personal/push-force", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await forcePushPersonalToRemote(userId) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true, message: r.message }) +}) + +// Destructive recovery: reset local personal repo to origin/. +app.post("/api/personal/reset", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await resetPersonalToRemote(userId) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true, message: r.message }) +}) + // ── Workspace repo sync (knowledge / notes / repos/) ── // All ff-only. Any authenticated user may sync. Push to repos/ is // not supported — code flows through PRs upstream, never from primary. diff --git a/server/src/loops.ts b/server/src/loops.ts index fed67e0c..f4f72461 100644 --- a/server/src/loops.ts +++ b/server/src/loops.ts @@ -1583,6 +1583,109 @@ export async function pushPersonalToRemote( return { ok: true, message: c.committed ? "committed and pushed" : "pushed" } } +async function ensurePersonalCommitIdentity(dir: string): Promise<{ ok: true } | { ok: false; error: string }> { + try { + try { await execFileP("git", ["-C", dir, "config", "user.email"]) } + catch { await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"]) } + try { await execFileP("git", ["-C", dir, "config", "user.name"]) } + catch { await execFileP("git", ["-C", dir, "config", "user.name", "loopat"]) } + return { ok: true } + } catch (e: any) { + return { ok: false, error: `git config failed: ${e?.stderr ?? e?.message ?? e}` } + } +} + +/** + * Continue an in-progress personal repo merge after the user resolved files + * manually. Refuses to become a normal commit when no merge is active. + */ +export async function continueMergePersonal( + userId: string, +): Promise<{ ok: true; message: string } | { ok: false; error: string }> { + const dir = personalDir(userId) + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "personal/ is not a git repo" } + } + if (!existsSyncBase(join(dir, ".git/MERGE_HEAD"))) { + return { ok: false, error: "no merge in progress" } + } + + const id = await ensurePersonalCommitIdentity(dir) + if (!id.ok) return id + + try { + await execFileP("git", ["-C", dir, "add", "-A"]) + await execFileP("git", ["-C", dir, "commit", "--no-edit"]) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `commit failed: ${stderr || e?.message || e}` } + } + + let branch = "main" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + if (stdout.trim()) branch = stdout.trim() + } catch {} + + try { + await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { + env: { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) }, + }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `push failed: ${stderr || e?.message || e}` } + } + return { ok: true, message: "merge committed and pushed" } +} + +/** + * Explicit destructive recovery: overwrite the remote personal repo with this + * local HEAD. Uses --force-with-lease so a newly advanced remote is still + * rejected instead of blindly overwritten. + */ +export async function forcePushPersonalToRemote( + userId: string, +): Promise<{ ok: true; message: string } | { ok: false; error: string }> { + const dir = personalDir(userId) + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "personal/ is not a git repo" } + } + + let branch = "main" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + if (stdout.trim()) branch = stdout.trim() + } catch {} + try { + await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]) + } catch { + return { ok: false, error: "no remote configured" } + } + + try { + await execFileP("git", ["-C", dir, "push", "--force-with-lease", "origin", `HEAD:${branch}`], { + env: { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) }, + }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `force push failed: ${stderr || e?.message || e}` } + } + return { ok: true, message: `force-pushed HEAD to origin/${branch}` } +} + +/** + * Explicit destructive recovery: discard local commits, uncommitted changes, + * untracked files, and merge/rebase state by matching origin/. + * Delegates to pullPersonalFromRemote with force:true. + */ +export async function resetPersonalToRemote( + userId: string, +): Promise<{ ok: true; message: string } | { ok: false; error: string }> { + const r = await pullPersonalFromRemote(userId, { force: true }) + if (!r.ok) return { ok: false, error: r.error.replace(/^force pull failed:/, "reset failed:") } + return r +} + /** * UI-loop notes worktree: a per-user checkout of notes, opened from origin/main, * for editing team notes outside any AI loop (the no-AI "UI loop"). Disposable — diff --git a/server/test/personal-sync.test.ts b/server/test/personal-sync.test.ts index 6647b7e4..d4efd98f 100644 --- a/server/test/personal-sync.test.ts +++ b/server/test/personal-sync.test.ts @@ -15,7 +15,7 @@ import { promisify } from "node:util" import { execFile } from "node:child_process" const run = promisify(execFile) -const g = (args: string[], cwd?: string) => run("git", cwd ? ["-C", cwd, ...args] : args) +const g = (args: string[], cwd?: string) => run("git", cwd ? ["-c", "core.hooksPath=/dev/null", "-C", cwd, ...args] : ["-c", "core.hooksPath=/dev/null", ...args]) let home: string let loops: any @@ -23,6 +23,7 @@ const user = "synctest" let pdir: string let origin: string let other: string +const TEST_TIMEOUT_MS = 30_000 beforeAll(async () => { home = await mkdtemp(join(tmpdir(), "loopat-personal-sync-")) @@ -34,11 +35,13 @@ beforeAll(async () => { await g(["init", "--bare", "-b", "main", origin]) await g(["clone", origin, pdir]) + await g(["config", "core.hooksPath", "/dev/null"], pdir) await writeFile(join(pdir, "a.txt"), "a1\n") await g(["add", "-A"], pdir) await g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"], pdir) await g(["push", "origin", "HEAD:main"], pdir) await g(["clone", origin, other]) + await g(["config", "core.hooksPath", "/dev/null"], other) }) afterAll(async () => { @@ -61,7 +64,7 @@ test("clean push fast-forwards", async () => { await writeFile(join(pdir, "b.txt"), "b1\n") const r = await loops.pushPersonalToRemote(user) expect(r.ok).toBe(true) -}) +}, TEST_TIMEOUT_MS) test("remote moved elsewhere → rebase keeps local AND pulls remote", async () => { await remoteEdit("remote.txt", "r1\n", "remote change") @@ -70,7 +73,7 @@ test("remote moved elsewhere → rebase keeps local AND pulls remote", async () expect(r.ok).toBe(true) expect(existsSync(join(pdir, "c.txt"))).toBe(true) // local edit kept expect(existsSync(join(pdir, "remote.txt"))).toBe(true) // remote folded in -}) +}, TEST_TIMEOUT_MS) test("real same-spot conflict is held back; local edit is NOT lost", async () => { await remoteEdit("a.txt", "a-remote\n", "remote edits a") @@ -81,10 +84,57 @@ test("real same-spot conflict is held back; local edit is NOT lost", async () => expect(r.files).toContain("a.txt") // the whole point: the local edit survives the held-back push expect((await readFile(join(pdir, "a.txt"), "utf8")).trim()).toBe("a-local") -}) +}, TEST_TIMEOUT_MS) test("force pull = take remote (discards local edit)", async () => { const r = await loops.pullPersonalFromRemote(user, { force: true }) expect(r.ok).toBe(true) expect((await readFile(join(pdir, "a.txt"), "utf8")).trim()).toBe("a-remote") -}) +}, TEST_TIMEOUT_MS) + +test("continue merge commits resolved conflict and pushes", async () => { + await remoteEdit("a.txt", "a-remote-merge\n", "remote merge edit") + await writeFile(join(pdir, "a.txt"), "a-local-merge\n") + await g(["add", "-A"], pdir) + await g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "local merge edit"], pdir) + await g(["fetch", "origin"], pdir) + await expect(g(["merge", "origin/main"], pdir)).rejects.toThrow() + + await writeFile(join(pdir, "a.txt"), "a-resolved\n") + const r = await loops.continueMergePersonal(user) + expect(r.ok).toBe(true) + + await g(["fetch", "origin"], other) + await g(["reset", "--hard", "origin/main"], other) + expect((await readFile(join(other, "a.txt"), "utf8")).trim()).toBe("a-resolved") +}, TEST_TIMEOUT_MS) + +test("force push overwrites remote after held-back conflict", async () => { + await remoteEdit("a.txt", "a-remote-force\n", "remote force edit") + await writeFile(join(pdir, "a.txt"), "a-local-force\n") + + const held = await loops.pushPersonalToRemote(user) + expect(held.ok).toBe(false) + expect(held.conflict).toBe(true) + + const r = await loops.forcePushPersonalToRemote(user) + expect(r.ok).toBe(true) + + await g(["fetch", "origin"], other) + await g(["reset", "--hard", "origin/main"], other) + expect((await readFile(join(other, "a.txt"), "utf8")).trim()).toBe("a-local-force") +}, TEST_TIMEOUT_MS) + +test("reset personal to remote discards local commits and uncommitted files", async () => { + await remoteEdit("a.txt", "a-remote-reset\n", "remote reset edit") + await writeFile(join(pdir, "local-only.txt"), "local only\n") + await g(["add", "-A"], pdir) + await g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "local only"], pdir) + await writeFile(join(pdir, "scratch.txt"), "scratch\n") + + const r = await loops.resetPersonalToRemote(user) + expect(r.ok).toBe(true) + expect((await readFile(join(pdir, "a.txt"), "utf8")).trim()).toBe("a-remote-reset") + expect(existsSync(join(pdir, "local-only.txt"))).toBe(false) + expect(existsSync(join(pdir, "scratch.txt"))).toBe(false) +}, TEST_TIMEOUT_MS) diff --git a/web/src/api.ts b/web/src/api.ts index e006387e..3277fd59 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -345,6 +345,27 @@ export async function pushPersonalVault(): Promise<{ return { ok: true, message: j.message } } +export async function continueMergePersonal(): Promise<{ ok: boolean; error?: string; message?: string }> { + const r = await apiFetch("/api/personal/merge-continue", { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `continue merge failed (${r.status})` } + return { ok: true, message: j.message } +} + +export async function forcePushPersonalVault(): Promise<{ ok: boolean; error?: string; message?: string }> { + const r = await apiFetch("/api/personal/push-force", { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `force push failed (${r.status})` } + return { ok: true, message: j.message } +} + +export async function resetPersonalVault(): Promise<{ ok: boolean; error?: string; message?: string }> { + const r = await apiFetch("/api/personal/reset", { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `reset failed (${r.status})` } + return { ok: true, message: j.message } +} + export async function importPersonal( repoUrl?: string, cryptKey?: string, diff --git a/web/src/components/dialog/PersonalRepoPanel.tsx b/web/src/components/dialog/PersonalRepoPanel.tsx index e045904f..af62ad34 100644 --- a/web/src/components/dialog/PersonalRepoPanel.tsx +++ b/web/src/components/dialog/PersonalRepoPanel.tsx @@ -1,13 +1,16 @@ import { useEffect, useState } from "react" import { + continueMergePersonal, deletePersonalVault, exportPersonalCryptKey, + forcePushPersonalVault, getPersonalStatus, importPersonal, setupPersonalGithub, listPersonalRepos, pullPersonalVault, pushPersonalVault, + resetPersonalVault, type PersonalStatus, } from "@/api" import { ArrowUp, ArrowDown, AlertTriangle, Check, X } from "lucide-react" @@ -748,8 +751,8 @@ export function PersonalRepoPanel({ onDone, initialToken }: { onDone?: () => voi function ImportedPanel({ status }: { status: PersonalStatus }) { type Action = null | "export" | "delete" | "pull" | "push" | "showkey" const [action, setAction] = useState(null) - const [pullResult, setPullResult] = useState<{ ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsStash?: boolean } | null>(null) - const [pushResult, setPushResult] = useState<{ ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsPull?: boolean } | null>(null) + const [pullResult, setPullResult] = useState<{ ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsStash?: boolean; message?: string } | null>(null) + const [pushResult, setPushResult] = useState<{ ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsPull?: boolean; message?: string } | null>(null) const handlePull = async () => { setAction("pull") @@ -765,6 +768,37 @@ function ImportedPanel({ status }: { status: PersonalStatus }) { setPushResult(r) } + const handleContinueMerge = async () => { + const r = await continueMergePersonal() + const payload = r.ok + ? { ok: true as const, message: r.message } + : { ok: false as const, error: r.error ?? "continue merge failed" } + if (action === "push") setPushResult(payload) + else { + setAction("pull") + setPullResult(payload) + } + } + + const handleForcePush = async () => { + setAction("push") + setPushResult(null) + const r = await forcePushPersonalVault() + setPushResult(r.ok ? { ok: true, message: r.message } : { ok: false, error: r.error }) + } + + const handleResetToRemote = async () => { + const r = await resetPersonalVault() + const payload = r.ok + ? { ok: true as const, message: r.message } + : { ok: false as const, error: r.error ?? "reset failed" } + if (action === "push") setPushResult(payload) + else { + setAction("pull") + setPullResult(payload) + } + } + return (
@@ -788,6 +822,9 @@ function ImportedPanel({ status }: { status: PersonalStatus }) { result={pullResult} onDone={() => setAction(null)} onRetry={handlePull} + onContinueMerge={handleContinueMerge} + onForcePush={handleForcePush} + onResetToRemote={handleResetToRemote} /> ) : action === "push" ? ( setAction(null)} onRetry={handlePush} + onContinueMerge={handleContinueMerge} + onForcePush={handleForcePush} + onResetToRemote={handleResetToRemote} /> ) : action === "showkey" ? ( setAction(null)} /> @@ -848,78 +888,26 @@ function ImportedPanel({ status }: { status: PersonalStatus }) { ) } -function ForcePullButton({ onRetry }: { onRetry: () => void }) { - const [confirming, setConfirming] = useState(false) - const [forcePulling, setForcePulling] = useState(false) - - const handleForcePull = async () => { - setForcePulling(true) - const r = await pullPersonalVault({ force: true }) - setForcePulling(false) - setConfirming(false) - // Replace the parent's result with the force pull result - if (r.ok) { - onRetry() // Let parent refresh — the successful pull result will show - } - } - - if (forcePulling) { - return ( - - ) - } - - if (confirming) { - return ( -
- - -
- ) - } - - return ( -
- - -
- ) -} function PullPushResultFlow({ type, result, onDone, onRetry, + onContinueMerge, + onForcePush, + onResetToRemote, }: { type: "pull" | "push" - result: { ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsStash?: boolean; needsPull?: boolean } | null + result: { ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsStash?: boolean; needsPull?: boolean; message?: string } | null onDone: () => void onRetry: () => void + onContinueMerge?: () => void + onForcePush?: () => void + onResetToRemote?: () => void }) { const [showConflicts, setShowConflicts] = useState(false) + const [armed, setArmed] = useState(null) if (!result) { return ( @@ -1001,11 +989,8 @@ function PullPushResultFlow({
)} -
- {hasConflict ? ( - // "take remote" — force pull discards the local edit and re-aligns to origin - - ) : ( +
+
+ +
+ + {(hasConflict || needsPull || result.needsStash) && ( +
+ {onContinueMerge && hasConflict && ( + + )} + {onForcePush && ( + armed === "forcePush" ? ( + + ) : ( + + ) + )} + {onResetToRemote && ( + armed === "reset" ? ( + + ) : ( + + ) + )} + {armed && ( + + )} +
)} -
) diff --git a/web/src/pages/ContextPage.tsx b/web/src/pages/ContextPage.tsx index 73581a63..8c9d3142 100644 --- a/web/src/pages/ContextPage.tsx +++ b/web/src/pages/ContextPage.tsx @@ -19,6 +19,8 @@ import { refreshNotes, pullPersonalVault, pushPersonalVault, + forcePushPersonalVault, + resetPersonalVault, vaultCreateFile, vaultCreateFolder, vaultDeleteFile, @@ -725,6 +727,13 @@ function DocView({ // Server flags secret files; the response never carries plaintext, so // `original` stays empty and the only way to mutate is to type a new value. const [secretFromServer, setSecretFromServer] = useState(false) + const [personalRecovery, setPersonalRecovery] = useState(null) const isMobile = useIsMobile() const initialEditingRef = useRef(initialEditing) @@ -739,6 +748,7 @@ function DocView({ setLastCommit(null) setFullscreen(false) setSecretFromServer(false) + setPersonalRecovery(null) vaultRead(vault, path).then((r) => { const c = r?.content ?? "" setOriginal(c) @@ -796,10 +806,12 @@ function DocView({ const sync = await pushPersonalVault() if (!sync.ok) { if (sync.conflict) { - alert(`Saved locally, but it conflicts with the remote (${(sync.files ?? []).join(", ")}). Your edit is kept — pull first (re-open Personal) or resolve in a loop.`) + setPersonalRecovery({ files: sync.files ?? [], error: sync.error }) } else { alert(`Saved locally, but push to remote failed: ${sync.error ?? "unknown"}`) } + } else { + setPersonalRecovery(null) } } } else { @@ -810,6 +822,39 @@ function DocView({ } }, [vault, path, draft, dirty, saving, onSaved, isSecret]) + const reloadCurrentFile = useCallback(async () => { + const r = await vaultRead(vault, path) + const c = r?.content ?? "" + setOriginal(c) + setDraft(c) + setSecretFromServer(!!r?.secret) + onSaved() + }, [vault, path, onSaved]) + + const recoverPersonalForcePush = async () => { + if (!personalRecovery || personalRecovery.busy) return + setPersonalRecovery({ ...personalRecovery, busy: "force", armed: undefined }) + const r = await forcePushPersonalVault() + setPersonalRecovery((prev) => { + if (!prev) return prev + return r.ok + ? { files: prev.files, message: r.message ?? "local vault pushed to remote" } + : { ...prev, busy: undefined, error: r.error ?? "force push failed" } + }) + } + + const recoverPersonalReset = async () => { + if (!personalRecovery || personalRecovery.busy) return + setPersonalRecovery({ ...personalRecovery, busy: "reset", armed: undefined }) + const r = await resetPersonalVault() + if (r.ok) { + await reloadCurrentFile() + setPersonalRecovery({ files: [], message: r.message ?? "local vault reset to remote" }) + } else { + setPersonalRecovery((prev) => prev ? { ...prev, busy: undefined, error: r.error ?? "reset failed" } : prev) + } + } + const startEdit = () => { setEditing(true); if (isMd) setMilkdown(true) } const cancelEdit = () => { @@ -916,6 +961,83 @@ function DocView({
+ {vault === "personal" && personalRecovery && ( +
+ {personalRecovery.message ? ( +
+ {personalRecovery.message} + +
+ ) : ( +
+
+ Saved locally, but the remote has conflicting changes. Your edit is kept locally. + {personalRecovery.files.length > 0 && ( + Conflicts: {personalRecovery.files.join(", ")} + )} +
+ {personalRecovery.error &&
{personalRecovery.error}
} +
+ {personalRecovery.armed === "force" ? ( + + ) : ( + + )} + {personalRecovery.armed === "reset" ? ( + + ) : ( + + )} + {personalRecovery.armed && ( + + )} +
+
+ )} +
+ )} + {editing ? ( isSecret ? ( // edit mode for a secret: single full-width editor starting empty;