@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/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, snapshotFilePath, stateDir } from "./config.js";
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
- /** Ensure the local mirror repository exists, is initialized, and knows the remote. */
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
- await runGit(["remote", "set-url", "origin", config.remote], { cwd: repo });
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: mirrorRepoDir(),
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
- /** Read the remote snapshot for the branch; returns undefined when the branch has no snapshot. */
147
- export async function readRemoteSnapshot(
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<Snapshot | undefined> {
151
- const repo = mirrorRepoDir();
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(["show", `${ref}:pi-sync/snapshot.json`], {
156
- cwd: repo,
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 JSON.parse(result.stdout) as Snapshot;
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 snapshot stored at a specific revision; undefined when absent. */
168
- export async function readSnapshotAt(
169
- revision: string,
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<Snapshot | undefined> {
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(["show", `${revision}:pi-sync/snapshot.json`], {
174
- cwd: mirrorRepoDir(),
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
- return JSON.parse(result.stdout) as Snapshot;
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 undefined;
243
+ if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return new Map();
181
244
  throw error;
182
245
  }
183
246
  }
184
247
 
185
- /** Remote revision (commit sha) for the branch, or undefined when the branch is absent. */
186
- export async function readRemoteRevision(
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 | undefined> {
190
- if (!(await pathExists(mirrorRepoDir()))) return undefined;
191
- const ref = `refs/remotes/origin/${config.branch}`;
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(["rev-parse", ref], {
194
- cwd: mirrorRepoDir(),
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
- return result.stdout.trim();
199
- } catch (error) {
200
- if (error instanceof GitCommandError && isMissingRefError(error.stderr)) return undefined;
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
- /** Publish a snapshot to the remote branch as one commit. */
206
- export async function publishSnapshot(
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<string> {
212
- const repo = mirrorRepoDir();
213
- // The mirror is disposable (it only tracks pi-sync/snapshot.json). Advance
214
- // it to the fetched remote tip so the publish push fast-forwards; the
215
- // caller has already decided it is safe to publish. Missing ref = first push.
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(["reset", "--hard", `refs/remotes/origin/${config.branch}`], {
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
- } catch {
222
- // Remote branch does not exist yet; publish from the empty HEAD.
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
- // The mirror tracks only pi-sync/snapshot.json. The reset aligned the
225
- // index to the remote tip, which may carry legacy or foreign paths;
226
- // empty the index so the published commit never drags them along.
227
- await runGit(["read-tree", "--empty"], { cwd: repo, signal: options.signal });
228
- await fs.mkdir(path.dirname(snapshotFilePath()), { recursive: true });
229
- await fs.writeFile(snapshotFilePath(), `${JSON.stringify(snapshot, null, "\t")}\n`, {
230
- mode: 0o600,
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
- await runGit(["add", "--", snapshotFilePath()], { cwd: repo, signal: options.signal });
233
- await runGit(["commit", "--quiet", "-m", `pi-sync: ${snapshot.files.length} files`], {
234
- cwd: repo,
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
- await runGit(
238
- ["push", "--quiet", ...(force ? ["--force"] : []), "origin", `HEAD:${config.branch}`],
239
- {
240
- cwd: repo,
241
- signal: options.signal,
242
- timeoutMs: options.timeoutMs,
243
- },
244
- );
245
- // The pushed revision is the commit we just created on HEAD; the
246
- // remote-tracking ref is only refreshed by fetch, so read HEAD directly.
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.trim();
375
+ return result.stdout.length > 0;
252
376
  }
253
377
 
254
- /** List recent snapshot commits on the remote branch (newest first). */
255
- export async function listHistory(
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<Array<{ id: string; date: string; message: string }>> {
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
- ["log", "--format=%H%x00%cI%x00%s", "-n", "20", "--", "pi-sync/snapshot.json"],
261
- { cwd: mirrorRepoDir(), signal: options.signal, timeoutMs: options.timeoutMs },
392
+ ["rev-list", "--left-right", "--count", `${config.branch}...${remoteRef}`],
393
+ { cwd: repo, signal: options.signal, timeoutMs: options.timeoutMs },
262
394
  );
263
- return result.stdout
264
- .trim()
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)) return [];
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
- export function isRemoteUpToDate(
278
- localRevision: string | undefined,
279
- remoteRevision: string | undefined,
280
- ): boolean {
281
- return localRevision !== undefined && localRevision === remoteRevision;
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