Skip to content
Open
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
26 changes: 25 additions & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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/<branch>.
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/<name>) ──
// All ff-only. Any authenticated user may sync. Push to repos/<name> is
// not supported — code flows through PRs upstream, never from primary.
Expand Down
103 changes: 103 additions & 0 deletions server/src/loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>.
* 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 —
Expand Down
60 changes: 55 additions & 5 deletions server/test/personal-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ 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
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-"))
Expand All @@ -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 () => {
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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)
21 changes: 21 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading