@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/git.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import type { SyncConfig } from "./config.js";
|
|
5
|
-
import { mirrorRepoDir,
|
|
6
|
-
import type { Snapshot } from "./snapshot.js";
|
|
5
|
+
import { mirrorRepoDir, stateDir } from "./config.js";
|
|
7
6
|
|
|
8
7
|
const GIT_TIMEOUT_MS = 60_000;
|
|
9
8
|
const COMMIT_IDENTITY = { name: "pi-sync", email: "pi-sync@local" };
|
|
@@ -113,16 +112,61 @@ export async function runGit(args: string[], options: GitRunOptions = {}): Promi
|
|
|
113
112
|
}
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
|
|
115
|
+
function gitCwd(): string {
|
|
116
|
+
return mirrorRepoDir();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Ensure the mirror repo exists, is initialized and knows the remote. */
|
|
117
120
|
export async function ensureMirror(config: SyncConfig): Promise<void> {
|
|
118
121
|
await fs.mkdir(stateDir(), { recursive: true });
|
|
119
122
|
const repo = mirrorRepoDir();
|
|
120
123
|
if (!(await pathExists(repo))) {
|
|
121
124
|
await fs.mkdir(repo, { recursive: true });
|
|
122
125
|
await runGit(["init", "-b", "main"], { cwd: repo });
|
|
126
|
+
}
|
|
127
|
+
// Never let git re-write line endings in the mirror; config content must
|
|
128
|
+
// round-trip byte-exactly between the agent dir and the remote.
|
|
129
|
+
await runGit(["config", "core.autocrlf", "false"], { cwd: repo });
|
|
130
|
+
await runGit(["config", "core.eol", "lf"], { cwd: repo });
|
|
131
|
+
await runGit(["config", "core.safecrlf", "false"], { cwd: repo });
|
|
132
|
+
await runGit(["remote", "set-url", "origin", config.remote], { cwd: repo }).catch(async () => {
|
|
123
133
|
await runGit(["remote", "add", "origin", config.remote], { cwd: repo });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Ensure the mirror work tree is checked out on the configured sync branch.
|
|
139
|
+
* When the branch does not exist locally but exists on the remote, the local
|
|
140
|
+
* branch is created tracking it (so a fresh machine starts equal to the remote
|
|
141
|
+
* — no false conflict). Otherwise the local branch starts empty. The remote is
|
|
142
|
+
* fetched first so origin/<branch> is up to date.
|
|
143
|
+
*
|
|
144
|
+
* Returns `fresh`=true when a local branch was just created (either tracking an
|
|
145
|
+
* existing remote branch, or an empty orphan). This signals the first sync on
|
|
146
|
+
* this machine, so a pull adopts the remote (clean checkout) instead of doing a
|
|
147
|
+
* three-way merge against an implicitly-empty base.
|
|
148
|
+
*/
|
|
149
|
+
export async function ensureBranch(
|
|
150
|
+
config: SyncConfig,
|
|
151
|
+
options: GitRunOptions = {},
|
|
152
|
+
): Promise<{ fresh: boolean; remoteExists: boolean }> {
|
|
153
|
+
await ensureMirror(config);
|
|
154
|
+
await fetchRemote(config, options);
|
|
155
|
+
const repo = gitCwd();
|
|
156
|
+
const remoteRef = `refs/remotes/origin/${config.branch}`;
|
|
157
|
+
const remoteExists = await refExists(remoteRef, options);
|
|
158
|
+
if (!(await branchExists(config.branch))) {
|
|
159
|
+
if (remoteExists) {
|
|
160
|
+
await runGit(["checkout", "-B", config.branch, "-t", remoteRef], {
|
|
161
|
+
cwd: repo,
|
|
162
|
+
signal: options.signal,
|
|
163
|
+
});
|
|
164
|
+
return { fresh: true, remoteExists };
|
|
165
|
+
}
|
|
166
|
+
await runGit(["checkout", "--orphan", config.branch], { cwd: repo, signal: options.signal });
|
|
167
|
+
return { fresh: true, remoteExists };
|
|
124
168
|
}
|
|
125
|
-
|
|
169
|
+
return { fresh: false, remoteExists };
|
|
126
170
|
}
|
|
127
171
|
|
|
128
172
|
/** Fetch the configured branch from the remote into origin/<branch>. */
|
|
@@ -130,7 +174,7 @@ export async function fetchRemote(config: SyncConfig, options: GitRunOptions = {
|
|
|
130
174
|
await ensureMirror(config);
|
|
131
175
|
try {
|
|
132
176
|
await runGit(["fetch", "--quiet", "origin", config.branch], {
|
|
133
|
-
cwd:
|
|
177
|
+
cwd: gitCwd(),
|
|
134
178
|
signal: options.signal,
|
|
135
179
|
timeoutMs: options.timeoutMs,
|
|
136
180
|
});
|
|
@@ -143,142 +187,236 @@ export async function fetchRemote(config: SyncConfig, options: GitRunOptions = {
|
|
|
143
187
|
}
|
|
144
188
|
}
|
|
145
189
|
|
|
146
|
-
/**
|
|
147
|
-
export async function
|
|
190
|
+
/** Remote revision (commit sha) for the branch, or undefined when absent. */
|
|
191
|
+
export async function readRemoteRevision(
|
|
148
192
|
config: SyncConfig,
|
|
149
193
|
options: GitRunOptions = {},
|
|
150
|
-
): Promise<
|
|
151
|
-
|
|
152
|
-
if (!(await pathExists(repo))) return undefined;
|
|
153
|
-
const ref = `refs/remotes/origin/${config.branch}`;
|
|
194
|
+
): Promise<string | undefined> {
|
|
195
|
+
if (!(await pathExists(mirrorRepoDir()))) return undefined;
|
|
154
196
|
try {
|
|
155
|
-
const result = await runGit(["
|
|
156
|
-
cwd:
|
|
197
|
+
const result = await runGit(["rev-parse", `refs/remotes/origin/${config.branch}`], {
|
|
198
|
+
cwd: gitCwd(),
|
|
157
199
|
signal: options.signal,
|
|
158
200
|
timeoutMs: options.timeoutMs,
|
|
159
201
|
});
|
|
160
|
-
return
|
|
202
|
+
return result.stdout.trim();
|
|
161
203
|
} catch (error) {
|
|
162
204
|
if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return undefined;
|
|
163
205
|
throw error;
|
|
164
206
|
}
|
|
165
207
|
}
|
|
166
208
|
|
|
167
|
-
/** Read the
|
|
168
|
-
export async function
|
|
169
|
-
|
|
209
|
+
/** Read the file tree of the remote branch as a path→content map. */
|
|
210
|
+
export async function readRemoteFiles(
|
|
211
|
+
config: SyncConfig,
|
|
170
212
|
options: GitRunOptions = {},
|
|
171
|
-
): Promise<
|
|
213
|
+
): Promise<Map<string, string>> {
|
|
214
|
+
return readFilesAt(`refs/remotes/origin/${config.branch}`, options);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Read the file tree at a specific ref as a path→content map. */
|
|
218
|
+
async function readFilesAt(ref: string, options: GitRunOptions = {}): Promise<Map<string, string>> {
|
|
219
|
+
const repo = gitCwd();
|
|
220
|
+
if (!(await pathExists(repo))) return new Map();
|
|
172
221
|
try {
|
|
173
|
-
const result = await runGit(["
|
|
174
|
-
cwd:
|
|
222
|
+
const result = await runGit(["ls-tree", "-r", "--name-only", ref], {
|
|
223
|
+
cwd: repo,
|
|
175
224
|
signal: options.signal,
|
|
176
225
|
timeoutMs: options.timeoutMs,
|
|
177
226
|
});
|
|
178
|
-
|
|
227
|
+
const entries = result.stdout.split("\n").filter(Boolean);
|
|
228
|
+
const map = new Map<string, string>();
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
try {
|
|
231
|
+
const out = await runGit(["show", `${ref}:${entry}`], {
|
|
232
|
+
cwd: repo,
|
|
233
|
+
signal: options.signal,
|
|
234
|
+
timeoutMs: options.timeoutMs,
|
|
235
|
+
});
|
|
236
|
+
map.set(entry, out.stdout);
|
|
237
|
+
} catch {
|
|
238
|
+
// Binary or unreadable path — skip for the diff view.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return map;
|
|
179
242
|
} catch (error) {
|
|
180
|
-
if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return
|
|
243
|
+
if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return new Map();
|
|
181
244
|
throw error;
|
|
182
245
|
}
|
|
183
246
|
}
|
|
184
247
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Read the content map at the merge-base of the local branch and the remote
|
|
250
|
+
* branch; empty when no shared ancestor exists (fresh branch).
|
|
251
|
+
*/
|
|
252
|
+
export async function readMergeBase(
|
|
187
253
|
config: SyncConfig,
|
|
188
254
|
options: GitRunOptions = {},
|
|
189
|
-
): Promise<string
|
|
190
|
-
|
|
191
|
-
const
|
|
255
|
+
): Promise<Map<string, string>> {
|
|
256
|
+
const repo = gitCwd();
|
|
257
|
+
const remoteRef = `refs/remotes/origin/${config.branch}`;
|
|
258
|
+
if (!(await pathExists(repo)) || !(await refExists(remoteRef, options))) return new Map();
|
|
259
|
+
let base: string;
|
|
192
260
|
try {
|
|
193
|
-
const result = await runGit(["
|
|
194
|
-
cwd:
|
|
261
|
+
const result = await runGit(["merge-base", config.branch, remoteRef], {
|
|
262
|
+
cwd: repo,
|
|
195
263
|
signal: options.signal,
|
|
196
264
|
timeoutMs: options.timeoutMs,
|
|
197
265
|
});
|
|
198
|
-
|
|
199
|
-
} catch
|
|
200
|
-
|
|
201
|
-
throw error;
|
|
266
|
+
base = result.stdout.trim();
|
|
267
|
+
} catch {
|
|
268
|
+
return new Map();
|
|
202
269
|
}
|
|
270
|
+
if (!base) return new Map();
|
|
271
|
+
return readFilesAt(base, options);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Stage all changes (after grafting the local side) in the mirror work tree. */
|
|
275
|
+
export async function stageAll(options: GitRunOptions = {}): Promise<void> {
|
|
276
|
+
await runGit(["add", "-A"], { cwd: gitCwd(), signal: options.signal });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Commit the staged changes; returns false when nothing was staged. */
|
|
280
|
+
export async function commitSync(message: string, options: GitRunOptions = {}): Promise<boolean> {
|
|
281
|
+
const repo = gitCwd();
|
|
282
|
+
const hasStaged = await hasStagedChanges(options);
|
|
283
|
+
if (!hasStaged) return false;
|
|
284
|
+
await runGit(["commit", "--quiet", "-m", message], { cwd: repo, signal: options.signal });
|
|
285
|
+
return true;
|
|
203
286
|
}
|
|
204
287
|
|
|
205
|
-
/**
|
|
206
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Publish the current local branch tip to the remote branch. Requires the
|
|
290
|
+
* local branch to be checked out; the caller commits the local side first.
|
|
291
|
+
* When `force` is false a non-fast-forward push is rejected by git.
|
|
292
|
+
*/
|
|
293
|
+
export async function pushBranch(
|
|
207
294
|
config: SyncConfig,
|
|
208
|
-
snapshot: Snapshot,
|
|
209
295
|
options: GitRunOptions = {},
|
|
210
296
|
force = false,
|
|
211
|
-
): Promise<
|
|
212
|
-
const repo =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
297
|
+
): Promise<void> {
|
|
298
|
+
const repo = gitCwd();
|
|
299
|
+
await runGit(
|
|
300
|
+
["push", "--quiet", ...(force ? ["--force"] : []), "origin", `HEAD:${config.branch}`],
|
|
301
|
+
{ cwd: repo, signal: options.signal, timeoutMs: options.timeoutMs },
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Merge origin/<branch> into the current branch (real three-way merge with
|
|
307
|
+
* merge-base from history). On conflict, git writes diff3/merge markers into
|
|
308
|
+
* the work tree files and leaves MERGE_HEAD set. Returns whether a real merge
|
|
309
|
+
* conflict is currently in progress (MERGE_HEAD set by git).
|
|
310
|
+
*/
|
|
311
|
+
export async function mergeRemote(
|
|
312
|
+
config: SyncConfig,
|
|
313
|
+
options: GitRunOptions = {},
|
|
314
|
+
): Promise<boolean> {
|
|
315
|
+
const repo = gitCwd();
|
|
216
316
|
try {
|
|
217
|
-
await runGit(["
|
|
317
|
+
await runGit(["merge", "--quiet", "--no-edit", `origin/${config.branch}`], {
|
|
218
318
|
cwd: repo,
|
|
219
319
|
signal: options.signal,
|
|
320
|
+
timeoutMs: options.timeoutMs,
|
|
220
321
|
});
|
|
221
|
-
|
|
222
|
-
|
|
322
|
+
return false;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// git merge exits non-zero on conflicts; MERGE_HEAD marks the conflict.
|
|
325
|
+
if (error instanceof GitCommandError) {
|
|
326
|
+
const mergeHead = await pathExists(path.join(repo, ".git", "MERGE_HEAD"));
|
|
327
|
+
if (mergeHead) return true;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
throw error;
|
|
223
331
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
await
|
|
229
|
-
|
|
230
|
-
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** List paths with unmerged (conflicted) entries after a failed merge. */
|
|
335
|
+
export async function listConflictedPaths(options: GitRunOptions = {}): Promise<string[]> {
|
|
336
|
+
const result = await runGit(["diff", "--name-only", "--diff-filter=U"], {
|
|
337
|
+
cwd: gitCwd(),
|
|
338
|
+
signal: options.signal,
|
|
231
339
|
});
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
340
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Complete an in-progress merge: stage the resolved work tree and commit. */
|
|
344
|
+
export async function completeMerge(
|
|
345
|
+
message: string,
|
|
346
|
+
options: GitRunOptions = {},
|
|
347
|
+
): Promise<boolean> {
|
|
348
|
+
await stageAll(options);
|
|
349
|
+
return commitSync(message, options);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Abort an in-progress merge, restoring the work tree to the pre-merge state. */
|
|
353
|
+
export async function abortMerge(options: GitRunOptions = {}): Promise<void> {
|
|
354
|
+
await runGit(["merge", "--abort"], { cwd: gitCwd(), signal: options.signal });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Reset the work tree to the remote tip (used by pull --force). */
|
|
358
|
+
export async function resetHard(config: SyncConfig, options: GitRunOptions = {}): Promise<void> {
|
|
359
|
+
await runGit(["reset", "--hard", `refs/remotes/origin/${config.branch}`], {
|
|
360
|
+
cwd: gitCwd(),
|
|
235
361
|
signal: options.signal,
|
|
236
362
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const result = await runGit(["rev-parse", "HEAD"], {
|
|
248
|
-
cwd: repo,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** True when git has an unmerged (conflicted) merge in progress. */
|
|
366
|
+
export async function isMergeInProgress(_options: GitRunOptions = {}): Promise<boolean> {
|
|
367
|
+
return pathExists(path.join(gitCwd(), ".git", "MERGE_HEAD"));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function hasStagedChanges(options: GitRunOptions = {}): Promise<boolean> {
|
|
371
|
+
const result = await runGit(["diff", "--cached", "--name-only"], {
|
|
372
|
+
cwd: gitCwd(),
|
|
249
373
|
signal: options.signal,
|
|
250
374
|
});
|
|
251
|
-
return result.stdout.
|
|
375
|
+
return result.stdout.length > 0;
|
|
252
376
|
}
|
|
253
377
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
378
|
+
/**
|
|
379
|
+
* Commit-position summary of the local branch tip vs origin/<branch>.
|
|
380
|
+
* ahead = commits on the local branch not on the remote; behind = commits on
|
|
381
|
+
* the remote not on the local branch. A diverged state is ahead>0 && behind>0.
|
|
382
|
+
*/
|
|
383
|
+
export async function aheadBehind(
|
|
384
|
+
config: SyncConfig,
|
|
256
385
|
options: GitRunOptions = {},
|
|
257
|
-
): Promise<
|
|
386
|
+
): Promise<{ ahead: number; behind: number }> {
|
|
387
|
+
const repo = gitCwd();
|
|
388
|
+
const remoteRef = `refs/remotes/origin/${config.branch}`;
|
|
389
|
+
if (!(await refExists(remoteRef, options))) return { ahead: 0, behind: 0 };
|
|
258
390
|
try {
|
|
259
391
|
const result = await runGit(
|
|
260
|
-
["
|
|
261
|
-
{ cwd:
|
|
392
|
+
["rev-list", "--left-right", "--count", `${config.branch}...${remoteRef}`],
|
|
393
|
+
{ cwd: repo, signal: options.signal, timeoutMs: options.timeoutMs },
|
|
262
394
|
);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
.split("\n")
|
|
266
|
-
.filter(Boolean)
|
|
267
|
-
.map((line) => {
|
|
268
|
-
const [id, date, ...messageParts] = line.split("\u0000");
|
|
269
|
-
return { id: id ?? "", date: date ?? "", message: messageParts.join("\u0000") };
|
|
270
|
-
});
|
|
395
|
+
const [left, right] = result.stdout.trim().split(/\s+/u).map(Number) ?? [0, 0];
|
|
396
|
+
return { ahead: left ?? 0, behind: right ?? 0 };
|
|
271
397
|
} catch (error) {
|
|
272
|
-
if (error instanceof GitCommandError && isMissingRefError(error.stderr))
|
|
398
|
+
if (error instanceof GitCommandError && isMissingRefError(error.stderr)) {
|
|
399
|
+
return { ahead: 0, behind: 0 };
|
|
400
|
+
}
|
|
273
401
|
throw error;
|
|
274
402
|
}
|
|
275
403
|
}
|
|
276
404
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
405
|
+
async function branchExists(name: string): Promise<boolean> {
|
|
406
|
+
const result = await runGit(["branch", "--list", name], { cwd: gitCwd() });
|
|
407
|
+
return result.stdout.trim().length > 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function refExists(ref: string, options: GitRunOptions = {}): Promise<boolean> {
|
|
411
|
+
try {
|
|
412
|
+
await runGit(["rev-parse", "--verify", ref], {
|
|
413
|
+
cwd: gitCwd(),
|
|
414
|
+
signal: options.signal,
|
|
415
|
+
});
|
|
416
|
+
return true;
|
|
417
|
+
} catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
282
420
|
}
|
|
283
421
|
|
|
284
422
|
async function pathExists(filePath: string): Promise<boolean> {
|
|
@@ -297,12 +435,7 @@ function isMissingRefError(stderr: string): boolean {
|
|
|
297
435
|
stderr.includes("bad revision") ||
|
|
298
436
|
stderr.includes("not a valid object name") ||
|
|
299
437
|
stderr.includes("invalid object name") ||
|
|
300
|
-
stderr.includes("does not exist in")
|
|
301
|
-
// git resolves <ref>:<path> by checking the working tree too: when the
|
|
302
|
-
// path is absent from the ref but a same-named file exists on disk it
|
|
303
|
-
// reports "exists on disk, but not in '<ref>'". Both mean the ref has
|
|
304
|
-
// no snapshot file.
|
|
305
|
-
stderr.includes("exists on disk, but not in")
|
|
438
|
+
stderr.includes("does not exist in")
|
|
306
439
|
);
|
|
307
440
|
}
|
|
308
441
|
|