@lisang233/pi-sync 0.1.3 → 0.2.1

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
@@ -1,18 +1,30 @@
1
1
  # 🔄 pi-sync
2
2
 
3
- A personal Pi extension that syncs Pi configuration through **Git** with git-style `fetch`/`pull`/`merge`/`push`, a single config file, and conflict resolution inside the Pi UI.
3
+ A personal Pi extension that syncs Pi configuration through **Git** using the real
4
+ file tree — each synced file lives in the repo as its actual file, so conflict
5
+ detection and merging are handled by git itself.
4
6
 
5
- > **Experimental.** This is a from-scratch rewrite of the classic pi-sync flow: one config file, one git remote, content-level diffs, three-way merges, and deliberate commands that never move your files without asking.
7
+ > **Experimental.** A from-scratch rewrite that treats the remote repo as a
8
+ > genuine file tree instead of a single packed snapshot. Conflicts resolve
9
+ > through git's native three-way merge; the base is git's real merge-base, so a
10
+ > fresh machine or a force-pushed remote never causes a false conflict.
6
11
 
7
12
  ## ✨ Features
8
13
 
9
- - **Single config, direct connection** — one `pi-sync.json` points at one git remote and branch. No two-level setup/connection model.
10
- - **Observe-only automatic** — `automatic: true` fetches at session start and shows a persistent status-bar indicator (up-to-date / ahead / behind / conflict). It never pushes, pulls or merges on its own.
11
- - **Git-style pull** — `/sync pull` fetches and merges. Clean changes apply directly; divergence without a flag writes nothing; `--force` overwrites local files; `--merge` opens in-UI conflict resolution.
12
- - **In-UI conflict resolution** — divergent edits are parsed into conflict blocks and resolved one at a time (keep local / keep remote / type a replacement). Progress persists across sessions; `/sync merge` resumes, `/sync merge --abort` restores the pre-merge backup.
13
- - **JSON-aware merging** single-line `settings.json`/`keybindings.json`/`models.json` merge field-wise, so formatting or an unrelated field change doesn't conflict.
14
- - **Closed-loop status** `/sync status` shows the effective config, the sync state, any in-progress merge, and the exact next step; `/sync status --diff` shows the content-level diff (JSON pretty-printed, secrets masked, bounded).
15
- - **No stored credentials** — Git uses your existing SSH/credential-helper setup; the config file never holds tokens.
14
+ - **Real file tree, not a blob** — the remote branch holds `settings.json`,
15
+ `skills/…`, `prompts/…` as real files. Git does per-path, per-line detection
16
+ and merging; no hand-rolled snapshot engine.
17
+ - **Git-native conflict detection** — the base is git's merge-base, not a local
18
+ anchor file. A new machine's first `pull` adopts the remote cleanly; a
19
+ rewritten remote doesn't spuriously conflict.
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.
23
+ - **Deliberate commands** — nothing moves your files without being asked;
24
+ `automatic` only observes at session start.
25
+ - **Sensible defaults** — one `pi-sync.json` points at a git remote and branch,
26
+ an `include` list, and `automatic`.
27
+ - **No stored credentials** — git uses your existing SSH/credential helper.
16
28
 
17
29
  ## 📦 Install
18
30
 
@@ -32,10 +44,10 @@ pi install npm:@lisang233/pi-sync
32
44
  /sync init # first-run wizard: remote, branch, include, automatic
33
45
  /sync config # view and edit the config at any time
34
46
  /sync status # config + sync state + next step (--diff for content)
35
- /sync fetch # pull the remote snapshot without applying
36
- /sync pull # fetch + merge (--force overwrites, --merge resolves)
37
- /sync merge # continue an in-progress merge (--abort discards)
38
- /sync push # publish local snapshot (--force overwrites remote)
47
+ /sync fetch # pull the remote tree without applying
48
+ /sync pull # fetch + merge (--force overwrites local)
49
+ /sync merge # complete an in-progress merge (--abort discards)
50
+ /sync push # publish the local tree (--force overwrites remote)
39
51
  ```
40
52
 
41
53
  ## ⚙️ Settings
@@ -60,9 +72,18 @@ The config lives at `~/.pi/agent/pi-sync.json` (agent dir honors `PI_CODING_AGEN
60
72
  }
