@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 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 produce real conflict markers in the actual
22
- files; `/sync merge` completes once you resolve them, `--abort` discards.
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 merge # complete an in-progress merge (--abort discards)
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.1",
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 = ["init", "status", "push", "pull", "fetch", "merge", "config", "help"] as const;
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 continue an in-progress merge (--abort discards)",
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
- return commitSync(message, options);
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 pull to merge, or /sync pull --force to overwrite local";
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 message = `Merge conflict in ${conflicts.length} file(s). Resolve the markers, then /sync merge. (remote ${shortId(
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: true, message, conflicts };
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: { abort?: boolean } = {},
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
- // The user resolved conflicts in the real agent files; graft the resolved
297
- // tree back into the mirror, then commit to complete the merge.
298
- const files = await collectAgentFiles(config);
299
- await graftAgentIntoMirror(config, files);
300
- await stageAll({ signal: ctx.signal });
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}) — pull --merge or --force`;
138
+ return `sync: conflict (${info.conflicts}) — /sync merge or mergetool`;
139
139
  }
140
140
  }