@lisang233/pi-sync 0.1.3 → 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/README.md +41 -25
- package/package.json +1 -1
- package/src/config.ts +3 -10
- package/src/diff.ts +17 -29
- package/src/extension.ts +6 -15
- package/src/git.ts +225 -92
- package/src/operations.ts +156 -388
- package/src/status.ts +72 -25
- package/src/tree.ts +231 -0
- package/src/conflict.ts +0 -90
- package/src/merge-session.ts +0 -145
- package/src/merge.ts +0 -203
- package/src/resolve.ts +0 -67
- package/src/snapshot.ts +0 -151
- package/src/state.ts +0 -60
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**
|
|
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.**
|
|
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
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **
|
|
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
|
|
36
|
-
/sync pull # fetch + merge (--force overwrites
|
|
37
|
-
/sync merge #
|
|
38
|
-
/sync push # publish local
|
|
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`,
|
|
64
|
-
|
|
65
|
-
|
|
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/
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
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 (!
|
|
34
|
-
else if (!
|
|
35
|
-
else if (
|
|
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
|
|
41
|
-
export function
|
|
42
|
-
const
|
|
43
|
-
const
|
|
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 (!
|
|
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("",
|
|
65
|
-
} else if (!
|
|
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("",
|
|
70
|
-
} else if (
|
|
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(
|
|
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
|
|
32
|
-
" pull fetch + merge (--force overwrites
|
|
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
|
|
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);
|