@lisang233/pi-sync 0.1.2 → 0.2.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.
package/src/merge.ts DELETED
@@ -1,203 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { runGit } from "./git.js";
5
- import { fileHashMap, type Snapshot, snapshotFileContent } from "./snapshot.js";
6
-
7
- export interface MergeOutcome {
8
- /** Files kept from the local side because only local changed. */
9
- takeLocal: string[];
10
- /** Files taken from the remote side because only remote changed. */
11
- takeRemote: string[];
12
- /** Files left with conflict markers for the user to resolve. */
13
- conflicts: string[];
14
- /** Files identical on both sides. */
15
- unchanged: string[];
16
- }
17
-
18
- export interface FileMerge {
19
- path: string;
20
- merged: string;
21
- conflicted: boolean;
22
- }
23
-
24
- /**
25
- * Three-way merge of local and remote snapshots against the base snapshot.
26
- * Per-file rule: unchanged files pass through, only-one-side-changed files are
27
- * taken from that side, and truly divergent files need a line-level merge.
28
- */
29
- export function planMerge(
30
- local: Snapshot,
31
- remote: Snapshot,
32
- base: Snapshot | undefined,
33
- ): MergeOutcome {
34
- const localMap = fileHashMap(local);
35
- const remoteMap = fileHashMap(remote);
36
- const baseMap = fileHashMap(base ?? emptySnapshot());
37
- const paths = [...new Set([...localMap.keys(), ...remoteMap.keys()])].sort();
38
- const outcome: MergeOutcome = { takeLocal: [], takeRemote: [], conflicts: [], unchanged: [] };
39
-
40
- for (const filePath of paths) {
41
- const localHash = localMap.get(filePath);
42
- const remoteHash = remoteMap.get(filePath);
43
- if (localHash === remoteHash) {
44
- outcome.unchanged.push(filePath);
45
- continue;
46
- }
47
- const baseHash = baseMap.get(filePath);
48
- const localChanged = localHash !== baseHash;
49
- const remoteChanged = remoteHash !== baseHash;
50
- if (!remoteChanged) {
51
- outcome.takeLocal.push(filePath);
52
- continue;
53
- }
54
- if (!localChanged) {
55
- outcome.takeRemote.push(filePath);
56
- continue;
57
- }
58
- outcome.conflicts.push(filePath);
59
- }
60
- return outcome;
61
- }
62
-
63
- /**
64
- * Line-level three-way merge of two file texts against a base using git
65
- * merge-file. JSON files merge field-wise first (so single-line settings
66
- * don't conflict over formatting); divergent values fall back to git
67
- * merge-file conflict markers.
68
- */
69
- export async function mergeTexts(
70
- base: string,
71
- local: string,
72
- remote: string,
73
- signal?: AbortSignal,
74
- ): Promise<{ merged: string; conflicted: boolean }> {
75
- const jsonMerged = tryJsonMerge(base, local, remote);
76
- if (jsonMerged !== undefined) {
77
- return { merged: jsonMerged, conflicted: false };
78
- }
79
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), "pi-sync-merge-"));
80
- try {
81
- const basePath = path.join(directory, "base");
82
- const localPath = path.join(directory, "local");
83
- const remotePath = path.join(directory, "remote");
84
- await fs.writeFile(basePath, base);
85
- await fs.writeFile(localPath, local);
86
- await fs.writeFile(remotePath, remote);
87
- try {
88
- // merge-file writes the result into the local file; it exits 1 when
89
- // conflicts remain and the merged content with markers is still written.
90
- await runGit(["merge-file", "--diff3", localPath, basePath, remotePath], { signal });
91
- } catch (error) {
92
- if (!isMergeConflictExit(error)) throw error;
93
- }
94
- const merged = await fs.readFile(localPath, "utf8");
95
- return { merged, conflicted: hasConflictMarkers(merged) };
96
- } finally {
97
- await fs.rm(directory, { recursive: true, force: true });
98
- }
99
- }
100
-
101
- /**
102
- * Field-level three-way merge for JSON documents. Returns undefined when the
103
- * documents are not all valid JSON or a value diverges on all three sides.
104
- */
105
- function tryJsonMerge(base: string, local: string, remote: string): string | undefined {
106
- let baseValue: unknown;
107
- let localValue: unknown;
108
- let remoteValue: unknown;
109
- try {
110
- baseValue = JSON.parse(base);
111
- localValue = JSON.parse(local);
112
- remoteValue = JSON.parse(remote);
113
- } catch {
114
- return undefined;
115
- }
116
- const merged = mergeJsonValue(baseValue, localValue, remoteValue);
117
- if (!merged.ok) return undefined;
118
- return `${JSON.stringify(merged.value, null, 2)}\n`;
119
- }
120
-
121
- function mergeJsonValue(
122
- base: unknown,
123
- local: unknown,
124
- remote: unknown,
125
- ): { ok: true; value: unknown } | { ok: false } {
126
- if (local === remote) return { ok: true, value: local };
127
- if (base === local) return { ok: true, value: remote };
128
- if (base === remote) return { ok: true, value: local };
129
- if (isPlainObject(local) && isPlainObject(remote) && isPlainObject(base)) {
130
- const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]);
131
- const result: Record<string, unknown> = {};
132
- for (const key of keys) {
133
- const merged = mergeJsonValue(
134
- (base as Record<string, unknown>)[key],
135
- (local as Record<string, unknown>)[key],
136
- (remote as Record<string, unknown>)[key],
137
- );
138
- if (!merged.ok) return { ok: false };
139
- if (merged.value !== undefined) result[key] = merged.value;
140
- }
141
- return { ok: true, value: result };
142
- }
143
- return { ok: false };
144
- }
145
-
146
- function isPlainObject(value: unknown): value is Record<string, unknown> {
147
- return typeof value === "object" && value !== null && !Array.isArray(value);
148
- }
149
-
150
- export function hasConflictMarkers(text: string): boolean {
151
- return (
152
- text.includes("<<<<<<<") ||
153
- text.includes(">>>>>>>") ||
154
- text.includes("|||||||") ||
155
- text.includes("=======")
156
- );
157
- }
158
-
159
- function isMergeConflictExit(error: unknown): boolean {
160
- return (
161
- error instanceof Error &&
162
- error.name === "GitCommandError" &&
163
- (error as { exitCode?: number | null }).exitCode === 1
164
- );
165
- }
166
-
167
- /**
168
- * Build the merged snapshot after resolving a merge plan: unchanged files pass
169
- * through, takeRemote files adopt the remote content, and conflict files use
170
- * the caller-provided merged text (falling back to local content).
171
- */
172
- export function mergeSnapshot(
173
- local: Snapshot,
174
- remote: Snapshot,
175
- outcome: MergeOutcome,
176
- conflictContents: Map<string, string> = new Map(),
177
- ): Snapshot {
178
- const adoptRemote = new Set(outcome.takeRemote);
179
- const files: Snapshot["files"] = [];
180
- for (const file of local.files) {
181
- if (adoptRemote.has(file.path)) continue;
182
- const merged = conflictContents.get(file.path);
183
- if (merged !== undefined) {
184
- files.push({ ...file, contentBase64: Buffer.from(merged).toString("base64") });
185
- continue;
186
- }
187
- files.push(file);
188
- }
189
- for (const file of remote.files) {
190
- if (adoptRemote.has(file.path)) files.push(file);
191
- }
192
- files.sort((left, right) => left.path.localeCompare(right.path));
193
- return { version: local.version, createdAt: new Date().toISOString(), files };
194
- }
195
-
196
- export function emptySnapshot(): Snapshot {
197
- return { version: 1, createdAt: new Date().toISOString(), files: [] };
198
- }
199
-
200
- /** Read a file text from any snapshot by path. */
201
- export function textFromSnapshot(snapshot: Snapshot, filePath: string): string {
202
- return snapshotFileContent(snapshot, filePath) ?? "";
203
- }
package/src/resolve.ts DELETED
@@ -1,67 +0,0 @@
1
- import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { type MergeSessionData, saveMergeSession } from "./merge-session.js";
3
-
4
- export interface ResolveResult {
5
- /** True when every block now has a resolution. */
6
- completed: boolean;
7
- resolved: number;
8
- }
9
-
10
- const KEEP_LOCAL = "keep local";
11
- const KEEP_REMOTE = "keep remote";
12
- const TYPE_REPLACEMENT = "type replacement";
13
- const ABORT = "abort";
14
-
15
- /**
16
- * Structured conflict resolution: walk the unresolved blocks one at a time and
17
- * let the user keep the local side, keep the remote side, or type a
18
- * replacement. Each block is persisted as it resolves, so an interrupted run
19
- * resumes from where it stopped (/sync merge).
20
- */
21
- export async function runBlockResolver(
22
- ui: ExtensionUIContext,
23
- session: MergeSessionData,
24
- ): Promise<ResolveResult> {
25
- const pending: Array<{ file: MergeSessionData["files"][number]; index: number }> = [];
26
- for (const file of session.files) {
27
- for (let index = 0; index < file.blocks.length; index += 1) {
28
- if (file.blocks[index].resolution === undefined) {
29
- pending.push({ file, index });
30
- }
31
- }
32
- }
33
- if (pending.length === 0) return { completed: true, resolved: 0 };
34
-
35
- let resolved = 0;
36
- for (const { file, index } of pending) {
37
- const block = file.blocks[index];
38
- const choice = await ui.select(`${file.path} — conflict ${index + 1}/${file.blocks.length}`, [
39
- KEEP_LOCAL,
40
- KEEP_REMOTE,
41
- TYPE_REPLACEMENT,
42
- ABORT,
43
- ]);
44
- if (choice === undefined || choice === ABORT) {
45
- await saveMergeSession(session);
46
- return { completed: false, resolved };
47
- }
48
- if (choice === KEEP_LOCAL) {
49
- block.resolution = block.local;
50
- block.choice = "local";
51
- } else if (choice === KEEP_REMOTE) {
52
- block.resolution = block.remote;
53
- block.choice = "remote";
54
- } else {
55
- const custom = await ui.input(`Replacement for ${file.path} block ${index + 1}`);
56
- if (custom === undefined) {
57
- await saveMergeSession(session);
58
- return { completed: false, resolved };
59
- }
60
- block.resolution = custom;
61
- block.choice = "custom";
62
- }
63
- resolved += 1;
64
- await saveMergeSession(session);
65
- }
66
- return { completed: true, resolved };
67
- }
package/src/snapshot.ts DELETED
@@ -1,151 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import type { Dirent } from "node:fs";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- import type { SyncConfig } from "./config.js";
6
- import { agentDir, syncRootPath } from "./paths.js";
7
-
8
- export const SNAPSHOT_VERSION = 1;
9
- export const MAX_SYNC_FILE_BYTES = 16 * 1024 * 1024;
10
- const MAX_SCAN_FILES = 5_000;
11
-
12
- export interface SnapshotFile {
13
- path: string;
14
- sha256: string;
15
- contentBase64: string;
16
- }
17
-
18
- export interface Snapshot {
19
- version: number;
20
- createdAt: string;
21
- files: SnapshotFile[];
22
- }
23
-
24
- export function snapshotSha256(snapshot: Snapshot): string {
25
- return createHash("sha256")
26
- .update(Buffer.from(JSON.stringify(snapshot)))
27
- .digest("hex");
28
- }
29
-
30
- export function fileHashMap(snapshot: Snapshot): Map<string, string> {
31
- return new Map(snapshot.files.map((file) => [file.path, file.sha256]));
32
- }
33
-
34
- /**
35
- * True when a snapshot path falls under an include entry: exact match or a
36
- * directory prefix, case-insensitive. This is the single include-matching
37
- * rule used by projection, write-back targeting and content mapping.
38
- */
39
- export function pathMatchesInclude(relativePath: string, entry: string): boolean {
40
- const lower = entry.toLowerCase();
41
- const pathLower = relativePath.toLowerCase();
42
- return pathLower === lower || pathLower.startsWith(`${lower}/`);
43
- }
44
-
45
- /**
46
- * Project a snapshot onto the include set: keep only paths covered by the
47
- * declaration. The local snapshot is always a projection (`createSnapshot`
48
- * scans include only); remote and historical snapshots are projected before
49
- * merge and reporting so out-of-include leftovers never participate.
50
- */
51
- export function projectSnapshot(snapshot: Snapshot, include: string[]): Snapshot {
52
- const files = snapshot.files.filter((file) =>
53
- include.some((entry) => pathMatchesInclude(file.path, entry)),
54
- );
55
- if (files.length === snapshot.files.length) return snapshot;
56
- return { ...snapshot, files };
57
- }
58
-
59
- /** Build the current snapshot of the configured include paths under the agent dir. */
60
- export async function createSnapshot(config: SyncConfig): Promise<Snapshot> {
61
- const files: SnapshotFile[] = [];
62
- const seen = new Set<string>();
63
- for (const entry of config.include) {
64
- const root = syncRootPath(entry);
65
- await collectFiles(root, agentDir(), files, seen, entry);
66
- }
67
- files.sort((left, right) => left.path.localeCompare(right.path));
68
- return { version: SNAPSHOT_VERSION, createdAt: new Date().toISOString(), files };
69
- }
70
-
71
- async function collectFiles(
72
- root: string,
73
- baseDir: string,
74
- files: SnapshotFile[],
75
- seen: Set<string>,
76
- entry: string,
77
- ): Promise<void> {
78
- let stat: Awaited<ReturnType<typeof fs.lstat>>;
79
- try {
80
- stat = await fs.lstat(root);
81
- } catch (error) {
82
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
83
- throw error;
84
- }
85
- if (stat.isSymbolicLink()) return;
86
- if (stat.isFile()) {
87
- await collectFile(root, baseDir, files, seen, entry);
88
- return;
89
- }
90
- if (!stat.isDirectory()) return;
91
- await collectDirectory(root, baseDir, files, seen, entry);
92
- }
93
-
94
- async function collectDirectory(
95
- directory: string,
96
- baseDir: string,
97
- files: SnapshotFile[],
98
- seen: Set<string>,
99
- entry: string,
100
- ): Promise<void> {
101
- let entries: Dirent[];
102
- try {
103
- entries = await fs.readdir(directory, { withFileTypes: true });
104
- } catch (error) {
105
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
106
- throw error;
107
- }
108
- entries.sort((left, right) => left.name.localeCompare(right.name));
109
- for (const dirent of entries) {
110
- if (files.length >= MAX_SCAN_FILES) return;
111
- if (dirent.isSymbolicLink()) continue;
112
- const child = path.join(directory, dirent.name);
113
- if (dirent.isDirectory()) {
114
- await collectDirectory(child, baseDir, files, seen, entry);
115
- } else if (dirent.isFile()) {
116
- await collectFile(child, baseDir, files, seen, entry);
117
- }
118
- }
119
- }
120
-
121
- async function collectFile(
122
- filePath: string,
123
- baseDir: string,
124
- files: SnapshotFile[],
125
- seen: Set<string>,
126
- _entry: string,
127
- ): Promise<void> {
128
- const stat = await fs.stat(filePath);
129
- if (stat.size > MAX_SYNC_FILE_BYTES) return;
130
- const content = await fs.readFile(filePath);
131
- if (content.includes(0)) return;
132
- const relative = path.relative(baseDir, filePath).split(path.sep).join("/");
133
- if (!relative || relative.startsWith("../") || seen.has(relative)) return;
134
- seen.add(relative);
135
- files.push({
136
- path: relative,
137
- sha256: sha256Buffer(content),
138
- contentBase64: content.toString("base64"),
139
- });
140
- }
141
-
142
- function sha256Buffer(content: Buffer): string {
143
- return createHash("sha256").update(content).digest("hex");
144
- }
145
-
146
- /** Decode snapshot file content; returns undefined for unknown paths. */
147
- export function snapshotFileContent(snapshot: Snapshot, filePath: string): string | undefined {
148
- const file = snapshot.files.find((candidate) => candidate.path === filePath);
149
- if (!file) return undefined;
150
- return Buffer.from(file.contentBase64, "base64").toString("utf8");
151
- }
package/src/state.ts DELETED
@@ -1,60 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { stateDir } from "./config.js";
4
-
5
- const STATE_FILE_NAME = "state.json";
6
- const STATE_VERSION = 1;
7
-
8
- export interface SyncState {
9
- version: number;
10
- lastAppliedSnapshot: string;
11
- lastRemoteRevision: string | undefined;
12
- lastHashes: Record<string, string>;
13
- }
14
-
15
- export function stateFilePath(): string {
16
- return path.join(stateDir(), STATE_FILE_NAME);
17
- }
18
-
19
- export async function loadState(): Promise<SyncState | undefined> {
20
- let text: string;
21
- try {
22
- text = await fs.readFile(stateFilePath(), "utf8");
23
- } catch (error) {
24
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
25
- throw error;
26
- }
27
- let parsed: unknown;
28
- try {
29
- parsed = JSON.parse(text);
30
- } catch {
31
- return undefined;
32
- }
33
- return parseState(parsed);
34
- }
35
-
36
- export async function saveState(state: SyncState): Promise<void> {
37
- await fs.mkdir(stateDir(), { recursive: true });
38
- const serialized = `${JSON.stringify(state, null, "\t")}\n`;
39
- await fs.writeFile(stateFilePath(), serialized, { mode: 0o600 });
40
- }
41
-
42
- function parseState(value: unknown): SyncState | undefined {
43
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
44
- const record = value as Record<string, unknown>;
45
- if (record.version !== STATE_VERSION || typeof record.lastAppliedSnapshot !== "string") {
46
- return undefined;
47
- }
48
- return {
49
- version: STATE_VERSION,
50
- lastAppliedSnapshot: record.lastAppliedSnapshot,
51
- lastRemoteRevision:
52
- typeof record.lastRemoteRevision === "string" ? record.lastRemoteRevision : undefined,
53
- lastHashes: isHashMap(record.lastHashes) ? record.lastHashes : {},
54
- };
55
- }
56
-
57
- function isHashMap(value: unknown): value is Record<string, string> {
58
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
59
- return Object.values(value).every((item) => typeof item === "string");
60
- }