61
73
  ```
62
74
 
63
- - `include` selects which agent-dir paths sync. The defaults are `settings.json`, `keybindings.json`, `models.json`, `skills`, `prompts`, `themes`, `extensions`, and `extension-settings`. Sessions and `AGENTS.md` are intentionally not included by default. Edit it any time with `/sync config`.
64
- - `automatic` only controls whether a non-destructive fetch runs at session start; the status-bar indicator always reflects the last known state.
65
- - State lives under `<agent-dir>/pi-sync/` (a mirror git repo, `state.json`, `merge-session/`, and backups).
75
+ - `include` selects which agent-dir paths sync. The defaults are `settings.json`,
76
+ `keybindings.json`, `models.json`, `skills`, `prompts`, `themes`, `extensions`,
77
+ and `extension-settings`. Sessions and `AGENTS.md` are not synced by default.
78
+ - `automatic` only controls whether a non-destructive `fetch` runs at session
79
+ start; the status-bar indicator always reflects the last known state.
80
+ - A mirror git repo lives under `<agent-dir>/pi-sync/mirror/` and is checked out
81
+ on the configured branch.
82
+
83
+ > Note: with a real file tree, any sensitive token in a synced config is stored
84
+ > in the git repo as plaintext (like any dotfile repo). It is not hidden, and it
85
+ > is not encrypted. Use a private remote and a credential helper that keeps
86
+ > `~/.git-credentials` out of the repo.
66
87
 
67
88
  ## 🗂️ Package layout
68
89
 
@@ -73,16 +94,11 @@ src/
73
94
  config.ts single-file config load/validate/save
74
95
  config-ui.ts interactive config editor (view + edit fields)
75
96
  paths.ts agent-dir paths and include normalization
76
- git.ts git subprocess backend (fetch/push/show/merge-file)
77
- snapshot.ts scan include paths into a hashed snapshot
78
- state.ts last-applied snapshot + remote revision
79
- status.ts sync-state derivation and indicator text
80
- merge-session.ts persistent conflict-resolution session store
81
- conflict.ts diff3 marker parsing and resolved-text splicing
82
- resolve.ts structured block-by-block conflict resolver
97
+ git.ts git subprocess backend (real-file-tree fetch/push/merge)
98
+ tree.ts sync agent-dir <-> mirror work tree (real files)
99
+ status.ts sync-state derivation (git merge-base) and indicator
83
100
  diff.ts content-level diff with JSON formatting and secret masking
84
- merge.ts three-way merge (JSON field-wise + git merge-file fallback)
85
- operations.ts status/push/pull/fetch/merge
101
+ operations.ts status/push/pull/fetch/merge orchestration
86
102
  wizard.ts first-run setup wizard
87
103
  test/ vitest unit + local-bare-repo end-to-end tests
88
104
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lisang233/pi-sync",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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",
package/src/config.ts CHANGED
@@ -27,8 +27,9 @@ export const DEFAULT_CONFIG: SyncConfig = {
27
27
  automatic: true,
28
28
  };
29
29
 
30
- export const SNAPSHOT_FILE = "snapshot.json";
31
- export const BACKUP_DIR = "backups";
30
+ export function shortId(value: string): string {
31
+ return value.length > 10 ? value.slice(0, 10) : value;
32
+ }
32
33
 
33
34
  export function configPath(): string {
34
35
  return path.join(agentDir(), CONFIG_FILE_NAME);
@@ -42,14 +43,6 @@ export function mirrorRepoDir(): string {
42
43
  return path.join(stateDir(), "mirror");
43
44
  }
44
45
 
45
- export function snapshotFilePath(): string {
46
- return path.join(mirrorRepoDir(), "pi-sync", SNAPSHOT_FILE);
47
- }
48
-
49
- export function backupRootDir(): string {
50
- return path.join(stateDir(), BACKUP_DIR);
51
- }
52
-
53
46
  export async function loadConfig(): Promise<SyncConfig> {
54
47
  let text: string;
55
48
  try {
package/src/diff.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { createTwoFilesPatch } from "diff";
2
- import { fileHashMap, type Snapshot, snapshotFileContent } from "./snapshot.js";
3
2
 
4
3
  const HUNK_CONTEXT_LINES = 2;
5
4
  const MAX_HUNK_FILE_BYTES = 1024 * 1024;
@@ -15,38 +14,31 @@ const SECRET_PATTERNS = [
15
14
  /gh[pousr]_[A-Za-z0-9_]{20,}/,
16
15
  ];
17
16
 
18
- export interface SnapshotDiffSummary {
17
+ export interface DiffSummary {
19
18
  changed: number;
20
19
  added: number;
21
20
  removed: number;
22
21
  identical: boolean;
23
22
  }
24
23
 
25
- export function diffSummary(local: Snapshot, remote: Snapshot): SnapshotDiffSummary {
26
- const localMap = fileHashMap(local);
27
- const remoteMap = fileHashMap(remote);
28
- const paths = [...new Set([...localMap.keys(), ...remoteMap.keys()])];
24
+ /** Summarize the difference between two path→content maps (agent vs remote). */
25
+ export function diffSummary(local: Map<string, string>, remote: Map<string, string>): DiffSummary {
26
+ const paths = new Set([...local.keys(), ...remote.keys()]);
29
27
  let added = 0;
30
28
  let removed = 0;
31
29
  let changed = 0;
32
30
  for (const filePath of paths) {
33
- if (!localMap.has(filePath)) added += 1;
34
- else if (!remoteMap.has(filePath)) removed += 1;
35
- else if (localMap.get(filePath) !== remoteMap.get(filePath)) changed += 1;
31
+ if (!local.has(filePath)) added += 1;
32
+ else if (!remote.has(filePath)) removed += 1;
33
+ else if (local.get(filePath) !== remote.get(filePath)) changed += 1;
36
34
  }
37
35
  return { changed, added, removed, identical: added === 0 && removed === 0 && changed === 0 };
38
36
  }
39
37
 
40
- /** Content-level diff of local vs remote with JSON pretty-print, masking, and bounds. */
41
- export function formatSnapshotDiff(local: Snapshot, remote: Snapshot): string {
42
- const localMap = fileHashMap(local);
43
- const remoteMap = fileHashMap(remote);
44
- const allPaths = [...new Set([...localMap.keys(), ...remoteMap.keys()])].sort();
45
- const lines = [
46
- `local: ${local.files.length} files`,
47
- `remote: ${remote.createdAt} (${remote.files.length} files)`,
48
- "",
49
- ];
38
+ /** Content-level diff of local vs remote path→content maps (JSON pretty, masked, bounded). */
39
+ export function formatDiff(local: Map<string, string>, remote: Map<string, string>): string {
40
+ const allPaths = [...new Set([...local.keys(), ...remote.keys()])].sort();
41
+ const lines = [`local: ${local.size} files`, `remote: ${remote.size} files`, ""];
50
42
  let totalChanges = 0;
51
43
  let hunkBudget = MAX_TOTAL_HUNK_LINES;
52
44
  let truncated = false;
@@ -57,22 +49,22 @@ export function formatSnapshotDiff(local: Snapshot, remote: Snapshot): string {
57
49
  if (hunks.at(-1) === TRUNCATED_MARKER) truncated = true;
58
50
  };
59
51
  for (const filePath of allPaths) {
60
- if (!localMap.has(filePath)) {
52
+ if (!local.has(filePath)) {
61
53
  lines.push(`Remote only: ${filePath}`);
62
54
  totalChanges += 1;
63
55
  if (hunkBudget <= 0) truncated = true;
64
- else appendHunks(contentHunks("", fileText(remote, filePath), hunkBudget));
65
- } else if (!remoteMap.has(filePath)) {
56
+ else appendHunks(contentHunks("", remote.get(filePath) ?? "", hunkBudget));
57
+ } else if (!remote.has(filePath)) {
66
58
  lines.push(`Local only: ${filePath}`);
67
59
  totalChanges += 1;
68
60
  if (hunkBudget <= 0) truncated = true;
69
- else appendHunks(contentHunks("", fileText(local, filePath), hunkBudget));
70
- } else if (localMap.get(filePath) !== remoteMap.get(filePath)) {
61
+ else appendHunks(contentHunks("", local.get(filePath) ?? "", hunkBudget));
62
+ } else if (local.get(filePath) !== remote.get(filePath)) {
71
63
  lines.push(`Different: ${filePath}`);
72
64
  totalChanges += 1;
73
65
  if (hunkBudget <= 0) truncated = true;
74
66
  else {
75
- const texts = diffTexts(fileText(remote, filePath), fileText(local, filePath));
67
+ const texts = diffTexts(remote.get(filePath), local.get(filePath));
76
68
  appendHunks(contentHunks(texts.before, texts.after, hunkBudget));
77
69
  }
78
70
  }
@@ -82,10 +74,6 @@ export function formatSnapshotDiff(local: Snapshot, remote: Snapshot): string {
82
74
  return lines.join("\n");
83
75
  }
84
76
 
85
- function fileText(snapshot: Snapshot, filePath: string): string {
86
- return snapshotFileContent(snapshot, filePath) ?? "";
87
- }
88
-
89
77
  function diffTexts(before: string | undefined, after: string | undefined) {
90
78
  const beforePretty = before !== undefined ? prettyJson(before) : undefined;
91
79
  const afterPretty = after !== undefined ? prettyJson(after) : undefined;
package/src/extension.ts CHANGED
@@ -28,10 +28,10 @@ const USAGE = [
28
28
  " init first-run setup wizard",
29
29
  " config view and edit the config",
30
30
  " status config + sync state + next step (--diff for content)",
31
- " fetch fetch the remote snapshot without applying",
32
- " pull fetch + merge (--force overwrites, --merge resolves)",
31
+ " fetch fetch the remote tree without applying",
32
+ " pull fetch + merge (--force overwrites local)",
33
33
  " merge continue an in-progress merge (--abort discards)",
34
- " push publish local snapshot (--force overwrites remote)",
34
+ " push publish local tree (--force overwrites remote)",
35
35
  " help show this help",
36
36
  ].join("\n");
37
37
 
@@ -85,9 +85,7 @@ export default function sync(pi: ExtensionAPI): void {
85
85
  for (const current of tasks) {
86
86
  if (!current) continue;
87
87
  try {
88
- await (signal
89
- ? Promise.race([current.settled, waitForAbort(signal)])
90
- : current.settled);
88
+ await (signal ? Promise.race([current.settled, waitForAbort(signal)]) : current.settled);
91
89
  } catch {
92
90
  // The shutdown deadline or a replacement aborted while draining; the
93
91
  // background task observes its own session signal and settles on its own.
@@ -168,11 +166,7 @@ async function runAutomaticSync(ctx: ExtensionContext, signal: AbortSignal): Pro
168
166
  async function handleCommand(
169
167
  rawArgs: string,
170
168
  ctx: ExtensionCommandContext,
171
- runPush: (
172
- ctx: ExtensionCommandContext,
173
- config: SyncConfig,
174
- force: boolean,
175
- ) => Promise<void>,
169
+ runPush: (ctx: ExtensionCommandContext, config: SyncConfig, force: boolean) => Promise<void>,
176
170
  ): Promise<void> {
177
171
  const [first = "", ...restTokens] = rawArgs.trim().split(/\s+/u);
178
172
  const subcommand = normalizeSubcommand(first);
@@ -209,10 +203,7 @@ async function handleCommand(
209
203
  await runPush(ctx, config, force);
210
204
  return;
211
205
  case "pull":
212
- await operations.pull(ctx, config, {
213
- force,
214
- merge: restTokens.some((token) => token === "--merge"),
215
- });
206
+ await operations.pull(ctx, config, { force });
216
207
  return;
217
208
  case "fetch":
218
209
  await operations.fetch(ctx, config);