@lisang233/pi-sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { loadConfig } from "./config.js";
7
+ import { runConfigEditor } from "./config-ui.js";
8
+ import * as operations from "./operations.js";
9
+ import { runSetupWizard } from "./wizard.js";
10
+
11
+ const STATUS_KEY = "sync";
12
+
13
+ interface BackgroundSync {
14
+ settled: Promise<void>;
15
+ }
16
+
17
+ const COMMANDS = ["init", "status", "push", "pull", "fetch", "merge", "config", "help"] as const;
18
+
19
+ type Subcommand = (typeof COMMANDS)[number];
20
+
21
+ const USAGE = [
22
+ "pi-sync — sync Pi configuration through Git",
23
+ "",
24
+ "usage: /sync <command> [options]",
25
+ "",
26
+ "commands:",
27
+ " init first-run setup wizard",
28
+ " config view and edit the config",
29
+ " status config + sync state + next step (--diff for content)",
30
+ " fetch fetch the remote snapshot without applying",
31
+ " pull fetch + merge (--force overwrites, --merge resolves)",
32
+ " merge continue an in-progress merge (--abort discards)",
33
+ " push publish local snapshot (--force overwrites remote)",
34
+ " help show this help",
35
+ ].join("\n");
36
+
37
+ export default function sync(pi: ExtensionAPI): void {
38
+ let sessionAbort = new AbortController();
39
+ let backgroundSync: BackgroundSync | undefined;
40
+
41
+ const startBackgroundSync = (ctx: ExtensionContext, signal: AbortSignal) => {
42
+ const settled = (async () => {
43
+ try {
44
+ await runAutomaticSync(ctx, signal);
45
+ } catch (error) {
46
+ if (signal.aborted) return;
47
+ ctx.ui.setStatus(STATUS_KEY, undefined);
48
+ ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
49
+ }
50
+ })();
51
+ backgroundSync = { settled };
52
+ };
53
+
54
+ const drainBackgroundSync = async (signal?: AbortSignal): Promise<void> => {
55
+ const current = backgroundSync;
56
+ backgroundSync = undefined;
57
+ if (!current) return;
58
+ try {
59
+ await (signal ? Promise.race([current.settled, waitForAbort(signal)]) : current.settled);
60
+ } catch {
61
+ // The shutdown deadline or a replacement aborted while draining; the
62
+ // background sync observes its own session signal and settles on its own.
63
+ }
64
+ };
65
+
66
+ pi.registerCommand("sync", {
67
+ description: "Sync Pi configuration through Git",
68
+ getArgumentCompletions: (prefix) => {
69
+ const [first = "", ...rest] = prefix.trim().split(/\s+/u);
70
+ if (rest.length > 0) return null;
71
+ return COMMANDS.filter((name) => name.startsWith(first)).map((name) => ({
72
+ value: name,
73
+ label: name,
74
+ }));
75
+ },
76
+ handler: async (args, ctx) => {
77
+ if (!ctx.hasUI) {
78
+ throw new Error(
79
+ "/sync requires TUI or RPC mode so results and safety prompts are observable.",
80
+ );
81
+ }
82
+ try {
83
+ await handleCommand(args, ctx);
84
+ } catch (error) {
85
+ if (sessionAbort.signal.aborted) return;
86
+ ctx.ui.setStatus(STATUS_KEY, undefined);
87
+ ctx.ui.notify(errorMessage(error), "error");
88
+ }
89
+ },
90
+ });
91
+
92
+ pi.on("session_start", async (_event, ctx) => {
93
+ sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
94
+ sessionAbort = new AbortController();
95
+ const signal = sessionAbort.signal;
96
+ ctx.ui.setStatus(STATUS_KEY, undefined);
97
+ await drainBackgroundSync();
98
+ try {
99
+ const config = await loadConfig();
100
+ if (signal.aborted) return;
101
+ if (config.remote.length === 0 || !config.automatic) return; // not configured or manual-only
102
+ startBackgroundSync(ctx, signal);
103
+ } catch (error) {
104
+ if (signal.aborted) return;
105
+ ctx.ui.notify(`pi-sync startup failed: ${errorMessage(error)}`, "warning");
106
+ }
107
+ });
108
+
109
+ pi.on("session_shutdown", async () => {
110
+ // automatic only observes; aborting the in-flight fetch is enough. No
111
+ // shutdown push — nothing writes local files without an explicit command.
112
+ sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
113
+ });
114
+ }
115
+
116
+ async function runAutomaticSync(ctx: ExtensionContext, signal: AbortSignal): Promise<void> {
117
+ const config = await loadConfig();
118
+ throwIfAborted(signal);
119
+ if (config.remote.length === 0 || !config.automatic) return;
120
+ // Non-destructive: fetch the remote snapshot and refresh the status-bar
121
+ // indicator. Never pushes, pulls or merges on its own.
122
+ await operations.fetch(ctx, config, { quiet: true });
123
+ }
124
+
125
+ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext): Promise<void> {
126
+ const [first = "", ...restTokens] = rawArgs.trim().split(/\s+/u);
127
+ const subcommand = normalizeSubcommand(first);
128
+ if (subcommand === undefined || subcommand === "help") {
129
+ ctx.ui.notify(USAGE, "info");
130
+ return;
131
+ }
132
+
133
+ const config = await loadConfig();
134
+ if (subcommand !== "init" && config.remote.length === 0) {
135
+ ctx.ui.notify(
136
+ "pi-sync is not configured. Run /sync init to set up the git remote, or edit pi-sync.json.",
137
+ "warning",
138
+ );
139
+ return;
140
+ }
141
+
142
+ const force = restTokens.some((token) => token === "--force");
143
+
144
+ switch (subcommand) {
145
+ case "init":
146
+ await runSetupWizard(ctx.ui);
147
+ return;
148
+ case "status":
149
+ await operations.status(ctx, config, {
150
+ diff: restTokens.some((token) => token === "--diff"),
151
+ });
152
+ return;
153
+ case "push":
154
+ await operations.push(ctx, config, { force });
155
+ return;
156
+ case "pull":
157
+ await operations.pull(ctx, config, {
158
+ force,
159
+ merge: restTokens.some((token) => token === "--merge"),
160
+ });
161
+ return;
162
+ case "fetch":
163
+ await operations.fetch(ctx, config);
164
+ return;
165
+ case "merge":
166
+ await operations.merge(ctx, config, {
167
+ abort: restTokens.some((token) => token === "--abort"),
168
+ });
169
+ return;
170
+ case "config":
171
+ await runConfigEditor(ctx.ui, config);
172
+ return;
173
+ }
174
+ }
175
+
176
+ function normalizeSubcommand(value: string): Subcommand | undefined {
177
+ if (value === "") return undefined;
178
+ if (COMMANDS.includes(value as Subcommand)) return value as Subcommand;
179
+ const matches = COMMANDS.filter((name) => name.startsWith(value));
180
+ return matches.length === 1 ? matches[0] : undefined;
181
+ }
182
+
183
+ function throwIfAborted(signal: AbortSignal): void {
184
+ if (!signal.aborted) return;
185
+ throw signal.reason instanceof Error
186
+ ? signal.reason
187
+ : new DOMException("The operation was aborted", "AbortError");
188
+ }
189
+
190
+ function waitForAbort(signal: AbortSignal): Promise<never> {
191
+ return new Promise((_resolve, reject) => {
192
+ const rejectWithReason = () =>
193
+ reject(
194
+ signal.reason instanceof Error
195
+ ? signal.reason
196
+ : new DOMException("The operation was aborted", "AbortError"),
197
+ );
198
+ if (signal.aborted) {
199
+ rejectWithReason();
200
+ return;
201
+ }
202
+ signal.addEventListener("abort", rejectWithReason, { once: true });
203
+ });
204
+ }
205
+
206
+ function errorMessage(error: unknown): string {
207
+ return error instanceof Error ? error.message : String(error);
208
+ }
package/src/git.ts ADDED
@@ -0,0 +1,317 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { SyncConfig } from "./config.js";
5
+ import { mirrorRepoDir, snapshotFilePath, stateDir } from "./config.js";
6
+ import type { Snapshot } from "./snapshot.js";
7
+
8
+ const GIT_TIMEOUT_MS = 60_000;
9
+ const COMMIT_IDENTITY = { name: "pi-sync", email: "pi-sync@local" };
10
+
11
+ export class GitCommandError extends Error {
12
+ readonly exitCode: number | null;
13
+ readonly stderr: string;
14
+
15
+ constructor(message: string, exitCode: number | null, stderr: string) {
16
+ super(message);
17
+ this.name = "GitCommandError";
18
+ this.exitCode = exitCode;
19
+ this.stderr = stderr;
20
+ }
21
+ }
22
+
23
+ export interface GitRunOptions {
24
+ signal?: AbortSignal;
25
+ cwd?: string;
26
+ input?: Buffer | string;
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ export interface GitRunResult {
31
+ stdout: string;
32
+ stderr: string;
33
+ }
34
+
35
+ /** Run one git command with a bounded timeout and prompt-free environment. */
36
+ export async function runGit(args: string[], options: GitRunOptions = {}): Promise<GitRunResult> {
37
+ throwIfAborted(options.signal);
38
+ const env: NodeJS.ProcessEnv = {
39
+ ...process.env,
40
+ GIT_TERMINAL_PROMPT: "0",
41
+ GCM_INTERACTIVE: "Never",
42
+ GIT_ASKPASS: "",
43
+ SSH_ASKPASS: "",
44
+ SSH_ASKPASS_REQUIRE: "never",
45
+ GIT_SSH_COMMAND: "ssh -oBatchMode=yes",
46
+ GIT_AUTHOR_NAME: COMMIT_IDENTITY.name,
47
+ GIT_AUTHOR_EMAIL: COMMIT_IDENTITY.email,
48
+ GIT_COMMITTER_NAME: COMMIT_IDENTITY.name,
49
+ GIT_COMMITTER_EMAIL: COMMIT_IDENTITY.email,
50
+ LC_ALL: "C",
51
+ LANG: "C",
52
+ };
53
+ const child = spawn("git", args, {
54
+ cwd: options.cwd,
55
+ env,
56
+ stdio: ["pipe", "pipe", "pipe"],
57
+ windowsHide: true,
58
+ });
59
+ const stdout: Buffer[] = [];
60
+ const stderr: Buffer[] = [];
61
+ let settled = false;
62
+ let terminationError: Error | undefined;
63
+
64
+ const terminate = (error: Error) => {
65
+ if (settled || terminationError) return;
66
+ terminationError = error;
67
+ child.kill("SIGTERM");
68
+ setTimeout(() => {
69
+ if (!settled) child.kill("SIGKILL");
70
+ }, 2_000);
71
+ };
72
+ child.stdout.on("data", (chunk: Buffer) => stdout.push(Buffer.from(chunk)));
73
+ child.stderr.on("data", (chunk: Buffer) => stderr.push(Buffer.from(chunk)));
74
+ child.stdin.on("error", () => undefined);
75
+ if (options.input !== undefined) child.stdin.end(options.input);
76
+ else child.stdin.end();
77
+ const onAbort = () => terminate(signalReason(options.signal));
78
+ options.signal?.addEventListener("abort", onAbort, { once: true });
79
+ const timer = setTimeout(
80
+ () =>
81
+ terminate(new Error(`Git command timed out after ${options.timeoutMs ?? GIT_TIMEOUT_MS}ms.`)),
82
+ options.timeoutMs ?? GIT_TIMEOUT_MS,
83
+ );
84
+
85
+ try {
86
+ const result = await new Promise<GitRunResult>((resolve, reject) => {
87
+ child.once("error", reject);
88
+ child.once("close", (code) => {
89
+ settled = true;
90
+ const stdoutText = Buffer.concat(stdout).toString("utf8");
91
+ const stderrText = Buffer.concat(stderr).toString("utf8");
92
+ if (terminationError) {
93
+ reject(terminationError);
94
+ return;
95
+ }
96
+ if (code !== 0) {
97
+ reject(
98
+ new GitCommandError(
99
+ stderrText.trim() || `Git exited with status ${code ?? "unknown"}.`,
100
+ code,
101
+ stderrText,
102
+ ),
103
+ );
104
+ return;
105
+ }
106
+ resolve({ stdout: stdoutText, stderr: stderrText });
107
+ });
108
+ });
109
+ return result;
110
+ } finally {
111
+ clearTimeout(timer);
112
+ options.signal?.removeEventListener("abort", onAbort);
113
+ }
114
+ }
115
+
116
+ /** Ensure the local mirror repository exists, is initialized, and knows the remote. */
117
+ export async function ensureMirror(config: SyncConfig): Promise<void> {
118
+ await fs.mkdir(stateDir(), { recursive: true });
119
+ const repo = mirrorRepoDir();
120
+ if (!(await pathExists(repo))) {
121
+ await fs.mkdir(repo, { recursive: true });
122
+ await runGit(["init", "-b", "main"], { cwd: repo });
123
+ await runGit(["remote", "add", "origin", config.remote], { cwd: repo });
124
+ }
125
+ await runGit(["remote", "set-url", "origin", config.remote], { cwd: repo });
126
+ }
127
+
128
+ /** Fetch the configured branch from the remote into origin/<branch>. */
129
+ export async function fetchRemote(config: SyncConfig, options: GitRunOptions = {}): Promise<void> {
130
+ await ensureMirror(config);
131
+ try {
132
+ await runGit(["fetch", "--quiet", "origin", config.branch], {
133
+ cwd: mirrorRepoDir(),
134
+ signal: options.signal,
135
+ timeoutMs: options.timeoutMs,
136
+ });
137
+ } catch (error) {
138
+ // A fresh remote without the branch yet is a normal first-run state.
139
+ if (error instanceof GitCommandError && error.stderr.includes("couldn't find remote ref")) {
140
+ return;
141
+ }
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ /** Read the remote snapshot for the branch; returns undefined when the branch has no snapshot. */
147
+ export async function readRemoteSnapshot(
148
+ config: SyncConfig,
149
+ options: GitRunOptions = {},
150
+ ): Promise<Snapshot | undefined> {
151
+ const repo = mirrorRepoDir();
152
+ if (!(await pathExists(repo))) return undefined;
153
+ const ref = `refs/remotes/origin/${config.branch}`;
154
+ try {
155
+ const result = await runGit(["show", `${ref}:pi-sync/snapshot.json`], {
156
+ cwd: repo,
157
+ signal: options.signal,
158
+ timeoutMs: options.timeoutMs,
159
+ });
160
+ return JSON.parse(result.stdout) as Snapshot;
161
+ } catch (error) {
162
+ if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return undefined;
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ /** Read the snapshot stored at a specific revision; undefined when absent. */
168
+ export async function readSnapshotAt(
169
+ revision: string,
170
+ options: GitRunOptions = {},
171
+ ): Promise<Snapshot | undefined> {
172
+ try {
173
+ const result = await runGit(["show", `${revision}:pi-sync/snapshot.json`], {
174
+ cwd: mirrorRepoDir(),
175
+ signal: options.signal,
176
+ timeoutMs: options.timeoutMs,
177
+ });
178
+ return JSON.parse(result.stdout) as Snapshot;
179
+ } catch (error) {
180
+ if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return undefined;
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ /** Remote revision (commit sha) for the branch, or undefined when the branch is absent. */
186
+ export async function readRemoteRevision(
187
+ config: SyncConfig,
188
+ options: GitRunOptions = {},
189
+ ): Promise<string | undefined> {
190
+ if (!(await pathExists(mirrorRepoDir()))) return undefined;
191
+ const ref = `refs/remotes/origin/${config.branch}`;
192
+ try {
193
+ const result = await runGit(["rev-parse", ref], {
194
+ cwd: mirrorRepoDir(),
195
+ signal: options.signal,
196
+ timeoutMs: options.timeoutMs,
197
+ });
198
+ return result.stdout.trim();
199
+ } catch (error) {
200
+ if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return undefined;
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ /** Publish a snapshot to the remote branch as one commit. */
206
+ export async function publishSnapshot(
207
+ config: SyncConfig,
208
+ snapshot: Snapshot,
209
+ options: GitRunOptions = {},
210
+ force = false,
211
+ ): Promise<string> {
212
+ const repo = mirrorRepoDir();
213
+ // The mirror is disposable (it only tracks pi-sync/snapshot.json). Advance
214
+ // it to the fetched remote tip so the publish push fast-forwards; the
215
+ // caller has already decided it is safe to publish. Missing ref = first push.
216
+ try {
217
+ await runGit(["reset", "--hard", `refs/remotes/origin/${config.branch}`], {
218
+ cwd: repo,
219
+ signal: options.signal,
220
+ });
221
+ } catch {
222
+ // Remote branch does not exist yet; publish from the empty HEAD.
223
+ }
224
+ // The mirror tracks only pi-sync/snapshot.json. The reset aligned the
225
+ // index to the remote tip, which may carry legacy or foreign paths;
226
+ // empty the index so the published commit never drags them along.
227
+ await runGit(["read-tree", "--empty"], { cwd: repo, signal: options.signal });
228
+ await fs.mkdir(path.dirname(snapshotFilePath()), { recursive: true });
229
+ await fs.writeFile(snapshotFilePath(), `${JSON.stringify(snapshot, null, "\t")}\n`, {
230
+ mode: 0o600,
231
+ });
232
+ await runGit(["add", "--", snapshotFilePath()], { cwd: repo, signal: options.signal });
233
+ await runGit(["commit", "--quiet", "-m", `pi-sync: ${snapshot.files.length} files`], {
234
+ cwd: repo,
235
+ signal: options.signal,
236
+ });
237
+ await runGit(
238
+ ["push", "--quiet", ...(force ? ["--force"] : []), "origin", `HEAD:${config.branch}`],
239
+ {
240
+ cwd: repo,
241
+ signal: options.signal,
242
+ timeoutMs: options.timeoutMs,
243
+ },
244
+ );
245
+ // The pushed revision is the commit we just created on HEAD; the
246
+ // remote-tracking ref is only refreshed by fetch, so read HEAD directly.
247
+ const result = await runGit(["rev-parse", "HEAD"], {
248
+ cwd: repo,
249
+ signal: options.signal,
250
+ });
251
+ return result.stdout.trim();
252
+ }
253
+
254
+ /** List recent snapshot commits on the remote branch (newest first). */
255
+ export async function listHistory(
256
+ options: GitRunOptions = {},
257
+ ): Promise<Array<{ id: string; date: string; message: string }>> {
258
+ try {
259
+ const result = await runGit(
260
+ ["log", "--format=%H%x00%cI%x00%s", "-n", "20", "--", "pi-sync/snapshot.json"],
261
+ { cwd: mirrorRepoDir(), signal: options.signal, timeoutMs: options.timeoutMs },
262
+ );
263
+ return result.stdout
264
+ .trim()
265
+ .split("\n")
266
+ .filter(Boolean)
267
+ .map((line) => {
268
+ const [id, date, ...messageParts] = line.split("\u0000");
269
+ return { id: id ?? "", date: date ?? "", message: messageParts.join("\u0000") };
270
+ });
271
+ } catch (error) {
272
+ if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return [];
273
+ throw error;
274
+ }
275
+ }
276
+
277
+ export function isRemoteUpToDate(
278
+ localRevision: string | undefined,
279
+ remoteRevision: string | undefined,
280
+ ): boolean {
281
+ return localRevision !== undefined && localRevision === remoteRevision;
282
+ }
283
+
284
+ async function pathExists(filePath: string): Promise<boolean> {
285
+ try {
286
+ await fs.access(filePath);
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ function isMissingRefError(stderr: string): boolean {
294
+ return (
295
+ stderr.includes("does not have any commits yet") ||
296
+ stderr.includes("unknown revision") ||
297
+ stderr.includes("bad revision") ||
298
+ stderr.includes("not a valid object name") ||
299
+ stderr.includes("invalid object name") ||
300
+ stderr.includes("does not exist in") ||
301
+ // git resolves <ref>:<path> by checking the working tree too: when the
302
+ // path is absent from the ref but a same-named file exists on disk it
303
+ // reports "exists on disk, but not in '<ref>'". Both mean the ref has
304
+ // no snapshot file.
305
+ stderr.includes("exists on disk, but not in")
306
+ );
307
+ }
308
+
309
+ function signalReason(signal: AbortSignal | undefined): Error {
310
+ if (signal?.reason instanceof Error) return signal.reason;
311
+ return new DOMException("The operation was aborted", "AbortError");
312
+ }
313
+
314
+ function throwIfAborted(signal: AbortSignal | undefined): void {
315
+ if (!signal?.aborted) return;
316
+ throw signalReason(signal);
317
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import sync from "./extension.js";
2
+
3
+ export default sync;
@@ -0,0 +1,145 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stateDir } from "./config.js";
4
+
5
+ const SESSION_DIR = "merge-session";
6
+ const SESSION_FILE = "session.json";
7
+
8
+ export type BlockChoice = "local" | "remote" | "custom";
9
+
10
+ /** One divergent block from a merged file; resolution is set when the user resolves it. */
11
+ export interface ConflictBlock {
12
+ local: string;
13
+ base: string;
14
+ remote: string;
15
+ resolution: string | undefined;
16
+ choice: BlockChoice | undefined;
17
+ }
18
+
19
+ export interface MergeFileState {
20
+ path: string;
21
+ /** The full merged text with diff3 markers (resume needs no disk state). */
22
+ merged: string;
23
+ blocks: ConflictBlock[];
24
+ }
25
+
26
+ /**
27
+ * The persistent state of one conflict-resolution session. Lives in its own
28
+ * file (block contents can be large) so state.json stays small and backward
29
+ * compatible; only the presence of this file marks an incomplete merge.
30
+ */
31
+ export interface MergeSessionData {
32
+ baselineRevision: string;
33
+ backupDir: string;
34
+ createdAt: string;
35
+ /** Files taken from the remote side when the session started. */
36
+ takeRemote: number;
37
+ /** Files kept from the local side when the session started. */
38
+ takeLocal: number;
39
+ files: MergeFileState[];
40
+ }
41
+
42
+ export function mergeSessionDir(): string {
43
+ return path.join(stateDir(), SESSION_DIR);
44
+ }
45
+
46
+ export function mergeSessionFilePath(): string {
47
+ return path.join(mergeSessionDir(), SESSION_FILE);
48
+ }
49
+
50
+ export async function loadMergeSession(): Promise<MergeSessionData | undefined> {
51
+ let text: string;
52
+ try {
53
+ text = await fs.readFile(mergeSessionFilePath(), "utf8");
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
56
+ throw error;
57
+ }
58
+ try {
59
+ return parseMergeSession(JSON.parse(text));
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ export async function saveMergeSession(session: MergeSessionData): Promise<void> {
66
+ await fs.mkdir(mergeSessionDir(), { recursive: true });
67
+ const serialized = `${JSON.stringify(session, null, "\t")}\n`;
68
+ await fs.writeFile(mergeSessionFilePath(), serialized, { mode: 0o600 });
69
+ }
70
+
71
+ export async function clearMergeSession(): Promise<void> {
72
+ await fs.rm(mergeSessionDir(), { recursive: true, force: true });
73
+ }
74
+
75
+ /** True when a merge session file exists and parses. */
76
+ export async function hasMergeSession(): Promise<boolean> {
77
+ return (await loadMergeSession()) !== undefined;
78
+ }
79
+
80
+ function parseMergeSession(value: unknown): MergeSessionData | undefined {
81
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
82
+ const record = value as Record<string, unknown>;
83
+ if (
84
+ typeof record.baselineRevision !== "string" ||
85
+ typeof record.backupDir !== "string" ||
86
+ typeof record.createdAt !== "string" ||
87
+ typeof record.takeRemote !== "number" ||
88
+ typeof record.takeLocal !== "number" ||
89
+ !Array.isArray(record.files)
90
+ ) {
91
+ return undefined;
92
+ }
93
+ const files: MergeFileState[] = [];
94
+ for (const file of record.files) {
95
+ const parsed = parseMergeFile(file);
96
+ if (!parsed) return undefined;
97
+ files.push(parsed);
98
+ }
99
+ return {
100
+ baselineRevision: record.baselineRevision,
101
+ backupDir: record.backupDir,
102
+ createdAt: record.createdAt,
103
+ takeRemote: record.takeRemote,
104
+ takeLocal: record.takeLocal,
105
+ files,
106
+ };
107
+ }
108
+
109
+ function parseMergeFile(value: unknown): MergeFileState | undefined {
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
111
+ const record = value as Record<string, unknown>;
112
+ if (
113
+ typeof record.path !== "string" ||
114
+ typeof record.merged !== "string" ||
115
+ !Array.isArray(record.blocks)
116
+ ) {
117
+ return undefined;
118
+ }
119
+ const blocks: ConflictBlock[] = [];
120
+ for (const block of record.blocks) {
121
+ const parsed = parseBlock(block);
122
+ if (!parsed) return undefined;
123
+ blocks.push(parsed);
124
+ }
125
+ return { path: record.path, merged: record.merged, blocks };
126
+ }
127
+
128
+ function parseBlock(value: unknown): ConflictBlock | undefined {
129
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
130
+ const record = value as Record<string, unknown>;
131
+ if (
132
+ typeof record.local !== "string" ||
133
+ typeof record.base !== "string" ||
134
+ typeof record.remote !== "string"
135
+ ) {
136
+ return undefined;
137
+ }
138
+ const resolution = record.resolution ?? undefined;
139
+ if (resolution !== undefined && typeof resolution !== "string") return undefined;
140
+ const choice = record.choice ?? undefined;
141
+ if (choice !== undefined && choice !== "local" && choice !== "remote" && choice !== "custom") {
142
+ return undefined;
143
+ }
144
+ return { local: record.local, base: record.base, remote: record.remote, resolution, choice };
145
+ }