@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/package.json +48 -0
- package/src/config-ui.ts +138 -0
- package/src/config.ts +111 -0
- package/src/conflict.ts +90 -0
- package/src/diff.ts +154 -0
- package/src/extension.ts +208 -0
- package/src/git.ts +317 -0
- package/src/index.ts +3 -0
- package/src/merge-session.ts +145 -0
- package/src/merge.ts +203 -0
- package/src/operations.ts +532 -0
- package/src/paths.ts +77 -0
- package/src/resolve.ts +67 -0
- package/src/snapshot.ts +126 -0
- package/src/state.ts +60 -0
- package/src/status.ts +70 -0
- package/src/wizard.ts +75 -0
package/src/merge.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
}
|