@ferrule-io/ok-fine 0.3.8 → 0.3.10

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.
@@ -10,6 +10,48 @@ import { Mutex } from "./mutex.js";
10
10
  import { PathIndex } from "./path-index.js";
11
11
  const REMOTE_BASE_REF = "refs/ok-fine/remote-base";
12
12
  const REMOTE_REWRITTEN_MSG = "remote history was rewritten; syncing halted. Stop ok-fine, remove DATA_DIR/repo, and restart to re-clone";
13
+ // One ref per (project, conflict): refs/heads/ok-fine/conflict/<project>/<id>. The commit stays reachable while
14
+ // any project's ref remains, so resolving one project never drops another project's preserved writes.
15
+ const CONFLICT_REF_PREFIX = "refs/heads/ok-fine/conflict/";
16
+ // Pre-split format: one branch for all projects. Migrated to per-project refs on open.
17
+ const LEGACY_CONFLICT_REF_PREFIX = "refs/heads/ok-fine/conflict-";
18
+ // Resolved conflicts whose remote branch still has to be deleted.
19
+ const RESOLVED_REF_PREFIX = "refs/ok-fine/resolved/";
20
+ // Holds preserved commits that touch no project directory, so they stay reachable.
21
+ const REPOSITORY_CONFLICT_SCOPE = "_repository";
22
+ /** `<UTC stamp>-<12 hex of the preserved tip>`; legacy stamps have no milliseconds. */
23
+ const CONFLICT_ID_RE = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\.\d{3})?Z-[0-9a-f]{12}$/;
24
+ const HISTORY_FORMAT = "--format=%H%x1f%aI%x1f%an%x1f%s%x1f%(trailers:key=Okf-Principal,valueonly)%x1e";
25
+ function conflictRef(project, id) {
26
+ return `${CONFLICT_REF_PREFIX}${project}/${id}`;
27
+ }
28
+ function conflictDetectedAt(id) {
29
+ const m = CONFLICT_ID_RE.exec(id);
30
+ if (!m)
31
+ return "";
32
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[7] ?? ""}Z`;
33
+ }
34
+ /** Server-generated files: rebuilt after every sync, so a conflict on them never needs a client merge. */
35
+ function isGeneratedPath(path) {
36
+ return path === "log.md" || path === "index.md" || path.endsWith("/index.md");
37
+ }
38
+ function parseHistory(stdout) {
39
+ const commits = [];
40
+ for (const entry of stdout.split("\x1e")) {
41
+ if (entry.trim().length === 0)
42
+ continue;
43
+ const parts = entry.trim().split("\x1f");
44
+ const principalRaw = parts[4]?.trim();
45
+ commits.push({
46
+ sha: parts[0] ?? "",
47
+ at: parts[1] ?? "",
48
+ actor: parts[2] ?? "",
49
+ subject: parts[3] ?? "",
50
+ principal: principalRaw && principalRaw.length > 0 ? principalRaw : null,
51
+ });
52
+ }
53
+ return commits;
54
+ }
13
55
  function extractChangedProjects(diffOutput) {
14
56
  const lines = diffOutput
15
57
  .split(/\r?\n/)
@@ -29,6 +71,8 @@ class GitTx {
29
71
  repoDir;
30
72
  index;
31
73
  touched = false;
74
+ /** Applied by GitBackend.transaction only after the transaction succeeds. */
75
+ resolutions = [];
32
76
  constructor(repoDir, index) {
33
77
  this.repoDir = repoDir;
34
78
  this.index = index;
@@ -107,6 +151,11 @@ class GitTx {
107
151
  this.index.setProject(project, written);
108
152
  this.touched = true;
109
153
  }
154
+ async resolveConflict(project, id) {
155
+ if (!this.resolutions.some((r) => r.project === project && r.id === id)) {
156
+ this.resolutions.push({ project, id });
157
+ }
158
+ }
110
159
  }
111
160
  export class GitBackend {
112
161
  config;
@@ -119,6 +168,8 @@ export class GitBackend {
119
168
  resyncHandler = null;
120
169
  lastSyncAt = null;
121
170
  lastError = null;
171
+ /** project -> conflict id -> preserved tip. Only this class creates or deletes conflict refs. */
172
+ conflictRefs = new Map();
122
173
  constructor(config, log, repoDir, homeDir) {
123
174
  this.config = config;
124
175
  this.log = log;
@@ -303,6 +354,8 @@ export class GitBackend {
303
354
  }
304
355
  }
305
356
  }
357
+ await repo.migrateLegacyConflicts();
358
+ await repo.loadConflictRefs();
306
359
  await repo.reindexAll();
307
360
  return repo;
308
361
  }
@@ -419,30 +472,187 @@ export class GitBackend {
419
472
  const rev = await this.git.run(["rev-parse", "HEAD"]);
420
473
  return rev.stdout.trim();
421
474
  }
475
+ async mergeBase(a, b) {
476
+ const res = await this.git.run(["merge-base", a, b], { allowFail: true });
477
+ return res.code === 0 ? res.stdout.trim() : null;
478
+ }
479
+ /** Projects whose directories `tip` changed since its merge-base with `against`. */
480
+ async projectsChangedSince(tip, against) {
481
+ const base = await this.mergeBase(tip, against);
482
+ const res = base
483
+ ? await this.git.run(["diff", "--name-only", "--no-renames", "-z", base, tip])
484
+ : await this.git.run(["ls-tree", "-r", "--name-only", "-z", tip]);
485
+ const projects = new Set();
486
+ for (const path of res.stdout.split("\0")) {
487
+ const slash = path.indexOf("/");
488
+ if (slash > 0 && PROJECT_RE.test(path.slice(0, slash)))
489
+ projects.add(path.slice(0, slash));
490
+ }
491
+ return [...projects].sort();
492
+ }
422
493
  /**
423
- * Aborts a failed rebase, keeps the original local commits on `ok-fine/conflict-<stamp>` (always as a
424
- * local branch, plus on the remote when the push succeeds), then resets to the remote branch.
425
- * Returns a human-readable description of where the commits were preserved.
494
+ * Aborts a failed rebase and keeps HEAD as one conflict per touched project (local refs, plus remote branches
495
+ * when the push succeeds), then resets to the remote branch. Never overwrites an existing conflict.
426
496
  */
427
497
  async preserveConflict() {
428
498
  await this.git.run(["rebase", "--abort"], { allowFail: true });
429
- const stamp = new Date()
430
- .toISOString()
431
- .replace(/[-:]/g, "")
432
- .replace(/\.\d{3}/, "")
433
- .replace(/Z$/, "Z");
434
- const conflictBranch = `ok-fine/conflict-${stamp}`;
435
- // The local branch keeps the commits reachable on the PVC even if the remote push fails.
436
- await this.git.run(["branch", "-f", conflictBranch, "HEAD"]);
437
- const pushRes = await this.git.run(["push", "origin", `refs/heads/${conflictBranch}:refs/heads/${conflictBranch}`], { allowFail: true });
438
- await this.git.run(["reset", "--hard", `origin/${this.config.gitBranch}`]);
439
- let message = `local commits preserved on ${conflictBranch}`;
499
+ const upstream = `origin/${this.config.gitBranch}`;
500
+ const tip = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
501
+ const projects = await this.projectsChangedSince(tip, upstream);
502
+ if (projects.length === 0)
503
+ projects.push(REPOSITORY_CONFLICT_SCOPE);
504
+ // Millisecond stamp plus tip prefix; update-ref with an empty old value fails rather than overwrite.
505
+ const id = `${new Date().toISOString().replace(/[-:]/g, "")}-${tip.slice(0, 12)}`;
506
+ const conflicts = projects.map((project) => ({ project, id }));
507
+ for (const { project } of conflicts) {
508
+ await this.git.run(["update-ref", conflictRef(project, id), tip, ""]);
509
+ let byId = this.conflictRefs.get(project);
510
+ if (!byId) {
511
+ byId = new Map();
512
+ this.conflictRefs.set(project, byId);
513
+ }
514
+ byId.set(id, tip);
515
+ }
516
+ const pushRes = await this.git.run(["push", "origin", ...conflicts.map(({ project }) => `${conflictRef(project, id)}:${conflictRef(project, id)}`)], { allowFail: true });
517
+ await this.git.run(["reset", "--hard", upstream]);
518
+ let message = `local commits preserved as conflict ${id} in ${projects.join(", ")}`;
440
519
  if (pushRes.code !== 0) {
441
520
  const reason = pushRes.stderr.trim().split("\n")[0] ?? "";
442
- message += ` (local branch only; pushing it failed: ${reason})`;
521
+ message += ` (kept locally only; pushing the conflict branches failed: ${reason})`;
443
522
  }
444
523
  this.log.error({ stderr: pushRes.code !== 0 ? pushRes.stderr : undefined }, `rebase conflict; ${message}`);
445
- return message;
524
+ return { message, conflicts };
525
+ }
526
+ /** Splits single-branch `ok-fine/conflict-<stamp>` conflicts into per-project refs. Remote copies are kept. */
527
+ async migrateLegacyConflicts() {
528
+ const res = await this.git.run([
529
+ "for-each-ref",
530
+ "--format=%(objectname) %(refname)",
531
+ `${LEGACY_CONFLICT_REF_PREFIX}*`,
532
+ ]);
533
+ for (const line of res.stdout.split("\n")) {
534
+ const [tip, ref] = line.split(" ");
535
+ if (!tip || !ref)
536
+ continue;
537
+ const id = `${ref.slice(LEGACY_CONFLICT_REF_PREFIX.length)}-${tip.slice(0, 12)}`;
538
+ if (!CONFLICT_ID_RE.test(id)) {
539
+ this.log.warn({ ref }, "leaving unrecognized legacy conflict branch in place");
540
+ continue;
541
+ }
542
+ const projects = await this.projectsChangedSince(tip, "HEAD");
543
+ if (projects.length === 0)
544
+ projects.push(REPOSITORY_CONFLICT_SCOPE);
545
+ for (const project of projects) {
546
+ const target = conflictRef(project, id);
547
+ const exists = await this.git.run(["rev-parse", "--verify", "--quiet", target], { allowFail: true });
548
+ if (exists.code !== 0)
549
+ await this.git.run(["update-ref", target, tip, ""]);
550
+ }
551
+ await this.git.run(["update-ref", "-d", ref, tip]);
552
+ }
553
+ }
554
+ async loadConflictRefs() {
555
+ this.conflictRefs.clear();
556
+ const res = await this.git.run(["for-each-ref", "--format=%(objectname) %(refname)", CONFLICT_REF_PREFIX]);
557
+ for (const line of res.stdout.split("\n")) {
558
+ const [tip, ref] = line.split(" ");
559
+ if (!tip || !ref)
560
+ continue;
561
+ const [project, id, ...rest] = ref.slice(CONFLICT_REF_PREFIX.length).split("/");
562
+ if (!project || !id || rest.length > 0 || !CONFLICT_ID_RE.test(id))
563
+ continue;
564
+ let byId = this.conflictRefs.get(project);
565
+ if (!byId) {
566
+ byId = new Map();
567
+ this.conflictRefs.set(project, byId);
568
+ }
569
+ byId.set(id, tip);
570
+ }
571
+ }
572
+ /** Drops resolved conflicts locally; with a remote, keeps a marker until the remote branch is deleted too. */
573
+ async applyResolutions(resolutions) {
574
+ for (const { project, id } of resolutions) {
575
+ const byId = this.conflictRefs.get(project);
576
+ const tip = byId?.get(id);
577
+ if (!byId || !tip)
578
+ continue;
579
+ if (this.hasRemote)
580
+ await this.git.run(["update-ref", `${RESOLVED_REF_PREFIX}${project}/${id}`, tip]);
581
+ await this.git.run(["update-ref", "-d", conflictRef(project, id), tip]);
582
+ byId.delete(id);
583
+ }
584
+ }
585
+ /** Deletes remote branches of resolved conflicts; returns one message per deletion still pending. */
586
+ async pushResolutions() {
587
+ const res = await this.git.run(["for-each-ref", "--format=%(refname)", RESOLVED_REF_PREFIX]);
588
+ const pending = [];
589
+ for (const marker of res.stdout.split("\n")) {
590
+ if (!marker)
591
+ continue;
592
+ const name = marker.slice(RESOLVED_REF_PREFIX.length);
593
+ const del = await this.git.run(["push", "origin", `:${CONFLICT_REF_PREFIX}${name}`], { allowFail: true });
594
+ // Never pushed (the preserving push failed) counts as deleted.
595
+ if (del.code === 0 || del.stderr.includes("remote ref does not exist")) {
596
+ await this.git.run(["update-ref", "-d", marker]);
597
+ }
598
+ else {
599
+ const reason = del.stderr.trim().split("\n")[0] ?? "";
600
+ pending.push(`conflict ${name} resolved; deleting its remote branch failed, will retry on next sync: ${reason}`);
601
+ }
602
+ }
603
+ return pending;
604
+ }
605
+ async conflicts(project) {
606
+ const byId = this.conflictRefs.get(project);
607
+ if (!byId || byId.size === 0)
608
+ return [];
609
+ const scope = `${project}/`;
610
+ const out = [];
611
+ for (const id of [...byId.keys()].sort()) {
612
+ const tip = byId.get(id);
613
+ if (!tip)
614
+ continue;
615
+ const base = await this.mergeBase(tip, "HEAD");
616
+ const files = [];
617
+ if (base) {
618
+ const current = await this.git.run(["diff", "--name-only", "--no-renames", "-z", base, "HEAD", "--", scope]);
619
+ const changedHere = new Set(current.stdout.split("\0"));
620
+ const preserved = await this.git.run(["diff", "--name-status", "--no-renames", "-z", base, tip, "--", scope]);
621
+ // -z name-status output alternates status and path.
622
+ const fields = preserved.stdout.split("\0");
623
+ for (let i = 0; i + 1 < fields.length; i += 2) {
624
+ const status = fields[i] ?? "";
625
+ const full = fields[i + 1] ?? "";
626
+ const path = full.slice(scope.length);
627
+ if (isGeneratedPath(path))
628
+ continue;
629
+ const change = status === "A" ? "added" : status === "D" ? "deleted" : "modified";
630
+ files.push({ path, change, divergent: changedHere.has(full) });
631
+ }
632
+ }
633
+ else {
634
+ const listed = await this.git.run(["ls-tree", "-r", "--name-only", "-z", tip, "--", scope]);
635
+ for (const full of listed.stdout.split("\0")) {
636
+ const path = full.slice(scope.length);
637
+ if (full.length === 0 || isGeneratedPath(path))
638
+ continue;
639
+ files.push({ path, change: "added", divergent: true });
640
+ }
641
+ }
642
+ const log = await this.git.run(["log", "-n", "20", HISTORY_FORMAT, base ? `${base}..${tip}` : tip, "--", scope]);
643
+ out.push({ id, detectedAt: conflictDetectedAt(id), files, commits: parseHistory(log.stdout) });
644
+ }
645
+ return out;
646
+ }
647
+ async readConflictFile(project, id, side, path) {
648
+ const tip = this.conflictRefs.get(project)?.get(id);
649
+ if (!tip)
650
+ return null;
651
+ const rev = side === "preserved" ? tip : await this.mergeBase(tip, "HEAD");
652
+ if (!rev)
653
+ return null;
654
+ const res = await this.git.run(["cat-file", "blob", `${rev}:${project}/${path}`], { allowFail: true });
655
+ return res.code === 0 ? res.stdoutBytes : null;
446
656
  }
447
657
  async transaction(spec, work) {
448
658
  return this.mutex.run(async () => {
@@ -475,7 +685,7 @@ export class GitBackend {
475
685
  const before = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
476
686
  const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
477
687
  if (rebaseRes.code !== 0) {
478
- warnings.push(await this.preserveConflict());
688
+ warnings.push((await this.preserveConflict()).message);
479
689
  }
480
690
  const after = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
481
691
  if (before !== after) {
@@ -488,7 +698,8 @@ export class GitBackend {
488
698
  }
489
699
  // Step 2 & 3: Run work and commit
490
700
  try {
491
- const workRes = await work(new GitTx(this.repoDir, this.index));
701
+ const tx = new GitTx(this.repoDir, this.index);
702
+ const workRes = await work(tx);
492
703
  let commitSha = null;
493
704
  if (workRes.commit) {
494
705
  const addArgs = spec.projects.length > 0 ? ["add", "-A", "--", ...spec.projects] : ["add", "-A"];
@@ -549,10 +760,12 @@ export class GitBackend {
549
760
  await this.resync(extractChangedProjects(diffRes.stdout));
550
761
  }
551
762
  else {
552
- await this.git.run(["rebase", "--abort"], { allowFail: true });
553
- await this.git.run(["reset", "--hard", `origin/${this.config.gitBranch}`]);
554
- await this.resync(spec.projects);
555
- throw new OkfError("upstream_conflict", 409, "change conflicts with a concurrent upstream edit; re-read and retry");
763
+ // Keep every local commit, including this write and earlier unpushed ones.
764
+ const preserved = await this.preserveConflict();
765
+ const post = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
766
+ const diffRes = await this.git.run(["diff", "--name-only", pre, post]);
767
+ await this.resync(extractChangedProjects(diffRes.stdout));
768
+ throw new OkfError("upstream_conflict", 409, `change conflicts with a concurrent upstream edit; ${preserved.message}. Re-read, merge the preserved content, then resolve the conflict`, { conflicts: preserved.conflicts });
556
769
  }
557
770
  }
558
771
  else {
@@ -567,6 +780,11 @@ export class GitBackend {
567
780
  }
568
781
  }
569
782
  }
783
+ if (tx.resolutions.length > 0) {
784
+ await this.applyResolutions(tx.resolutions);
785
+ if (this.hasRemote && !syncHalted)
786
+ warnings.push(...(await this.pushResolutions()));
787
+ }
570
788
  return {
571
789
  value: workRes.value,
572
790
  commit: commitSha,
@@ -613,7 +831,7 @@ export class GitBackend {
613
831
  if (behind > 0) {
614
832
  const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
615
833
  if (rebaseRes.code !== 0) {
616
- runError = `rebase conflict; ${await this.preserveConflict()}`;
834
+ runError = `rebase conflict; ${(await this.preserveConflict()).message}`;
617
835
  }
618
836
  const after = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
619
837
  if (before !== after) {
@@ -641,6 +859,11 @@ export class GitBackend {
641
859
  else {
642
860
  await this.setRememberedRemoteTip(remoteTip);
643
861
  }
862
+ const pendingResolutions = await this.pushResolutions();
863
+ if (pendingResolutions.length > 0) {
864
+ const joined = pendingResolutions.join("; ");
865
+ runError = runError ? `${runError}; ${joined}` : joined;
866
+ }
644
867
  // lastError describes the most recent sync run only.
645
868
  this.lastError = runError;
646
869
  this.lastSyncAt = nowIso();
@@ -676,35 +899,8 @@ export class GitBackend {
676
899
  }
677
900
  async history(project, path, limit) {
678
901
  const clampedLimit = Math.max(1, Math.min(100, limit));
679
- const args = [
680
- "log",
681
- "-n",
682
- String(clampedLimit),
683
- "--format=%H%x1f%aI%x1f%an%x1f%s%x1f%(trailers:key=Okf-Principal,valueonly)%x1e",
684
- ];
685
- args.push("--", path === null ? `${project}/` : `${project}/${path}`);
686
- const res = await this.git.run(args, { allowFail: true });
687
- if (res.code !== 0) {
688
- return [];
689
- }
690
- const entries = res.stdout.split("\x1e").filter((e) => e.trim().length > 0);
691
- const commits = [];
692
- for (const entry of entries) {
693
- const parts = entry.trim().split("\x1f");
694
- const sha = parts[0] ?? "";
695
- const at = parts[1] ?? "";
696
- const actor = parts[2] ?? "";
697
- const subject = parts[3] ?? "";
698
- const principalRaw = parts[4]?.trim();
699
- commits.push({
700
- sha,
701
- at,
702
- actor,
703
- subject,
704
- principal: principalRaw && principalRaw.length > 0 ? principalRaw : null,
705
- });
706
- }
707
- return commits;
902
+ const res = await this.git.run(["log", "-n", String(clampedLimit), HISTORY_FORMAT, "--", path === null ? `${project}/` : `${project}/${path}`], { allowFail: true });
903
+ return res.code === 0 ? parseHistory(res.stdout) : [];
708
904
  }
709
905
  archive(project) {
710
906
  return this.git.spawnStdout(["archive", "--format=tar.gz", "HEAD", "--", project]);
package/dist/store/git.js CHANGED
@@ -229,13 +229,14 @@ export class Git {
229
229
  if (settled)
230
230
  return;
231
231
  settled = true;
232
- const stdout = Buffer.concat(stdoutChunks).toString("utf8");
232
+ const stdoutBytes = Buffer.concat(stdoutChunks);
233
+ const stdout = stdoutBytes.toString("utf8");
233
234
  const stderr = Buffer.concat(stderrChunks).toString("utf8") + extraStderr;
234
235
  if (failed && !opts?.allowFail) {
235
236
  reject(new GitError(args, code, stderr, this.config.gitRemoteUrl));
236
237
  }
237
238
  else {
238
- resolve({ code, stdout, stderr });
239
+ resolve({ code, stdout, stdoutBytes, stderr });
239
240
  }
240
241
  };
241
242
  child.on("error", (err) => settle(1, true, err.message));