@lisang233/pi-sync 0.2.1 → 0.2.2
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/README.md +6 -3
- package/package.json +4 -1
- package/src/extension.ts +20 -2
- package/src/git.ts +62 -1
- package/src/operations.ts +158 -14
- package/src/status.ts +1 -1
package/README.md
CHANGED
|
@@ -18,8 +18,10 @@ detection and merging are handled by git itself.
|
|
|
18
18
|
anchor file. A new machine's first `pull` adopts the remote cleanly; a
|
|
19
19
|
rewritten remote doesn't spuriously conflict.
|
|
20
20
|
- **Git-native three-way merge** — `/sync pull` merges the remote branch into
|
|
21
|
-
the local side. Divergent edits
|
|
22
|
-
|
|
21
|
+
the local side. Divergent edits keep local configuration protected while
|
|
22
|
+
holding conflicts in the mirror; `/sync mergetool` or `--ours`/`--theirs`
|
|
23
|
+
resolve them, and `/sync merge` validates (no markers, valid JSON) before
|
|
24
|
+
applying to local files.
|
|
23
25
|
- **Deliberate commands** — nothing moves your files without being asked;
|
|
24
26
|
`automatic` only observes at session start.
|
|
25
27
|
- **Sensible defaults** — one `pi-sync.json` points at a git remote and branch,
|
|
@@ -46,7 +48,8 @@ pi install npm:@lisang233/pi-sync
|
|
|
46
48
|
/sync status # config + sync state + next step (--diff for content)
|
|
47
49
|
/sync fetch # pull the remote tree without applying
|
|
48
50
|
/sync pull # fetch + merge (--force overwrites local)
|
|
49
|
-
/sync
|
|
51
|
+
/sync mergetool # launch git mergetool for conflicts ([tool])
|
|
52
|
+
/sync merge # complete merge (--abort, --ours, --theirs)
|
|
50
53
|
/sync push # publish the local tree (--force overwrites remote)
|
|
51
54
|
```
|
|
52
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lisang233/pi-sync",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Personal Pi extension that syncs Pi configuration through Git with background auto-sync and git-style fetch/merge conflict handling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
19
22
|
"pi": {
|
|
20
23
|
"extensions": [
|
|
21
24
|
"./src/index.ts"
|
package/src/extension.ts
CHANGED
|
@@ -15,7 +15,17 @@ interface BackgroundSync {
|
|
|
15
15
|
settled: Promise<void>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const COMMANDS = [
|
|
18
|
+
const COMMANDS = [
|
|
19
|
+
"init",
|
|
20
|
+
"status",
|
|
21
|
+
"push",
|
|
22
|
+
"pull",
|
|
23
|
+
"fetch",
|
|
24
|
+
"merge",
|
|
25
|
+
"mergetool",
|
|
26
|
+
"config",
|
|
27
|
+
"help",
|
|
28
|
+
] as const;
|
|
19
29
|
|
|
20
30
|
type Subcommand = (typeof COMMANDS)[number];
|
|
21
31
|
|
|
@@ -30,7 +40,8 @@ const USAGE = [
|
|
|
30
40
|
" status config + sync state + next step (--diff for content)",
|
|
31
41
|
" fetch fetch the remote tree without applying",
|
|
32
42
|
" pull fetch + merge (--force overwrites local)",
|
|
33
|
-
" merge
|
|
43
|
+
" merge complete merge (--abort, --ours, --theirs)",
|
|
44
|
+
" mergetool launch git mergetool for conflicts ([tool])",
|
|
34
45
|
" push publish local tree (--force overwrites remote)",
|
|
35
46
|
" help show this help",
|
|
36
47
|
].join("\n");
|
|
@@ -211,8 +222,15 @@ async function handleCommand(
|
|
|
211
222
|
case "merge":
|
|
212
223
|
await operations.merge(ctx, config, {
|
|
213
224
|
abort: restTokens.some((token) => token === "--abort"),
|
|
225
|
+
ours: restTokens.some((token) => token === "--ours"),
|
|
226
|
+
theirs: restTokens.some((token) => token === "--theirs"),
|
|
214
227
|
});
|
|
215
228
|
return;
|
|
229
|
+
case "mergetool": {
|
|
230
|
+
const tool = restTokens.find((token) => !token.startsWith("-"));
|
|
231
|
+
await operations.mergetool(ctx, config, tool);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
216
234
|
case "config":
|
|
217
235
|
await runConfigEditor(ctx.ui, config);
|
|
218
236
|
return;
|
package/src/git.ts
CHANGED
|
@@ -271,6 +271,25 @@ export async function readMergeBase(
|
|
|
271
271
|
return readFilesAt(base, options);
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** Read the content of a file at a specific git ref (e.g. "HEAD:settings.json"). */
|
|
275
|
+
export async function readCommitFile(
|
|
276
|
+
ref: string,
|
|
277
|
+
relativePath: string,
|
|
278
|
+
options: GitRunOptions = {},
|
|
279
|
+
): Promise<string | undefined> {
|
|
280
|
+
const repo = gitCwd();
|
|
281
|
+
try {
|
|
282
|
+
const out = await runGit(["show", `${ref}:${relativePath}`], {
|
|
283
|
+
cwd: repo,
|
|
284
|
+
signal: options.signal,
|
|
285
|
+
timeoutMs: options.timeoutMs,
|
|
286
|
+
});
|
|
287
|
+
return out.stdout;
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
274
293
|
/** Stage all changes (after grafting the local side) in the mirror work tree. */
|
|
275
294
|
export async function stageAll(options: GitRunOptions = {}): Promise<void> {
|
|
276
295
|
await runGit(["add", "-A"], { cwd: gitCwd(), signal: options.signal });
|
|
@@ -346,7 +365,49 @@ export async function completeMerge(
|
|
|
346
365
|
options: GitRunOptions = {},
|
|
347
366
|
): Promise<boolean> {
|
|
348
367
|
await stageAll(options);
|
|
349
|
-
|
|
368
|
+
const repo = gitCwd();
|
|
369
|
+
try {
|
|
370
|
+
await runGit(["commit", "--quiet", "-m", message], { cwd: repo, signal: options.signal });
|
|
371
|
+
return true;
|
|
372
|
+
} catch {
|
|
373
|
+
return !(await isMergeInProgress(options));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Check out our local version for all conflicted files in the mirror work tree. */
|
|
378
|
+
export async function checkoutOurs(options: GitRunOptions = {}): Promise<void> {
|
|
379
|
+
await runGit(["checkout", "--ours", "--", "."], { cwd: gitCwd(), signal: options.signal });
|
|
380
|
+
await stageAll(options);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Check out their remote version for all conflicted files in the mirror work tree. */
|
|
384
|
+
export async function checkoutTheirs(options: GitRunOptions = {}): Promise<void> {
|
|
385
|
+
await runGit(["checkout", "--theirs", "--", "."], { cwd: gitCwd(), signal: options.signal });
|
|
386
|
+
await stageAll(options);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Launch git mergetool interactively in the mirror work tree.
|
|
391
|
+
*/
|
|
392
|
+
export async function launchMergetool(tool?: string, options: GitRunOptions = {}): Promise<void> {
|
|
393
|
+
throwIfAborted(options.signal);
|
|
394
|
+
const repo = gitCwd();
|
|
395
|
+
const args = ["mergetool", "-y"];
|
|
396
|
+
if (tool && tool.trim().length > 0) {
|
|
397
|
+
args.push(`--tool=${tool.trim()}`);
|
|
398
|
+
}
|
|
399
|
+
await new Promise<void>((resolve, reject) => {
|
|
400
|
+
const child = spawn("git", args, {
|
|
401
|
+
cwd: repo,
|
|
402
|
+
stdio: process.stdin.isTTY ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
403
|
+
windowsHide: false,
|
|
404
|
+
});
|
|
405
|
+
child.on("error", reject);
|
|
406
|
+
child.on("close", (code) => {
|
|
407
|
+
if (code === 0) resolve();
|
|
408
|
+
else reject(new Error(`git mergetool exited with status ${code ?? "unknown"}.`));
|
|
409
|
+
});
|
|
410
|
+
});
|
|
350
411
|
}
|
|
351
412
|
|
|
352
413
|
/** Abort an in-progress merge, restoring the work tree to the pre-merge state. */
|
package/src/operations.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import type {
|
|
2
4
|
ExtensionCommandContext,
|
|
3
5
|
ExtensionContext,
|
|
@@ -10,15 +12,19 @@ import { diffSummary, formatDiff } from "./diff.js";
|
|
|
10
12
|
import {
|
|
11
13
|
abortMerge,
|
|
12
14
|
aheadBehind,
|
|
15
|
+
checkoutOurs,
|
|
16
|
+
checkoutTheirs,
|
|
13
17
|
commitSync,
|
|
14
18
|
completeMerge,
|
|
15
19
|
ensureBranch,
|
|
16
20
|
ensureMirror,
|
|
17
21
|
fetchRemote,
|
|
18
22
|
isMergeInProgress,
|
|
23
|
+
launchMergetool,
|
|
19
24
|
listConflictedPaths,
|
|
20
25
|
mergeRemote,
|
|
21
26
|
pushBranch,
|
|
27
|
+
readCommitFile,
|
|
22
28
|
readMergeBase,
|
|
23
29
|
readRemoteFiles,
|
|
24
30
|
readRemoteRevision,
|
|
@@ -30,6 +36,8 @@ import {
|
|
|
30
36
|
collectAgentFiles,
|
|
31
37
|
copyMirrorToAgent,
|
|
32
38
|
graftAgentIntoMirror,
|
|
39
|
+
mirrorProjectedFiles,
|
|
40
|
+
mirrorTarget,
|
|
33
41
|
readAgentContents,
|
|
34
42
|
} from "./tree.js";
|
|
35
43
|
|
|
@@ -116,7 +124,7 @@ function nextStepHint(info: StateClassify): string {
|
|
|
116
124
|
case "behind":
|
|
117
125
|
return "next: /sync pull to fetch and apply remote changes";
|
|
118
126
|
case "conflict":
|
|
119
|
-
return "next: /sync
|
|
127
|
+
return "next: /sync merge (--ours, --theirs, or mergetool), or /sync pull --force to overwrite local";
|
|
120
128
|
case "unknown":
|
|
121
129
|
return "next: /sync fetch to check the remote";
|
|
122
130
|
}
|
|
@@ -209,10 +217,8 @@ export async function pull(
|
|
|
209
217
|
await commitLocalSide({ signal: ctx.signal });
|
|
210
218
|
|
|
211
219
|
const conflicted = await mergeRemote(config, { signal: ctx.signal });
|
|
212
|
-
await copyMirrorToAgent(config);
|
|
213
|
-
await refreshIndicator(ctx, config);
|
|
214
|
-
|
|
215
220
|
if (conflicted) {
|
|
221
|
+
await refreshIndicator(ctx, config);
|
|
216
222
|
const conflicts = await listConflictedPaths({ signal: ctx.signal });
|
|
217
223
|
let remoteRevision = "";
|
|
218
224
|
try {
|
|
@@ -220,13 +226,15 @@ export async function pull(
|
|
|
220
226
|
} catch {
|
|
221
227
|
// ignore
|
|
222
228
|
}
|
|
223
|
-
const
|
|
229
|
+
const conflictList = conflicts.length > 0 ? ` (${conflicts.join(", ")})` : "";
|
|
230
|
+
const message = `Merge conflict in ${conflicts.length} file(s)${conflictList}. Local configuration was preserved. Resolve with /sync merge --ours, /sync merge --theirs, or /sync mergetool. Then run /sync merge. (remote ${shortId(
|
|
224
231
|
remoteRevision,
|
|
225
232
|
)})`;
|
|
226
233
|
ctx.ui.notify(message, "warning");
|
|
227
|
-
return { pushed: false, pulled: false, merged:
|
|
234
|
+
return { pushed: false, pulled: false, merged: false, message, conflicts };
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
await copyMirrorToAgent(config);
|
|
230
238
|
await refreshIndicator(ctx, config);
|
|
231
239
|
const remote = await readRemoteFiles(config, { signal: ctx.signal });
|
|
232
240
|
const message = `Pulled ${remote.size} file(s) from ${config.branch}.`;
|
|
@@ -267,10 +275,78 @@ export async function fetch(
|
|
|
267
275
|
return { pushed: false, pulled: false, merged: false, message };
|
|
268
276
|
}
|
|
269
277
|
|
|
278
|
+
export interface FileValidationError {
|
|
279
|
+
path: string;
|
|
280
|
+
line?: number;
|
|
281
|
+
reason: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Validate that the mirror work tree has resolved all conflicts and produced
|
|
286
|
+
* valid JSON for any .json files before allowing completeMerge to commit.
|
|
287
|
+
*/
|
|
288
|
+
export async function validateResolvedMirror(
|
|
289
|
+
config: SyncConfig,
|
|
290
|
+
signal?: AbortSignal,
|
|
291
|
+
): Promise<FileValidationError[]> {
|
|
292
|
+
const errors: FileValidationError[] = [];
|
|
293
|
+
const unmerged = await listConflictedPaths({ signal });
|
|
294
|
+
for (const file of unmerged) {
|
|
295
|
+
errors.push({ path: file, reason: "unmerged git conflict" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const projected = await mirrorProjectedFiles(config);
|
|
299
|
+
for (const relative of projected) {
|
|
300
|
+
const fullPath = mirrorTarget(relative);
|
|
301
|
+
let content: string;
|
|
302
|
+
try {
|
|
303
|
+
content = await fs.readFile(fullPath, "utf8");
|
|
304
|
+
} catch {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const lines = content.split(/\r?\n/u);
|
|
309
|
+
for (let i = 0; i < lines.length; i++) {
|
|
310
|
+
const line = lines[i];
|
|
311
|
+
if (
|
|
312
|
+
line.startsWith("<<<<<<<") ||
|
|
313
|
+
line.startsWith("=======") ||
|
|
314
|
+
line.startsWith(">>>>>>>") ||
|
|
315
|
+
line.startsWith("|||||||")
|
|
316
|
+
) {
|
|
317
|
+
errors.push({
|
|
318
|
+
path: relative,
|
|
319
|
+
line: i + 1,
|
|
320
|
+
reason: `unresolved conflict marker: ${line.slice(0, 12).trim()}`,
|
|
321
|
+
});
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (relative.toLowerCase().endsWith(".json")) {
|
|
327
|
+
try {
|
|
328
|
+
JSON.parse(content);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
errors.push({
|
|
331
|
+
path: relative,
|
|
332
|
+
reason: `invalid JSON syntax: ${(err as Error).message}`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return errors;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface MergeOptions {
|
|
341
|
+
abort?: boolean;
|
|
342
|
+
ours?: boolean;
|
|
343
|
+
theirs?: boolean;
|
|
344
|
+
}
|
|
345
|
+
|
|
270
346
|
export async function merge(
|
|
271
347
|
ctx: CommandContext,
|
|
272
348
|
config: SyncConfig,
|
|
273
|
-
options:
|
|
349
|
+
options: MergeOptions = {},
|
|
274
350
|
): Promise<SyncResult> {
|
|
275
351
|
if (options.abort) {
|
|
276
352
|
if (!(await isMergeInProgress({ signal: ctx.signal }))) {
|
|
@@ -279,7 +355,6 @@ export async function merge(
|
|
|
279
355
|
return { pushed: false, pulled: false, merged: false, message };
|
|
280
356
|
}
|
|
281
357
|
await abortMerge({ signal: ctx.signal });
|
|
282
|
-
await copyMirrorToAgent(config);
|
|
283
358
|
await refreshIndicator(ctx, config);
|
|
284
359
|
const message = "Merge aborted; local files restored to the pre-merge state.";
|
|
285
360
|
ctx.ui.notify(message, "info");
|
|
@@ -293,21 +368,90 @@ export async function merge(
|
|
|
293
368
|
return { pushed: false, pulled: false, merged: false, message };
|
|
294
369
|
}
|
|
295
370
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
371
|
+
if (options.ours && options.theirs) {
|
|
372
|
+
const message = "Cannot specify both --ours and --theirs.";
|
|
373
|
+
ctx.ui.notify(message, "error");
|
|
374
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (options.ours) {
|
|
378
|
+
await checkoutOurs({ signal: ctx.signal });
|
|
379
|
+
} else if (options.theirs) {
|
|
380
|
+
await checkoutTheirs({ signal: ctx.signal });
|
|
381
|
+
} else {
|
|
382
|
+
// Graft any user modifications from the agent directory into mirror if the
|
|
383
|
+
// user manually edited the agent file while resolving.
|
|
384
|
+
const localFiles = await collectAgentFiles(config);
|
|
385
|
+
for (const file of localFiles) {
|
|
386
|
+
try {
|
|
387
|
+
const agentContent = await fs.readFile(file.source, "utf8");
|
|
388
|
+
const headContent = await readCommitFile("HEAD", file.path, { signal: ctx.signal });
|
|
389
|
+
if (headContent === undefined || agentContent !== headContent) {
|
|
390
|
+
const target = mirrorTarget(file.path);
|
|
391
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
392
|
+
await fs.writeFile(target, agentContent, "utf8");
|
|
393
|
+
}
|
|
394
|
+
} catch {
|
|
395
|
+
// Unreadable file — ignore
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
await stageAll({ signal: ctx.signal });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Validate mirror files before completing merge.
|
|
402
|
+
const errors = await validateResolvedMirror(config, ctx.signal);
|
|
403
|
+
if (errors.length > 0) {
|
|
404
|
+
const errorDetails = errors
|
|
405
|
+
.slice(0, 5)
|
|
406
|
+
.map((e) => ` - ${e.path}${e.line ? `:${e.line}` : ""}: ${e.reason}`)
|
|
407
|
+
.join("\n");
|
|
408
|
+
const message = `Cannot complete merge: ${errors.length} unresolved issue(s) detected.\n${errorDetails}\nResolve them or run /sync merge --abort.`;
|
|
409
|
+
ctx.ui.notify(message, "error");
|
|
410
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
411
|
+
}
|
|
412
|
+
|
|
301
413
|
const completed = await completeMerge(LOCAL_COMMIT_MESSAGE, { signal: ctx.signal });
|
|
302
414
|
await copyMirrorToAgent(config);
|
|
303
415
|
await refreshIndicator(ctx, config);
|
|
304
416
|
const message = completed
|
|
305
|
-
? "Merge completed. Run /sync push to publish."
|
|
417
|
+
? "Merge completed. Applied cleanly to local configuration. Run /sync push to publish."
|
|
306
418
|
: "Merge has no further changes to record. Run /sync push to publish.";
|
|
307
419
|
ctx.ui.notify(message, "info");
|
|
308
420
|
return { pushed: false, pulled: false, merged: true, message };
|
|
309
421
|
}
|
|
310
422
|
|
|
423
|
+
export async function mergetool(
|
|
424
|
+
ctx: CommandContext,
|
|
425
|
+
config: SyncConfig,
|
|
426
|
+
tool?: string,
|
|
427
|
+
): Promise<SyncResult> {
|
|
428
|
+
if (!(await isMergeInProgress({ signal: ctx.signal }))) {
|
|
429
|
+
const message =
|
|
430
|
+
"No merge in progress. Run /sync pull to merge remote changes into local files.";
|
|
431
|
+
ctx.ui.notify(message, "info");
|
|
432
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
await launchMergetool(tool, { signal: ctx.signal });
|
|
437
|
+
await refreshIndicator(ctx, config);
|
|
438
|
+
const conflicts = await listConflictedPaths({ signal: ctx.signal });
|
|
439
|
+
if (conflicts.length === 0) {
|
|
440
|
+
ctx.ui.notify("Mergetool finished. Run /sync merge to validate and apply.", "info");
|
|
441
|
+
} else {
|
|
442
|
+
ctx.ui.notify(
|
|
443
|
+
`Mergetool finished, but ${conflicts.length} file(s) still conflicted.`,
|
|
444
|
+
"warning",
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return { pushed: false, pulled: false, merged: false, message: "mergetool" };
|
|
448
|
+
} catch (error) {
|
|
449
|
+
const message = `Failed to run mergetool: ${error instanceof Error ? error.message : String(error)}`;
|
|
450
|
+
ctx.ui.notify(message, "error");
|
|
451
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
311
455
|
function describeChanges(summary: ReturnType<typeof diffSummary>): string {
|
|
312
456
|
const parts: string[] = [];
|
|
313
457
|
if (summary.added > 0) parts.push(`${summary.added} added`);
|
package/src/status.ts
CHANGED
|
@@ -135,6 +135,6 @@ export function syncIndicatorText(info: SyncStatusInfo): string {
|
|
|
135
135
|
case "behind":
|
|
136
136
|
return `sync: ${info.behind} behind — pull`;
|
|
137
137
|
case "conflict":
|
|
138
|
-
return `sync: conflict (${info.conflicts}) —
|
|
138
|
+
return `sync: conflict (${info.conflicts}) — /sync merge or mergetool`;
|
|
139
139
|
}
|
|
140
140
|
}
|