@lisang233/pi-sync 0.1.2 β 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 +58 -20
- package/src/git.ts +225 -92
- package/src/operations.ts +156 -388
- package/src/status.ts +75 -28
- 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
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { loadConfig } from "./config.js";
|
|
6
|
+
import { loadConfig, type SyncConfig } from "./config.js";
|
|
7
7
|
import { runConfigEditor } from "./config-ui.js";
|
|
8
8
|
import * as operations from "./operations.js";
|
|
9
9
|
import { syncBusyText } from "./status.js";
|
|
@@ -28,16 +28,17 @@ 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
|
|
|
38
38
|
export default function sync(pi: ExtensionAPI): void {
|
|
39
39
|
let sessionAbort = new AbortController();
|
|
40
40
|
let backgroundSync: BackgroundSync | undefined;
|
|
41
|
+
let backgroundPush: BackgroundSync | undefined;
|
|
41
42
|
|
|
42
43
|
const startBackgroundSync = (ctx: ExtensionContext, signal: AbortSignal) => {
|
|
43
44
|
// While the automatic fetch is in flight the indicator shows the busy
|
|
@@ -55,15 +56,40 @@ export default function sync(pi: ExtensionAPI): void {
|
|
|
55
56
|
backgroundSync = { settled };
|
|
56
57
|
};
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
// Publish in the background so the TUI stays interactive while git talks
|
|
60
|
+
// to the remote (fetch + push can take many seconds). The indicator shows
|
|
61
|
+
// "pushingβ¦"; operations.push notifies the outcome when it settles.
|
|
62
|
+
const startBackgroundPush = (
|
|
63
|
+
ctx: ExtensionCommandContext,
|
|
64
|
+
config: SyncConfig,
|
|
65
|
+
force: boolean,
|
|
66
|
+
signal: AbortSignal,
|
|
67
|
+
) => {
|
|
68
|
+
ctx.ui.setStatus(STATUS_KEY, syncBusyText("push"));
|
|
69
|
+
const settled = (async () => {
|
|
70
|
+
try {
|
|
71
|
+
await operations.push(ctx, config, { force });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (signal.aborted) return;
|
|
74
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
75
|
+
ctx.ui.notify(`pi-sync push failed: ${errorMessage(error)}`, "error");
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
backgroundPush = { settled };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const drainBackgroundTasks = async (signal?: AbortSignal): Promise<void> => {
|
|
82
|
+
const tasks = [backgroundSync, backgroundPush];
|
|
60
83
|
backgroundSync = undefined;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
backgroundPush = undefined;
|
|
85
|
+
for (const current of tasks) {
|
|
86
|
+
if (!current) continue;
|
|
87
|
+
try {
|
|
88
|
+
await (signal ? Promise.race([current.settled, waitForAbort(signal)]) : current.settled);
|
|
89
|
+
} catch {
|
|
90
|
+
// The shutdown deadline or a replacement aborted while draining; the
|
|
91
|
+
// background task observes its own session signal and settles on its own.
|
|
92
|
+
}
|
|
67
93
|
}
|
|
68
94
|
};
|
|
69
95
|
|
|
@@ -84,7 +110,18 @@ export default function sync(pi: ExtensionAPI): void {
|
|
|
84
110
|
);
|
|
85
111
|
}
|
|
86
112
|
try {
|
|
87
|
-
await handleCommand(args, ctx)
|
|
113
|
+
await handleCommand(args, ctx, async (pushCtx, config, force) => {
|
|
114
|
+
if (backgroundPush) {
|
|
115
|
+
pushCtx.ui.notify("A push is already in progress.", "warning");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Wait for the session-start automatic fetch so the two
|
|
119
|
+
// background tasks never contend on the mirror repo, then run
|
|
120
|
+
// the publish without blocking the TUI.
|
|
121
|
+
await drainBackgroundTasks();
|
|
122
|
+
if (sessionAbort.signal.aborted) return;
|
|
123
|
+
startBackgroundPush(pushCtx, config, force, sessionAbort.signal);
|
|
124
|
+
});
|
|
88
125
|
} catch (error) {
|
|
89
126
|
if (sessionAbort.signal.aborted) return;
|
|
90
127
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -98,7 +135,7 @@ export default function sync(pi: ExtensionAPI): void {
|
|
|
98
135
|
sessionAbort = new AbortController();
|
|
99
136
|
const signal = sessionAbort.signal;
|
|
100
137
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
101
|
-
await
|
|
138
|
+
await drainBackgroundTasks();
|
|
102
139
|
try {
|
|
103
140
|
const config = await loadConfig();
|
|
104
141
|
if (signal.aborted) return;
|
|
@@ -126,7 +163,11 @@ async function runAutomaticSync(ctx: ExtensionContext, signal: AbortSignal): Pro
|
|
|
126
163
|
await operations.fetch(ctx, config, { quiet: true });
|
|
127
164
|
}
|
|
128
165
|
|
|
129
|
-
async function handleCommand(
|
|
166
|
+
async function handleCommand(
|
|
167
|
+
rawArgs: string,
|
|
168
|
+
ctx: ExtensionCommandContext,
|
|
169
|
+
runPush: (ctx: ExtensionCommandContext, config: SyncConfig, force: boolean) => Promise<void>,
|
|
170
|
+
): Promise<void> {
|
|
130
171
|
const [first = "", ...restTokens] = rawArgs.trim().split(/\s+/u);
|
|
131
172
|
const subcommand = normalizeSubcommand(first);
|
|
132
173
|
if (subcommand === undefined || subcommand === "help") {
|
|
@@ -159,13 +200,10 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext): Pro
|
|
|
159
200
|
});
|
|
160
201
|
return;
|
|
161
202
|
case "push":
|
|
162
|
-
await
|
|
203
|
+
await runPush(ctx, config, force);
|
|
163
204
|
return;
|
|
164
205
|
case "pull":
|
|
165
|
-
await operations.pull(ctx, config, {
|
|
166
|
-
force,
|
|
167
|
-
merge: restTokens.some((token) => token === "--merge"),
|
|
168
|
-
});
|
|
206
|
+
await operations.pull(ctx, config, { force });
|
|
169
207
|
return;
|
|
170
208
|
case "fetch":
|
|
171
209
|
await operations.fetch(ctx, config);
|