@lisang233/pi-sync 0.1.1 → 0.1.3
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/package.json +1 -1
- package/src/extension.ts +64 -13
- package/src/operations.ts +40 -21
- package/src/snapshot.ts +25 -0
- package/src/status.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lisang233/pi-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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/extension.ts
CHANGED
|
@@ -3,9 +3,10 @@ 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
|
+
import { syncBusyText } from "./status.js";
|
|
9
10
|
import { runSetupWizard } from "./wizard.js";
|
|
10
11
|
|
|
11
12
|
const STATUS_KEY = "sync";
|
|
@@ -37,8 +38,12 @@ const USAGE = [
|
|
|
37
38
|
export default function sync(pi: ExtensionAPI): void {
|
|
38
39
|
let sessionAbort = new AbortController();
|
|
39
40
|
let backgroundSync: BackgroundSync | undefined;
|
|
41
|
+
let backgroundPush: BackgroundSync | undefined;
|
|
40
42
|
|
|
41
43
|
const startBackgroundSync = (ctx: ExtensionContext, signal: AbortSignal) => {
|
|
44
|
+
// While the automatic fetch is in flight the indicator shows the busy
|
|
45
|
+
// state; refreshIndicator replaces it with the real state when done.
|
|
46
|
+
ctx.ui.setStatus(STATUS_KEY, syncBusyText());
|
|
42
47
|
const settled = (async () => {
|
|
43
48
|
try {
|
|
44
49
|
await runAutomaticSync(ctx, signal);
|
|
@@ -51,15 +56,42 @@ export default function sync(pi: ExtensionAPI): void {
|
|
|
51
56
|
backgroundSync = { settled };
|
|
52
57
|
};
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
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];
|
|
56
83
|
backgroundSync = undefined;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
backgroundPush = undefined;
|
|
85
|
+
for (const current of tasks) {
|
|
86
|
+
if (!current) continue;
|
|
87
|
+
try {
|
|
88
|
+
await (signal
|
|
89
|
+
? Promise.race([current.settled, waitForAbort(signal)])
|
|
90
|
+
: current.settled);
|
|
91
|
+
} catch {
|
|
92
|
+
// The shutdown deadline or a replacement aborted while draining; the
|
|
93
|
+
// background task observes its own session signal and settles on its own.
|
|
94
|
+
}
|
|
63
95
|
}
|
|
64
96
|
};
|
|
65
97
|
|
|
@@ -80,7 +112,18 @@ export default function sync(pi: ExtensionAPI): void {
|
|
|
80
112
|
);
|
|
81
113
|
}
|
|
82
114
|
try {
|
|
83
|
-
await handleCommand(args, ctx)
|
|
115
|
+
await handleCommand(args, ctx, async (pushCtx, config, force) => {
|
|
116
|
+
if (backgroundPush) {
|
|
117
|
+
pushCtx.ui.notify("A push is already in progress.", "warning");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Wait for the session-start automatic fetch so the two
|
|
121
|
+
// background tasks never contend on the mirror repo, then run
|
|
122
|
+
// the publish without blocking the TUI.
|
|
123
|
+
await drainBackgroundTasks();
|
|
124
|
+
if (sessionAbort.signal.aborted) return;
|
|
125
|
+
startBackgroundPush(pushCtx, config, force, sessionAbort.signal);
|
|
126
|
+
});
|
|
84
127
|
} catch (error) {
|
|
85
128
|
if (sessionAbort.signal.aborted) return;
|
|
86
129
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -94,7 +137,7 @@ export default function sync(pi: ExtensionAPI): void {
|
|
|
94
137
|
sessionAbort = new AbortController();
|
|
95
138
|
const signal = sessionAbort.signal;
|
|
96
139
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
97
|
-
await
|
|
140
|
+
await drainBackgroundTasks();
|
|
98
141
|
try {
|
|
99
142
|
const config = await loadConfig();
|
|
100
143
|
if (signal.aborted) return;
|
|
@@ -122,7 +165,15 @@ async function runAutomaticSync(ctx: ExtensionContext, signal: AbortSignal): Pro
|
|
|
122
165
|
await operations.fetch(ctx, config, { quiet: true });
|
|
123
166
|
}
|
|
124
167
|
|
|
125
|
-
async function handleCommand(
|
|
168
|
+
async function handleCommand(
|
|
169
|
+
rawArgs: string,
|
|
170
|
+
ctx: ExtensionCommandContext,
|
|
171
|
+
runPush: (
|
|
172
|
+
ctx: ExtensionCommandContext,
|
|
173
|
+
config: SyncConfig,
|
|
174
|
+
force: boolean,
|
|
175
|
+
) => Promise<void>,
|
|
176
|
+
): Promise<void> {
|
|
126
177
|
const [first = "", ...restTokens] = rawArgs.trim().split(/\s+/u);
|
|
127
178
|
const subcommand = normalizeSubcommand(first);
|
|
128
179
|
if (subcommand === undefined || subcommand === "help") {
|
|
@@ -155,7 +206,7 @@ async function handleCommand(rawArgs: string, ctx: ExtensionCommandContext): Pro
|
|
|
155
206
|
});
|
|
156
207
|
return;
|
|
157
208
|
case "push":
|
|
158
|
-
await
|
|
209
|
+
await runPush(ctx, config, force);
|
|
159
210
|
return;
|
|
160
211
|
case "pull":
|
|
161
212
|
await operations.pull(ctx, config, {
|
package/src/operations.ts
CHANGED
|
@@ -35,7 +35,13 @@ import {
|
|
|
35
35
|
} from "./merge-session.js";
|
|
36
36
|
import { agentDir, syncRootPath } from "./paths.js";
|
|
37
37
|
import { runBlockResolver } from "./resolve.js";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
createSnapshot,
|
|
40
|
+
pathMatchesInclude,
|
|
41
|
+
projectSnapshot,
|
|
42
|
+
type Snapshot,
|
|
43
|
+
snapshotSha256,
|
|
44
|
+
} from "./snapshot.js";
|
|
39
45
|
import { loadState, saveState } from "./state.js";
|
|
40
46
|
import { deriveSyncStatus, type SyncStatusInfo, syncIndicatorText } from "./status.js";
|
|
41
47
|
|
|
@@ -65,10 +71,15 @@ export async function refreshIndicator(ctx: OperationContext, config: SyncConfig
|
|
|
65
71
|
readRemoteSnapshot(config),
|
|
66
72
|
loadState(),
|
|
67
73
|
]);
|
|
74
|
+
const projectedRemote = remote ? projectSnapshot(remote, config.include) : undefined;
|
|
68
75
|
const base = state?.lastRemoteRevision
|
|
69
76
|
? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
|
|
70
77
|
: undefined;
|
|
71
|
-
|
|
78
|
+
const projectedBase = base ? projectSnapshot(base, config.include) : undefined;
|
|
79
|
+
ctx.ui.setStatus(
|
|
80
|
+
"sync",
|
|
81
|
+
syncIndicatorText(deriveSyncStatus(local, projectedRemote, projectedBase)),
|
|
82
|
+
);
|
|
72
83
|
} catch {
|
|
73
84
|
ctx.ui.setStatus(
|
|
74
85
|
"sync",
|
|
@@ -90,10 +101,12 @@ export async function status(
|
|
|
90
101
|
readRemoteSnapshot(config),
|
|
91
102
|
loadState(),
|
|
92
103
|
]);
|
|
104
|
+
const projectedRemote = remote ? projectSnapshot(remote, config.include) : undefined;
|
|
93
105
|
const base = state?.lastRemoteRevision
|
|
94
106
|
? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
|
|
95
107
|
: undefined;
|
|
96
|
-
const
|
|
108
|
+
const projectedBase = base ? projectSnapshot(base, config.include) : undefined;
|
|
109
|
+
const info = deriveSyncStatus(local, projectedRemote, projectedBase);
|
|
97
110
|
const mergeSession = await loadMergeSession();
|
|
98
111
|
await refreshIndicator(ctx, config);
|
|
99
112
|
const lines = [
|
|
@@ -107,8 +120,8 @@ export async function status(
|
|
|
107
120
|
);
|
|
108
121
|
}
|
|
109
122
|
lines.push(nextStepHint(info, mergeSession !== undefined));
|
|
110
|
-
if (options.diff &&
|
|
111
|
-
lines.push("", formatSnapshotDiff(local,
|
|
123
|
+
if (options.diff && projectedRemote) {
|
|
124
|
+
lines.push("", formatSnapshotDiff(local, projectedRemote));
|
|
112
125
|
}
|
|
113
126
|
const level = info.label === "up-to-date" && !mergeSession ? "info" : "warning";
|
|
114
127
|
ctx.ui.notify(lines.join("\n"), level);
|
|
@@ -196,10 +209,12 @@ export async function pull(
|
|
|
196
209
|
ctx.ui.notify(message, "warning");
|
|
197
210
|
return { pushed: false, pulled: false, merged: false, message };
|
|
198
211
|
}
|
|
212
|
+
const projectedRemote = projectSnapshot(remote, config.include);
|
|
199
213
|
const base = state?.lastRemoteRevision
|
|
200
214
|
? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
|
|
201
215
|
: undefined;
|
|
202
|
-
const
|
|
216
|
+
const projectedBase = base ? projectSnapshot(base, config.include) : undefined;
|
|
217
|
+
const plan = planMerge(local, projectedRemote, projectedBase);
|
|
203
218
|
const diverged =
|
|
204
219
|
plan.conflicts.length > 0 || (plan.takeLocal.length > 0 && plan.takeRemote.length > 0);
|
|
205
220
|
|
|
@@ -214,7 +229,7 @@ export async function pull(
|
|
|
214
229
|
return { pushed: false, pulled: false, merged: false, message };
|
|
215
230
|
}
|
|
216
231
|
// Fast-forward: apply the merged snapshot (remote-only changes).
|
|
217
|
-
const merged = mergeSnapshot(local,
|
|
232
|
+
const merged = mergeSnapshot(local, projectedRemote, plan);
|
|
218
233
|
await applySnapshot(merged, config);
|
|
219
234
|
await saveState({
|
|
220
235
|
version: 1,
|
|
@@ -231,22 +246,30 @@ export async function pull(
|
|
|
231
246
|
|
|
232
247
|
if (options.force) {
|
|
233
248
|
const backup = await backupLocalFiles(local);
|
|
234
|
-
await applySnapshot(
|
|
249
|
+
await applySnapshot(projectedRemote, config);
|
|
235
250
|
await saveState({
|
|
236
251
|
version: 1,
|
|
237
|
-
lastAppliedSnapshot: snapshotSha256(
|
|
252
|
+
lastAppliedSnapshot: snapshotSha256(projectedRemote),
|
|
238
253
|
lastRemoteRevision: remoteRevision,
|
|
239
|
-
lastHashes: Object.fromEntries(
|
|
254
|
+
lastHashes: Object.fromEntries(projectedRemote.files.map((file) => [file.path, file.sha256])),
|
|
240
255
|
});
|
|
241
256
|
await clearMergeSession();
|
|
242
257
|
await refreshIndicator(ctx, config);
|
|
243
|
-
const message = `Overwrote local files with the remote snapshot (${
|
|
258
|
+
const message = `Overwrote local files with the remote snapshot (${projectedRemote.files.length} files). Backup: ${backup}`;
|
|
244
259
|
ctx.ui.notify(message, "warning");
|
|
245
260
|
return { pushed: false, pulled: true, merged: false, message };
|
|
246
261
|
}
|
|
247
262
|
|
|
248
263
|
if (options.merge) {
|
|
249
|
-
return startMergeFlow(
|
|
264
|
+
return startMergeFlow(
|
|
265
|
+
ctx,
|
|
266
|
+
config,
|
|
267
|
+
local,
|
|
268
|
+
projectedRemote,
|
|
269
|
+
projectedBase,
|
|
270
|
+
plan,
|
|
271
|
+
remoteRevision ?? "",
|
|
272
|
+
);
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
const message =
|
|
@@ -402,14 +425,15 @@ export async function fetch(
|
|
|
402
425
|
readRemoteRevision(config),
|
|
403
426
|
createSnapshot(config),
|
|
404
427
|
]);
|
|
428
|
+
const projectedRemote = remote ? projectSnapshot(remote, config.include) : undefined;
|
|
405
429
|
await refreshIndicator(ctx, config);
|
|
406
|
-
if (!
|
|
430
|
+
if (!projectedRemote) {
|
|
407
431
|
const message = "Remote is empty. Run /sync push to publish local content.";
|
|
408
432
|
if (!options.quiet) ctx.ui.notify(message, "info");
|
|
409
433
|
return { pushed: false, pulled: false, merged: false, message };
|
|
410
434
|
}
|
|
411
|
-
const summary = diffSummary(local,
|
|
412
|
-
const message = `Fetched ${
|
|
435
|
+
const summary = diffSummary(local, projectedRemote);
|
|
436
|
+
const message = `Fetched ${projectedRemote.files.length} files from ${config.branch} (${shortId(remoteRevision ?? "")}). ${describeChanges(summary)}.`;
|
|
413
437
|
if (!options.quiet) ctx.ui.notify(message, summary.identical ? "info" : "warning");
|
|
414
438
|
return { pushed: false, pulled: false, merged: false, message };
|
|
415
439
|
}
|
|
@@ -489,12 +513,7 @@ export async function applySnapshot(snapshot: Snapshot, config: SyncConfig): Pro
|
|
|
489
513
|
|
|
490
514
|
function resolveSnapshotTarget(relativePath: string, config: SyncConfig): string | undefined {
|
|
491
515
|
if (relativePath.split("/").some((segment) => segment === "..")) return undefined;
|
|
492
|
-
const entry = config.include.find((candidate) =>
|
|
493
|
-
const lower = candidate.toLowerCase();
|
|
494
|
-
return (
|
|
495
|
-
relativePath.toLowerCase() === lower || relativePath.toLowerCase().startsWith(`${lower}/`)
|
|
496
|
-
);
|
|
497
|
-
});
|
|
516
|
+
const entry = config.include.find((candidate) => pathMatchesInclude(relativePath, candidate));
|
|
498
517
|
if (!entry) return undefined;
|
|
499
518
|
const root = syncRootPath(entry);
|
|
500
519
|
const suffix = relativePath.slice(entry.length);
|
package/src/snapshot.ts
CHANGED
|
@@ -31,6 +31,31 @@ export function fileHashMap(snapshot: Snapshot): Map<string, string> {
|
|
|
31
31
|
return new Map(snapshot.files.map((file) => [file.path, file.sha256]));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* True when a snapshot path falls under an include entry: exact match or a
|
|
36
|
+
* directory prefix, case-insensitive. This is the single include-matching
|
|
37
|
+
* rule used by projection, write-back targeting and content mapping.
|
|
38
|
+
*/
|
|
39
|
+
export function pathMatchesInclude(relativePath: string, entry: string): boolean {
|
|
40
|
+
const lower = entry.toLowerCase();
|
|
41
|
+
const pathLower = relativePath.toLowerCase();
|
|
42
|
+
return pathLower === lower || pathLower.startsWith(`${lower}/`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Project a snapshot onto the include set: keep only paths covered by the
|
|
47
|
+
* declaration. The local snapshot is always a projection (`createSnapshot`
|
|
48
|
+
* scans include only); remote and historical snapshots are projected before
|
|
49
|
+
* merge and reporting so out-of-include leftovers never participate.
|
|
50
|
+
*/
|
|
51
|
+
export function projectSnapshot(snapshot: Snapshot, include: string[]): Snapshot {
|
|
52
|
+
const files = snapshot.files.filter((file) =>
|
|
53
|
+
include.some((entry) => pathMatchesInclude(file.path, entry)),
|
|
54
|
+
);
|
|
55
|
+
if (files.length === snapshot.files.length) return snapshot;
|
|
56
|
+
return { ...snapshot, files };
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
/** Build the current snapshot of the configured include paths under the agent dir. */
|
|
35
60
|
export async function createSnapshot(config: SyncConfig): Promise<Snapshot> {
|
|
36
61
|
const files: SnapshotFile[] = [];
|
package/src/status.ts
CHANGED
|
@@ -49,6 +49,11 @@ export function deriveSyncStatus(
|
|
|
49
49
|
return { label: "up-to-date", ahead: 0, behind: 0, conflicts: 0 };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Status-bar text while a background/foreground sync action is in flight. */
|
|
53
|
+
export function syncBusyText(action: "fetch" | "push" = "fetch"): string {
|
|
54
|
+
return action === "push" ? "sync: pushing…" : "sync: fetching…";
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
/** Short status-bar text for the sync indicator. */
|
|
53
58
|
export function syncIndicatorText(info: SyncStatusInfo): string {
|
|
54
59
|
switch (info.label) {
|