@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 +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 +89 -24
- 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/src/operations.ts
CHANGED
|
@@ -1,49 +1,37 @@
|
|
|
1
|
-
import type { Dirent } from "node:fs";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
1
|
import type {
|
|
5
2
|
ExtensionCommandContext,
|
|
6
3
|
ExtensionContext,
|
|
7
4
|
ExtensionUIContext,
|
|
8
5
|
} from "@earendil-works/pi-coding-agent";
|
|
9
6
|
import type { SyncConfig } from "./config.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { diffSummary,
|
|
7
|
+
import { shortId, stateDir } from "./config.js";
|
|
8
|
+
import { formatConfig } from "./config-ui.js";
|
|
9
|
+
import { diffSummary, formatDiff } from "./diff.js";
|
|
13
10
|
import {
|
|
11
|
+
abortMerge,
|
|
12
|
+
aheadBehind,
|
|
13
|
+
commitSync,
|
|
14
|
+
completeMerge,
|
|
15
|
+
ensureBranch,
|
|
16
|
+
ensureMirror,
|
|
14
17
|
fetchRemote,
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
isMergeInProgress,
|
|
19
|
+
listConflictedPaths,
|
|
20
|
+
mergeRemote,
|
|
21
|
+
pushBranch,
|
|
22
|
+
readMergeBase,
|
|
23
|
+
readRemoteFiles,
|
|
17
24
|
readRemoteRevision,
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
resetHard,
|
|
26
|
+
stageAll,
|
|
20
27
|
} from "./git.js";
|
|
28
|
+
import { classifyState, type StateClassify, syncIndicatorText } from "./status.js";
|
|
21
29
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} from "./merge.js";
|
|
28
|
-
import {
|
|
29
|
-
clearMergeSession,
|
|
30
|
-
hasMergeSession,
|
|
31
|
-
loadMergeSession,
|
|
32
|
-
type MergeFileState,
|
|
33
|
-
type MergeSessionData,
|
|
34
|
-
saveMergeSession,
|
|
35
|
-
} from "./merge-session.js";
|
|
36
|
-
import { agentDir, syncRootPath } from "./paths.js";
|
|
37
|
-
import { runBlockResolver } from "./resolve.js";
|
|
38
|
-
import {
|
|
39
|
-
createSnapshot,
|
|
40
|
-
pathMatchesInclude,
|
|
41
|
-
projectSnapshot,
|
|
42
|
-
type Snapshot,
|
|
43
|
-
snapshotSha256,
|
|
44
|
-
} from "./snapshot.js";
|
|
45
|
-
import { loadState, saveState } from "./state.js";
|
|
46
|
-
import { deriveSyncStatus, type SyncStatusInfo, syncIndicatorText } from "./status.js";
|
|
30
|
+
collectAgentFiles,
|
|
31
|
+
copyMirrorToAgent,
|
|
32
|
+
graftAgentIntoMirror,
|
|
33
|
+
readAgentContents,
|
|
34
|
+
} from "./tree.js";
|
|
47
35
|
|
|
48
36
|
export interface SyncResult {
|
|
49
37
|
pushed: boolean;
|
|
@@ -60,26 +48,13 @@ export interface OperationContext {
|
|
|
60
48
|
|
|
61
49
|
export type CommandContext = ExtensionCommandContext | ExtensionContext;
|
|
62
50
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*/
|
|
51
|
+
const LOCAL_COMMIT_MESSAGE = "pi-sync: local";
|
|
52
|
+
|
|
53
|
+
/** Refresh the persistent status-bar sync indicator from the last known state. */
|
|
67
54
|
export async function refreshIndicator(ctx: OperationContext, config: SyncConfig): Promise<void> {
|
|
68
55
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
readRemoteSnapshot(config),
|
|
72
|
-
loadState(),
|
|
73
|
-
]);
|
|
74
|
-
const projectedRemote = remote ? projectSnapshot(remote, config.include) : undefined;
|
|
75
|
-
const base = state?.lastRemoteRevision
|
|
76
|
-
? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
|
|
77
|
-
: undefined;
|
|
78
|
-
const projectedBase = base ? projectSnapshot(base, config.include) : undefined;
|
|
79
|
-
ctx.ui.setStatus(
|
|
80
|
-
"sync",
|
|
81
|
-
syncIndicatorText(deriveSyncStatus(local, projectedRemote, projectedBase)),
|
|
82
|
-
);
|
|
56
|
+
const info = await computeState(config, ctx.signal);
|
|
57
|
+
ctx.ui.setStatus("sync", syncIndicatorText(info));
|
|
83
58
|
} catch {
|
|
84
59
|
ctx.ui.setStatus(
|
|
85
60
|
"sync",
|
|
@@ -88,7 +63,21 @@ export async function refreshIndicator(ctx: OperationContext, config: SyncConfig
|
|
|
88
63
|
}
|
|
89
64
|
}
|
|
90
65
|
|
|
91
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Build the current classification by comparing the agent file tree to the
|
|
68
|
+
* remote branch. Uses git's real merge-base as the base, so a fresh machine or
|
|
69
|
+
* a rewritten remote never causes a false conflict. Does not graft or write.
|
|
70
|
+
*/
|
|
71
|
+
async function computeState(config: SyncConfig, signal?: AbortSignal): Promise<StateClassify> {
|
|
72
|
+
await ensureMirror(config);
|
|
73
|
+
const [local, remote, base, mergePending] = await Promise.all([
|
|
74
|
+
readAgentContents(config),
|
|
75
|
+
readRemoteFiles(config, { signal }),
|
|
76
|
+
readMergeBase(config, { signal }),
|
|
77
|
+
isMergeInProgress({ signal }),
|
|
78
|
+
]);
|
|
79
|
+
return classifyState(local, remote, base, mergePending, config.include);
|
|
80
|
+
}
|
|
92
81
|
|
|
93
82
|
export async function status(
|
|
94
83
|
ctx: CommandContext,
|
|
@@ -96,41 +85,25 @@ export async function status(
|
|
|
96
85
|
options: { diff?: boolean } = {},
|
|
97
86
|
): Promise<SyncResult> {
|
|
98
87
|
// status never fetches; it reflects the last known mirror state.
|
|
99
|
-
const
|
|
100
|
-
createSnapshot(config),
|
|
101
|
-
readRemoteSnapshot(config),
|
|
102
|
-
loadState(),
|
|
103
|
-
]);
|
|
104
|
-
const projectedRemote = remote ? projectSnapshot(remote, config.include) : undefined;
|
|
105
|
-
const base = state?.lastRemoteRevision
|
|
106
|
-
? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
|
|
107
|
-
: undefined;
|
|
108
|
-
const projectedBase = base ? projectSnapshot(base, config.include) : undefined;
|
|
109
|
-
const info = deriveSyncStatus(local, projectedRemote, projectedBase);
|
|
110
|
-
const mergeSession = await loadMergeSession();
|
|
88
|
+
const info = await computeState(config, ctx.signal);
|
|
111
89
|
await refreshIndicator(ctx, config);
|
|
112
|
-
const lines = [
|
|
113
|
-
|
|
114
|
-
`
|
|
115
|
-
`last applied: ${state ? shortId(state.lastAppliedSnapshot) : "never"}`,
|
|
116
|
-
];
|
|
117
|
-
if (mergeSession) {
|
|
118
|
-
lines.push(
|
|
119
|
-
`merge in progress: ${countPendingBlocks(mergeSession)} conflict block(s) unresolved — /sync merge to continue, /sync merge --abort to discard`,
|
|
120
|
-
);
|
|
90
|
+
const lines = [formatConfig(config), `state: ${syncIndicatorText(info)}`];
|
|
91
|
+
if (info.label === "conflict") {
|
|
92
|
+
lines.push(`conflicting file(s): ${info.diverged.slice(0, 5).join(", ") || "(resolving)"}`);
|
|
121
93
|
}
|
|
122
|
-
lines.push(nextStepHint(info
|
|
123
|
-
if (options.diff
|
|
124
|
-
|
|
94
|
+
lines.push(nextStepHint(info));
|
|
95
|
+
if (options.diff) {
|
|
96
|
+
const remote = await readRemoteFiles(config, { signal: ctx.signal });
|
|
97
|
+
const local = await readAgentContents(config);
|
|
98
|
+
lines.push("", formatDiff(local, remote));
|
|
125
99
|
}
|
|
126
|
-
const level = info.label === "up-to-date"
|
|
100
|
+
const level = info.label === "up-to-date" ? "info" : "warning";
|
|
127
101
|
ctx.ui.notify(lines.join("\n"), level);
|
|
128
102
|
return { pushed: false, pulled: false, merged: false, message: "status" };
|
|
129
103
|
}
|
|
130
104
|
|
|
131
105
|
/** The closed-loop hint: what to do next given the current state. */
|
|
132
|
-
function nextStepHint(info:
|
|
133
|
-
if (mergePending) return "next: /sync merge (resolve) or /sync merge --abort (discard)";
|
|
106
|
+
function nextStepHint(info: StateClassify): string {
|
|
134
107
|
switch (info.label) {
|
|
135
108
|
case "unconfigured":
|
|
136
109
|
return "next: /sync init";
|
|
@@ -143,7 +116,7 @@ function nextStepHint(info: SyncStatusInfo, mergePending: boolean): string {
|
|
|
143
116
|
case "behind":
|
|
144
117
|
return "next: /sync pull to fetch and apply remote changes";
|
|
145
118
|
case "conflict":
|
|
146
|
-
return "next: /sync pull
|
|
119
|
+
return "next: /sync pull to merge, or /sync pull --force to overwrite local";
|
|
147
120
|
case "unknown":
|
|
148
121
|
return "next: /sync fetch to check the remote";
|
|
149
122
|
}
|
|
@@ -154,264 +127,117 @@ export async function push(
|
|
|
154
127
|
config: SyncConfig,
|
|
155
128
|
options: { force?: boolean } = {},
|
|
156
129
|
): Promise<SyncResult> {
|
|
157
|
-
await
|
|
158
|
-
if (await
|
|
130
|
+
const { remoteExists } = await ensureBranch(config, { signal: ctx.signal });
|
|
131
|
+
if (await isMergeInProgress({ signal: ctx.signal })) {
|
|
159
132
|
const message =
|
|
160
133
|
"A merge is in progress. Resolve it (/sync merge) or discard it (/sync merge --abort) before pushing.";
|
|
161
134
|
ctx.ui.notify(message, "error");
|
|
162
135
|
return { pushed: false, pulled: false, merged: false, message };
|
|
163
136
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
remote &&
|
|
172
|
-
state &&
|
|
173
|
-
!isRemoteUpToDate(state.lastRemoteRevision, remoteRevision) &&
|
|
174
|
-
!options.force
|
|
175
|
-
) {
|
|
137
|
+
// Stage the local (agent) side as the current branch tip, then publish.
|
|
138
|
+
const files = await collectAgentFiles(config);
|
|
139
|
+
await graftAgentIntoMirror(config, files);
|
|
140
|
+
await stageAll({ signal: ctx.signal });
|
|
141
|
+
await commitLocalSide({ signal: ctx.signal });
|
|
142
|
+
|
|
143
|
+
if (remoteExists && !options.force && !(await isFastForward(config, ctx.signal))) {
|
|
176
144
|
const message =
|
|
177
|
-
"Remote changed since the last sync. Run /sync
|
|
145
|
+
"Remote changed since the last sync. Run /sync pull to merge, or /sync push --force to overwrite.";
|
|
178
146
|
ctx.ui.notify(message, "error");
|
|
179
147
|
return { pushed: false, pulled: false, merged: false, message };
|
|
180
148
|
}
|
|
181
|
-
|
|
182
|
-
await
|
|
183
|
-
version: 1,
|
|
184
|
-
lastAppliedSnapshot: snapshotSha256(local),
|
|
185
|
-
lastRemoteRevision: revision,
|
|
186
|
-
lastHashes: Object.fromEntries(local.files.map((file) => [file.path, file.sha256])),
|
|
187
|
-
});
|
|
188
|
-
await clearMergeSession();
|
|
149
|
+
|
|
150
|
+
await pushBranch(config, { signal: ctx.signal }, options.force);
|
|
189
151
|
await refreshIndicator(ctx, config);
|
|
190
|
-
const message = `Pushed ${
|
|
152
|
+
const message = `Pushed ${files.length} file(s) from ${stateDir()} to ${config.branch}.`;
|
|
191
153
|
ctx.ui.notify(message, "info");
|
|
192
154
|
return { pushed: true, pulled: false, merged: false, message };
|
|
193
155
|
}
|
|
194
156
|
|
|
157
|
+
/** True when the remote tip is an ancestor of the local branch tip (a safe fast-forward). */
|
|
158
|
+
async function isFastForward(config: SyncConfig, signal?: AbortSignal): Promise<boolean> {
|
|
159
|
+
const { behind } = await aheadBehind(config, { signal });
|
|
160
|
+
return behind === 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function commitLocalSide(options: { signal?: AbortSignal } = {}): Promise<void> {
|
|
164
|
+
await commitSync(LOCAL_COMMIT_MESSAGE, options);
|
|
165
|
+
}
|
|
166
|
+
|
|
195
167
|
export async function pull(
|
|
196
168
|
ctx: CommandContext,
|
|
197
169
|
config: SyncConfig,
|
|
198
|
-
options: { force?: boolean
|
|
170
|
+
options: { force?: boolean } = {},
|
|
199
171
|
): Promise<SyncResult> {
|
|
200
|
-
await
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!
|
|
172
|
+
const { fresh, remoteExists } = await ensureBranch(config, { signal: ctx.signal });
|
|
173
|
+
if (await isMergeInProgress({ signal: ctx.signal })) {
|
|
174
|
+
const message =
|
|
175
|
+
"A merge is already in progress. Continue with /sync merge or discard it with /sync merge --abort.";
|
|
176
|
+
ctx.ui.notify(message, "warning");
|
|
177
|
+
return { pushed: false, pulled: false, merged: false, message };
|
|
178
|
+
}
|
|
179
|
+
if (!remoteExists) {
|
|
208
180
|
const message = "Remote is empty. Run /sync push first.";
|
|
209
181
|
ctx.ui.notify(message, "warning");
|
|
210
182
|
return { pushed: false, pulled: false, merged: false, message };
|
|
211
183
|
}
|
|
212
|
-
const projectedRemote = projectSnapshot(remote, config.include);
|
|
213
|
-
const base = state?.lastRemoteRevision
|
|
214
|
-
? await readSnapshotAt(state.lastRemoteRevision, { signal: ctx.signal })
|
|
215
|
-
: undefined;
|
|
216
|
-
const projectedBase = base ? projectSnapshot(base, config.include) : undefined;
|
|
217
|
-
const plan = planMerge(local, projectedRemote, projectedBase);
|
|
218
|
-
const diverged =
|
|
219
|
-
plan.conflicts.length > 0 || (plan.takeLocal.length > 0 && plan.takeRemote.length > 0);
|
|
220
184
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
plan.takeLocal.length > 0
|
|
225
|
-
? "Already up to date; local is ahead. Run /sync push to publish."
|
|
226
|
-
: "Already up to date.";
|
|
227
|
-
ctx.ui.notify(message, "info");
|
|
228
|
-
await refreshIndicator(ctx, config);
|
|
229
|
-
return { pushed: false, pulled: false, merged: false, message };
|
|
230
|
-
}
|
|
231
|
-
// Fast-forward: apply the merged snapshot (remote-only changes).
|
|
232
|
-
const merged = mergeSnapshot(local, projectedRemote, plan);
|
|
233
|
-
await applySnapshot(merged, config);
|
|
234
|
-
await saveState({
|
|
235
|
-
version: 1,
|
|
236
|
-
lastAppliedSnapshot: snapshotSha256(merged),
|
|
237
|
-
lastRemoteRevision: remoteRevision,
|
|
238
|
-
lastHashes: Object.fromEntries(merged.files.map((file) => [file.path, file.sha256])),
|
|
239
|
-
});
|
|
240
|
-
await clearMergeSession();
|
|
185
|
+
// Force: throw away the local side and adopt the remote directly.
|
|
186
|
+
if (options.force) {
|
|
187
|
+
await forceAdoptRemote(config, ctx.signal);
|
|
241
188
|
await refreshIndicator(ctx, config);
|
|
242
|
-
const
|
|
243
|
-
|
|
189
|
+
const remote = await readRemoteFiles(config, { signal: ctx.signal });
|
|
190
|
+
const message = `Overwrote local files with the remote tree (${remote.size} files).`;
|
|
191
|
+
ctx.ui.notify(message, "warning");
|
|
244
192
|
return { pushed: false, pulled: true, merged: false, message };
|
|
245
193
|
}
|
|
246
194
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
await
|
|
250
|
-
await saveState({
|
|
251
|
-
version: 1,
|
|
252
|
-
lastAppliedSnapshot: snapshotSha256(projectedRemote),
|
|
253
|
-
lastRemoteRevision: remoteRevision,
|
|
254
|
-
lastHashes: Object.fromEntries(projectedRemote.files.map((file) => [file.path, file.sha256])),
|
|
255
|
-
});
|
|
256
|
-
await clearMergeSession();
|
|
195
|
+
// Fresh machine (first sync): adopt the remote so it never false-conflicts.
|
|
196
|
+
if (fresh) {
|
|
197
|
+
await copyMirrorToAgent(config);
|
|
257
198
|
await refreshIndicator(ctx, config);
|
|
258
|
-
const
|
|
259
|
-
|
|
199
|
+
const remote = await readRemoteFiles(config, { signal: ctx.signal });
|
|
200
|
+
const message = `Pulled ${remote.size} file(s) from ${config.branch} — initial sync.`;
|
|
201
|
+
ctx.ui.notify(message, "info");
|
|
260
202
|
return { pushed: false, pulled: true, merged: false, message };
|
|
261
203
|
}
|
|
262
204
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
projectedRemote,
|
|
269
|
-
projectedBase,
|
|
270
|
-
plan,
|
|
271
|
-
remoteRevision ?? "",
|
|
272
|
-
);
|
|
273
|
-
}
|
|
205
|
+
// Stage local (agent) side as the branch tip, then git merge origin.
|
|
206
|
+
const files = await collectAgentFiles(config);
|
|
207
|
+
await graftAgentIntoMirror(config, files);
|
|
208
|
+
await stageAll({ signal: ctx.signal });
|
|
209
|
+
await commitLocalSide({ signal: ctx.signal });
|
|
274
210
|
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
ctx.ui.notify(message, "warning");
|
|
211
|
+
const conflicted = await mergeRemote(config, { signal: ctx.signal });
|
|
212
|
+
await copyMirrorToAgent(config);
|
|
278
213
|
await refreshIndicator(ctx, config);
|
|
279
|
-
return { pushed: false, pulled: false, merged: false, message, conflicts: plan.conflicts };
|
|
280
|
-
}
|
|
281
214
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
ctx: CommandContext,
|
|
290
|
-
config: SyncConfig,
|
|
291
|
-
local: Snapshot,
|
|
292
|
-
remote: Snapshot,
|
|
293
|
-
base: Snapshot | undefined,
|
|
294
|
-
plan: MergeOutcome,
|
|
295
|
-
remoteRevision: string,
|
|
296
|
-
): Promise<SyncResult> {
|
|
297
|
-
if (await hasMergeSession()) {
|
|
298
|
-
const message =
|
|
299
|
-
"A merge is already in progress. Continue with /sync merge or discard it with /sync merge --abort.";
|
|
300
|
-
ctx.ui.notify(message, "warning");
|
|
301
|
-
return { pushed: false, pulled: false, merged: false, message };
|
|
302
|
-
}
|
|
303
|
-
const backup = await backupLocalFiles(local);
|
|
304
|
-
const conflictContents = new Map<string, string>();
|
|
305
|
-
const sessionFiles: MergeFileState[] = [];
|
|
306
|
-
for (const filePath of plan.conflicts) {
|
|
307
|
-
const merged = await mergeTexts(
|
|
308
|
-
textFromSnapshot(base ?? emptySnapshot(), filePath),
|
|
309
|
-
textFromSnapshot(local, filePath),
|
|
310
|
-
textFromSnapshot(remote, filePath),
|
|
311
|
-
ctx.signal,
|
|
312
|
-
);
|
|
313
|
-
conflictContents.set(filePath, merged.merged);
|
|
314
|
-
const blocks = parseConflictBlocks(merged.merged);
|
|
315
|
-
if (blocks.length > 0) {
|
|
316
|
-
sessionFiles.push({
|
|
317
|
-
path: filePath,
|
|
318
|
-
merged: merged.merged,
|
|
319
|
-
blocks: blocks.map((block) => block.block),
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
// Remote-only files and field-merged conflict files apply immediately;
|
|
324
|
-
// divergent files get their diff3 markers written to disk.
|
|
325
|
-
const toWrite = new Map<string, string>();
|
|
326
|
-
for (const filePath of plan.takeRemote) {
|
|
327
|
-
toWrite.set(filePath, textFromSnapshot(remote, filePath));
|
|
328
|
-
}
|
|
329
|
-
for (const [filePath, mergedText] of conflictContents) {
|
|
330
|
-
if (!sessionFiles.some((file) => file.path === filePath)) {
|
|
331
|
-
toWrite.set(filePath, mergedText);
|
|
215
|
+
if (conflicted) {
|
|
216
|
+
const conflicts = await listConflictedPaths({ signal: ctx.signal });
|
|
217
|
+
let remoteRevision = "";
|
|
218
|
+
try {
|
|
219
|
+
remoteRevision = (await readRemoteRevision(config, { signal: ctx.signal })) ?? "";
|
|
220
|
+
} catch {
|
|
221
|
+
// ignore
|
|
332
222
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
for (const [filePath, content] of toWrite) {
|
|
338
|
-
await writeAgentContent(config, filePath, content);
|
|
339
|
-
}
|
|
340
|
-
const session: MergeSessionData = {
|
|
341
|
-
baselineRevision: remoteRevision,
|
|
342
|
-
backupDir: backup,
|
|
343
|
-
createdAt: new Date().toISOString(),
|
|
344
|
-
takeRemote: plan.takeRemote.length,
|
|
345
|
-
takeLocal: plan.takeLocal.length,
|
|
346
|
-
files: sessionFiles,
|
|
347
|
-
};
|
|
348
|
-
await saveMergeSession(session);
|
|
349
|
-
await refreshIndicator(ctx, config);
|
|
350
|
-
if (sessionFiles.length === 0) {
|
|
351
|
-
return finishMergeSession(ctx, config, session, plan.takeRemote.length, plan.takeLocal.length);
|
|
352
|
-
}
|
|
353
|
-
const result = await runBlockResolver(ctx.ui, session);
|
|
354
|
-
if (!result.completed) {
|
|
355
|
-
const pending = countPendingBlocks(session);
|
|
356
|
-
const message = `Merge in progress: ${pending} conflict block(s) left. /sync merge to continue, /sync merge --abort to discard.`;
|
|
223
|
+
const message = `Merge conflict in ${conflicts.length} file(s). Resolve the markers, then /sync merge. (remote ${shortId(
|
|
224
|
+
remoteRevision,
|
|
225
|
+
)})`;
|
|
357
226
|
ctx.ui.notify(message, "warning");
|
|
358
|
-
return {
|
|
359
|
-
pushed: false,
|
|
360
|
-
pulled: false,
|
|
361
|
-
merged: true,
|
|
362
|
-
message,
|
|
363
|
-
conflicts: sessionFiles.map((file) => file.path),
|
|
364
|
-
};
|
|
227
|
+
return { pushed: false, pulled: false, merged: true, message, conflicts };
|
|
365
228
|
}
|
|
366
|
-
return finishMergeSession(ctx, config, session, plan.takeRemote.length, plan.takeLocal.length);
|
|
367
|
-
}
|
|
368
229
|
|
|
369
|
-
/** Write the resolved files, record the applied snapshot, and clear the session. */
|
|
370
|
-
async function finishMergeSession(
|
|
371
|
-
ctx: CommandContext,
|
|
372
|
-
config: SyncConfig,
|
|
373
|
-
session: MergeSessionData,
|
|
374
|
-
remoteTaken: number,
|
|
375
|
-
localKept: number,
|
|
376
|
-
): Promise<SyncResult> {
|
|
377
|
-
for (const file of session.files) {
|
|
378
|
-
const content = applyResolutions(
|
|
379
|
-
file.merged,
|
|
380
|
-
file.blocks.map((block) => block.resolution),
|
|
381
|
-
);
|
|
382
|
-
await writeAgentContent(config, file.path, content);
|
|
383
|
-
}
|
|
384
|
-
const finalSnapshot = await createSnapshot(config);
|
|
385
|
-
await saveState({
|
|
386
|
-
version: 1,
|
|
387
|
-
lastAppliedSnapshot: snapshotSha256(finalSnapshot),
|
|
388
|
-
lastRemoteRevision: session.baselineRevision,
|
|
389
|
-
lastHashes: Object.fromEntries(finalSnapshot.files.map((file) => [file.path, file.sha256])),
|
|
390
|
-
});
|
|
391
|
-
await clearMergeSession();
|
|
392
230
|
await refreshIndicator(ctx, config);
|
|
393
|
-
const
|
|
394
|
-
const message = `
|
|
231
|
+
const remote = await readRemoteFiles(config, { signal: ctx.signal });
|
|
232
|
+
const message = `Pulled ${remote.size} file(s) from ${config.branch}.`;
|
|
395
233
|
ctx.ui.notify(message, "info");
|
|
396
|
-
return { pushed: false, pulled:
|
|
234
|
+
return { pushed: false, pulled: true, merged: false, message };
|
|
397
235
|
}
|
|
398
236
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async function writeAgentContent(
|
|
407
|
-
config: SyncConfig,
|
|
408
|
-
relativePath: string,
|
|
409
|
-
content: string,
|
|
410
|
-
): Promise<void> {
|
|
411
|
-
const target = resolveSnapshotTarget(relativePath, config);
|
|
412
|
-
if (!target) return;
|
|
413
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
414
|
-
await fs.writeFile(target, content);
|
|
237
|
+
/** Throw away the local agent side and adopt the remote tree (--force pull). */
|
|
238
|
+
async function forceAdoptRemote(config: SyncConfig, signal?: AbortSignal): Promise<void> {
|
|
239
|
+
await resetHard(config, { signal });
|
|
240
|
+
await copyMirrorToAgent(config);
|
|
415
241
|
}
|
|
416
242
|
|
|
417
243
|
export async function fetch(
|
|
@@ -420,21 +246,24 @@ export async function fetch(
|
|
|
420
246
|
options: { quiet?: boolean } = {},
|
|
421
247
|
): Promise<SyncResult> {
|
|
422
248
|
await fetchRemote(config, { signal: ctx.signal });
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
249
|
+
const { fresh, remoteExists } = await ensureBranch(config, { signal: ctx.signal });
|
|
250
|
+
const [local, remote] = await Promise.all([
|
|
251
|
+
readAgentContents(config),
|
|
252
|
+
remoteExists ? readRemoteFiles(config, { signal: ctx.signal }) : new Map<string, string>(),
|
|
427
253
|
]);
|
|
428
|
-
const projectedRemote = remote ? projectSnapshot(remote, config.include) : undefined;
|
|
429
254
|
await refreshIndicator(ctx, config);
|
|
430
|
-
if (!
|
|
255
|
+
if (!remoteExists || remote.size === 0) {
|
|
431
256
|
const message = "Remote is empty. Run /sync push to publish local content.";
|
|
432
257
|
if (!options.quiet) ctx.ui.notify(message, "info");
|
|
433
258
|
return { pushed: false, pulled: false, merged: false, message };
|
|
434
259
|
}
|
|
435
|
-
const summary = diffSummary(local,
|
|
436
|
-
const message = `Fetched ${
|
|
437
|
-
if (!options.quiet)
|
|
260
|
+
const summary = diffSummary(local, remote);
|
|
261
|
+
const message = `Fetched ${remote.size} files from ${config.branch}. ${describeChanges(summary)}.`;
|
|
262
|
+
if (!options.quiet) {
|
|
263
|
+
ctx.ui.notify(message, summary.identical ? "info" : "warning");
|
|
264
|
+
}
|
|
265
|
+
// On a fresh machine, fetch alone must not overwrite the agent.
|
|
266
|
+
void fresh;
|
|
438
267
|
return { pushed: false, pulled: false, merged: false, message };
|
|
439
268
|
}
|
|
440
269
|
|
|
@@ -444,92 +273,39 @@ export async function merge(
|
|
|
444
273
|
options: { abort?: boolean } = {},
|
|
445
274
|
): Promise<SyncResult> {
|
|
446
275
|
if (options.abort) {
|
|
447
|
-
|
|
448
|
-
if (!session) {
|
|
276
|
+
if (!(await isMergeInProgress({ signal: ctx.signal }))) {
|
|
449
277
|
const message = "No merge in progress to abort.";
|
|
450
278
|
ctx.ui.notify(message, "info");
|
|
451
279
|
return { pushed: false, pulled: false, merged: false, message };
|
|
452
280
|
}
|
|
453
|
-
await
|
|
454
|
-
await
|
|
281
|
+
await abortMerge({ signal: ctx.signal });
|
|
282
|
+
await copyMirrorToAgent(config);
|
|
455
283
|
await refreshIndicator(ctx, config);
|
|
456
|
-
const message = "Merge aborted; local files restored
|
|
284
|
+
const message = "Merge aborted; local files restored to the pre-merge state.";
|
|
457
285
|
ctx.ui.notify(message, "info");
|
|
458
286
|
return { pushed: false, pulled: false, merged: false, message };
|
|
459
287
|
}
|
|
460
|
-
|
|
461
|
-
if (!
|
|
288
|
+
|
|
289
|
+
if (!(await isMergeInProgress({ signal: ctx.signal }))) {
|
|
462
290
|
const message =
|
|
463
|
-
"No merge in progress. Run /sync pull
|
|
291
|
+
"No merge in progress. Run /sync pull to merge remote changes into local files.";
|
|
464
292
|
ctx.ui.notify(message, "info");
|
|
465
293
|
return { pushed: false, pulled: false, merged: false, message };
|
|
466
294
|
}
|
|
467
|
-
await refreshIndicator(ctx, config);
|
|
468
|
-
const result = await runBlockResolver(ctx.ui, session);
|
|
469
|
-
if (!result.completed) {
|
|
470
|
-
const pending = countPendingBlocks(session);
|
|
471
|
-
const message = `Merge in progress: ${pending} conflict block(s) left. /sync merge to continue, /sync merge --abort to discard.`;
|
|
472
|
-
ctx.ui.notify(message, "warning");
|
|
473
|
-
return {
|
|
474
|
-
pushed: false,
|
|
475
|
-
pulled: false,
|
|
476
|
-
merged: true,
|
|
477
|
-
message,
|
|
478
|
-
conflicts: session.files.map((file) => file.path),
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
return finishMergeSession(ctx, config, session, session.takeRemote, session.takeLocal);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/** Overwrite the agent dir's include paths with the pre-merge backup files. */
|
|
485
|
-
async function restoreBackup(backupDir: string, config: SyncConfig): Promise<void> {
|
|
486
|
-
let entries: Dirent[];
|
|
487
|
-
try {
|
|
488
|
-
entries = await fs.readdir(backupDir, { recursive: true, withFileTypes: true });
|
|
489
|
-
} catch (error) {
|
|
490
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
491
|
-
throw error;
|
|
492
|
-
}
|
|
493
|
-
for (const entry of entries) {
|
|
494
|
-
if (!entry.isFile()) continue;
|
|
495
|
-
const absPath = path.join(entry.parentPath ?? backupDir, entry.name);
|
|
496
|
-
const relative = path.relative(backupDir, absPath).split(path.sep).join("/");
|
|
497
|
-
const target = resolveSnapshotTarget(relative, config);
|
|
498
|
-
if (!target) continue;
|
|
499
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
500
|
-
await fs.copyFile(absPath, target);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
295
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (!entry) return undefined;
|
|
518
|
-
const root = syncRootPath(entry);
|
|
519
|
-
const suffix = relativePath.slice(entry.length);
|
|
520
|
-
return path.join(root, suffix);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
async function backupLocalFiles(local: Snapshot): Promise<string> {
|
|
524
|
-
const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
|
525
|
-
const directory = path.join(backupRootDir(), stamp);
|
|
526
|
-
await fs.mkdir(directory, { recursive: true });
|
|
527
|
-
for (const file of local.files) {
|
|
528
|
-
const target = path.join(directory, file.path);
|
|
529
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
530
|
-
await fs.writeFile(target, Buffer.from(file.contentBase64, "base64"));
|
|
531
|
-
}
|
|
532
|
-
return directory;
|
|
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 });
|
|
301
|
+
const completed = await completeMerge(LOCAL_COMMIT_MESSAGE, { signal: ctx.signal });
|
|
302
|
+
await copyMirrorToAgent(config);
|
|
303
|
+
await refreshIndicator(ctx, config);
|
|
304
|
+
const message = completed
|
|
305
|
+
? "Merge completed. Run /sync push to publish."
|
|
306
|
+
: "Merge has no further changes to record. Run /sync push to publish.";
|
|
307
|
+
ctx.ui.notify(message, "info");
|
|
308
|
+
return { pushed: false, pulled: false, merged: true, message };
|
|
533
309
|
}
|
|
534
310
|
|
|
535
311
|
function describeChanges(summary: ReturnType<typeof diffSummary>): string {
|
|
@@ -540,12 +316,4 @@ function describeChanges(summary: ReturnType<typeof diffSummary>): string {
|
|
|
540
316
|
return parts.length > 0 ? parts.join(", ") : "no differences";
|
|
541
317
|
}
|
|
542
318
|
|
|
543
|
-
function shortId(value: string): string {
|
|
544
|
-
return value.length > 10 ? value.slice(0, 10) : value;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function emptySnapshot(): Snapshot {
|
|
548
|
-
return { version: 1, createdAt: new Date().toISOString(), files: [] };
|
|
549
|
-
}
|
|
550
|
-
|
|
551
319
|
export { stateDir };
|