@heyquilt/seam 0.140.0 → 0.142.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.
@@ -17,6 +17,8 @@ import { CDC_THRESHOLD } from './fastcdc.js'
17
17
  import { TreeReader, decodeNode, EMPTY_TREE } from './tree.js'
18
18
  import { globMatcher } from './claims.js'
19
19
  import { theCas } from './cas.js'
20
+ import { fastMerge } from './fastmerge.js'
21
+ import { diffMarkers, gitMarkers } from './conflict-format.js'
20
22
 
21
23
  /** How much of a large file is read at a time (10, streaming the cut). */
22
24
  export const READ_PIECE = 1024 * 1024
@@ -174,8 +176,10 @@ function readFileEntry (abs, stat, pointer) {
174
176
  */
175
177
  export function livePatch (dir, block, paths) {
176
178
  const only = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
177
- const changed = localChanges(dir, block, { paths })
178
- const seen = new Set(changed.map((c) => c.path))
179
+ // A file a move left conflicted is not shared until it is settled (its markers would go out as work).
180
+ const held = new Set(pullConflicts(dir, block))
181
+ const changed = localChanges(dir, block, { paths }).filter((c) => !held.has(c.path))
182
+ const seen = new Set([...changed.map((c) => c.path), ...held])
179
183
  const changes = changed.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: bytesOf(c).toString('base64'), mode: c.mode })
180
184
  // A named path that is not a change and not tracked is `clean` only when it is gone from disk (it
181
185
  // may have been shared as added); one still there is ignored or a folder, and is not sent.
@@ -436,7 +440,9 @@ export async function checkout (client, { quilt, square = null, squareRow = null
436
440
 
437
441
  /**
438
442
  * Moves the folder from `block.at` to commit `to`, file by file: paths changed on the server
439
- * are rewritten; a path changed both here and there is left as it is here and reported.
443
+ * are rewritten; a path changed both here and there is merged three ways (planMerges): merged
444
+ * cleanly, or left with conflict markers (or, when it can't be merged line by line, as it is
445
+ * here) and reported, to be settled before it is committed.
440
446
  */
441
447
  export async function syncTo (client, dir, block, to, { square, afterWrite } = {}) {
442
448
  const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
@@ -444,23 +450,29 @@ export async function syncTo (client, dir, block, to, { square, afterWrite } = {
444
450
  const t = d.files.length ? await listTree(client, block.quilt, to ? `c:${to}` : 'main', block.paths, dir) : { entries: [] }
445
451
  const byPath = new Map(t.entries.filter((e) => !e.unreadable).map((e) => [e.path, e]))
446
452
  const get = await contentsFor(client, block.quilt, to ? `c:${to}` : 'main', d.files.map((f) => byPath.get(f.path)).filter(Boolean), block._codec, t.ticket)
447
- const kept = []
448
453
  let written = 0
454
+ // Paths changed on both sides are merged before anything is written, so the journal's plan
455
+ // holds each merge's outcome (05 "Projection model"; rebuild finishes them).
456
+ const both = d.files.filter((f) => local.has(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null, theirs: byPath.get(f.path) || null }))
457
+ const plans = await planMerges(client, block, both, { from: block.at, get, to, oursOf: (p) => oursOfChange(local.get(p)) })
449
458
  // The plan goes to the journal first (05 "Rebuilt, never repaired"), so a move that dies part
450
459
  // way is finished by rebuild instead of leaving the server's files looking like the holder's edits.
451
460
  const journal = openJournal(dir, {
452
461
  op: 'move', from: block.at || null, to, rev: to ? `c:${to}` : 'main',
453
462
  ...(square ? { square: { id: square.id, short: square.short, head: square.head, line: square.line } } : {}),
454
463
  writes: d.files.filter((f) => !local.has(f.path) && byPath.get(f.path)).map((f) => { const e = byPath.get(f.path); return { path: e.path, blob: e.blob, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}), ...(e.read_only ? { read_only: true } : {}), old: block.files[f.path] || null } }),
455
- removes: d.files.filter((f) => !local.has(f.path) && !byPath.get(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null }))
464
+ removes: d.files.filter((f) => !local.has(f.path) && !byPath.get(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null })),
465
+ merges: plans.map(({ write: _, ours: __, ...m }) => m)
456
466
  })
467
+ const byMerge = new Map(plans.map((m) => [m.path, m]))
457
468
  for (const f of d.files) {
458
- if (local.has(f.path)) { kept.push(f.path); continue }
459
- const e = byPath.get(f.path)
460
- if (!e) {
469
+ const m = byMerge.get(f.path)
470
+ if (m) applyMerge(dir, block, m)
471
+ else if (!byPath.get(f.path)) {
461
472
  removeEntry(dir, f.path, block.files[f.path]?.mode)
462
473
  delete block.files[f.path]; written++
463
474
  } else {
475
+ const e = byPath.get(f.path)
464
476
  block.files[f.path] = await writeEntry(dir, e, await get(e))
465
477
  written++
466
478
  }
@@ -476,7 +488,163 @@ export async function syncTo (client, dir, block, to, { square, afterWrite } = {
476
488
  setStack(block, await stackOf(client, block.quilt, to))
477
489
  saveBlock(dir, block)
478
490
  journal.close()
479
- return { written, kept }
491
+ return { written, ...mergeReport(plans) }
492
+ }
493
+
494
+ const mergeReport = (plans) => ({ merged: plans.filter((m) => m.how === 'merged').map((m) => m.path), conflicted: plans.filter((m) => m.how === 'conflict').map((m) => m.path) })
495
+
496
+ // The ladder steps that merge by themselves (07's auto_combine at its default, as merge.js's AUTO).
497
+ const MERGES_ITSELF = new Set(['lines', 'additive', 'whitespace'])
498
+ const textMode = (m) => m === 'file' || m === 'exec'
499
+ const conflictErr = (msg) => Object.assign(new Error(msg), { code: 'conflicted' })
500
+ /** Where a move keeps the folder's own version of a file it left conflicted (for seam resolve --ours). */
501
+ const savedOurs = (dir, tag) => path.join(dir, BLOCK_DIR, 'conflicts', tag)
502
+ /** The folder's side of a merge from a localChanges entry: { bytes (null: deleted, undefined: not read whole), mode, blob }. */
503
+ const oursOfChange = (c) => c.status === 'deleted' ? { bytes: null } : { bytes: c.bytes, mode: c.mode, blob: c.blob }
504
+ /** A tree entry as the folder's record of it (mtime 0: the file on disk is read again). */
505
+ const recordOf = (e) => e ? { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: e.size, mtimeMs: 0, ...(e.term ? { term: e.term } : {}) } : null
506
+
507
+ /**
508
+ * Plans the three-way merge of each path changed both in the folder and in a move, as the server
509
+ * merges (07): base = the folder's record (`old`, read at `from`), ours = the disk (`oursOf`),
510
+ * theirs = the move's entry (`theirs`, read with `get`; null when the move deletes it). Writes
511
+ * nothing. Each plan says what the file becomes and that the record moves to theirs, so the
512
+ * file is an edit against the new commit and the next commit drops nothing the move brought
513
+ * (05 "Projection model"). `how`:
514
+ * - 'same': both sides made the same change;
515
+ * - 'merged': merged cleanly; `write` is the merged text, the folder's edit;
516
+ * - 'conflict': the clash with conflict markers in `write` (the folder's own version in `ours`, for
517
+ * seam resolve --ours), or, for a file that can't be merged line by line, no write: the folder's
518
+ * version stays. `conflict` is its block.conflicts entry, kept until it is settled.
519
+ */
520
+ async function planMerges (client, block, both, { from, get, to, oursOf }) {
521
+ if (!both.length) return []
522
+ const bases = both.filter((b) => b.old && b.old.blob && textMode(b.old.mode)).map((b) => ({ path: b.path, blob: b.old.blob, mode: b.old.mode, size: b.old.size }))
523
+ const getBase = bases.length && from ? await contentsFor(client, block.quilt, `c:${from}`, bases, block._codec) : null
524
+ const baseOf = new Map(bases.map((e) => [e.path, e]))
525
+ const idOf = idsOf(block)
526
+ const out = []
527
+ for (const { path: p, old, theirs } of both) {
528
+ const ours = oursOf(p)
529
+ const base = baseOf.has(p) && getBase ? await getBase(baseOf.get(p)) : undefined
530
+ const theirsBytes = theirs && theirs.mode !== 'conflict' ? await get(theirs) : null
531
+ const m = { path: p, old, record: recordOf(theirs) }
532
+ // Both deleted it, or both made it the same.
533
+ if ((!theirs && ours.bytes === null) || (theirs && ours.bytes && ours.blob === theirs.blob && ours.mode === theirs.mode)) { out.push({ ...m, how: 'same' }); continue }
534
+ const prior = block.conflicts?.[p]
535
+ const isBuf = (b) => b == null || Buffer.isBuffer(b)
536
+ const mergeable = (ours.bytes === null || (textMode(ours.mode) && ours.bytes)) && (!theirs || (textMode(theirs.mode) && theirsBytes)) &&
537
+ (!old || (textMode(old.mode) && base !== undefined)) && isBuf(ours.bytes) && isBuf(theirsBytes) && isBuf(base) &&
538
+ isText(ours.bytes) && isText(theirsBytes) && isText(base) && !!(ours.bytes || theirsBytes)
539
+ if (!mergeable) {
540
+ // A file that can't be merged line by line (binary, large, a link, a conflict already):
541
+ // the folder's version stays as it is until seam resolve --ours or --theirs settles it.
542
+ out.push({ ...m, how: 'conflict', conflict: { kind: 'whole', ...(prior?.tags ? { tags: prior.tags } : {}) } })
543
+ continue
544
+ }
545
+ const [o, a, b] = [base, ours.bytes, theirsBytes].map((x) => (x || Buffer.alloc(0)).toString('utf8'))
546
+ const exec = (theirs && old && old.mode === ours.mode ? theirs.mode : ours.mode || theirs?.mode) === 'exec'
547
+ if (ours.bytes && theirsBytes) {
548
+ const r = fastMerge(o, a, b, { allow: 'whitespace' })
549
+ if (!r.conflicts.length && MERGES_ITSELF.has(r.how)) {
550
+ const write = Buffer.from(r.text, 'utf8')
551
+ out.push({ ...m, how: 'merged', write, exec, out: idOf(write) })
552
+ continue
553
+ }
554
+ }
555
+ // A clash, or one side deleted what the other changed: the file with both sides in markers.
556
+ const hex = blobId(Buffer.from(['pull', p, old?.blob || '', ours.blob || '', theirs?.blob || ''].join('\n'))).slice(0, 12)
557
+ const tag = `quilt-conflict ${hex}`
558
+ const kind = ours.bytes && theirsBytes ? 'content' : 'edit_vs_delete'
559
+ const at = `upstream ${String(to || '').slice(0, 12)}`.trim()
560
+ const text = conflictStyle() === 'git' && ours.bytes && theirsBytes
561
+ ? gitMarkers({ ours: { text: a, label: 'yours' }, base: o, theirs: { text: b, label: at }, tag })
562
+ : diffMarkers({ sides: [{ text: ours.bytes ? a : null, label: 'yours', by: 'you' }, { text: theirsBytes ? b : null, label: at, by: 'upstream' }], bases: [o], tag, kind })
563
+ const write = Buffer.from(text, 'utf8')
564
+ out.push({ ...m, how: 'conflict', write, exec, out: idOf(write), ...(ours.bytes ? { ours: ours.bytes } : {}), conflict: { kind, tags: [...(prior?.tags || []), hex], ours: ours.bytes ? hex : null } })
565
+ }
566
+ return out
567
+ }
568
+
569
+ /** Carries out one planMerges plan: the folder's own version saved, the file written, the record and conflict set. */
570
+ function applyMerge (dir, block, m) {
571
+ if (m.ours && m.conflict?.ours) { fs.mkdirSync(path.dirname(savedOurs(dir, m.conflict.ours)), { recursive: true }); fs.writeFileSync(savedOurs(dir, m.conflict.ours), m.ours) }
572
+ if (m.write) {
573
+ const abs = absOf(dir, m.path)
574
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
575
+ const tmpDir = path.join(dir, BLOCK_DIR, 'tmp')
576
+ fs.mkdirSync(tmpDir, { recursive: true })
577
+ const tmp = path.join(tmpDir, `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
578
+ fs.writeFileSync(tmp, m.write, { mode: m.exec ? 0o755 : 0o644 })
579
+ fs.renameSync(tmp, abs)
580
+ }
581
+ settleRecord(block, m)
582
+ }
583
+
584
+ function settleRecord (block, m) {
585
+ if (m.record) block.files[m.path] = m.record
586
+ else delete block.files[m.path]
587
+ if (m.conflict) block.conflicts = { ...block.conflicts, [m.path]: m.conflict }
588
+ }
589
+
590
+ /**
591
+ * The files a move left conflicted (planMerges) that are not settled yet, sorted. A file is settled
592
+ * once its markers are gone (edited, deleted, restored), or, for one kept whole, once it matches
593
+ * the folder's record; a settled one leaves block.conflicts (and block.json is saved).
594
+ */
595
+ export function pullConflicts (dir, block) {
596
+ const c = block.conflicts
597
+ if (!c) return []
598
+ const open = []
599
+ let changed = null
600
+ let dropped = false
601
+ for (const [p, x] of Object.entries(c)) {
602
+ let still = false
603
+ if (x.tags?.length) { let bytes = null; try { bytes = fs.readFileSync(absOf(dir, p)) } catch {} still = !!bytes && x.tags.some((t) => bytes.includes(Buffer.from(`quilt-conflict ${t}`))) }
604
+ if (!still && x.kind === 'whole') { changed ??= new Set(localChanges(dir, block).map((ch) => ch.path)); still = changed.has(p) }
605
+ if (still) { open.push(p); continue }
606
+ for (const t of x.tags || []) fs.rmSync(savedOurs(dir, t), { force: true })
607
+ delete c[p]
608
+ dropped = true
609
+ }
610
+ if (!Object.keys(c).length) delete block.conflicts
611
+ if (dropped) saveBlock(dir, block)
612
+ return open.sort(cmpPath)
613
+ }
614
+
615
+ /** Refuses to commit (or move) while `paths` hold a conflict a move left. */
616
+ function refuseConflicted (open, verb) {
617
+ if (!open.length) return
618
+ throw conflictErr(`${open.join(', ')} still ${open.length === 1 ? 'has' : 'have'} the conflict seam pull left: edit ${open.length === 1 ? 'it' : 'each'} to the version you want (no markers), or seam resolve <path> --ours | --theirs, then ${verb}.`)
619
+ }
620
+
621
+ /**
622
+ * seam resolve --ours | --theirs for files a move left conflicted, in the folder: ours puts back
623
+ * the folder's own version from before the move (one kept whole stays as it is), theirs the
624
+ * version the folder moved to. Answers { settled: [paths], took }.
625
+ */
626
+ export async function resolvePulled (client, dir, block, { paths, choose }) {
627
+ const open = new Set(pullConflicts(dir, block))
628
+ const rels = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
629
+ const not = rels.filter((p) => !open.has(p))
630
+ if (not.length) throw new Error(`${not.join(', ')}: no conflict seam pull left there (seam status lists them)`)
631
+ if (choose === 'theirs') {
632
+ const entries = rels.filter((p) => block.files[p]).map((p) => ({ path: p, ...block.files[p] }))
633
+ const get = await contentsFor(client, block.quilt, `c:${block.at}`, entries, block._codec)
634
+ for (const e of entries) block.files[e.path] = { ...await writeEntry(dir, e, await get(e)), ...(e.term ? { term: e.term } : {}) }
635
+ for (const p of rels) if (!block.files[p]) { removeEntry(dir, p, 'file'); pruneEmpty(dir, p) }
636
+ } else {
637
+ for (const p of rels) {
638
+ const x = block.conflicts[p]
639
+ if (x.kind === 'whole') continue
640
+ if (x.ours) fs.writeFileSync(absOf(dir, p), fs.readFileSync(savedOurs(dir, x.ours)))
641
+ else { removeEntry(dir, p, 'file'); pruneEmpty(dir, p) }
642
+ }
643
+ }
644
+ for (const p of rels) { for (const t of block.conflicts[p].tags || []) fs.rmSync(savedOurs(dir, t), { force: true }); delete block.conflicts[p] }
645
+ if (!Object.keys(block.conflicts).length) delete block.conflicts
646
+ saveBlock(dir, block)
647
+ return { settled: rels, took: choose }
480
648
  }
481
649
 
482
650
  /** The write journal of a block folder: one plan line, then one `{ done }` line per path. */
@@ -518,30 +686,44 @@ function onDisk (dir, block, p) {
518
686
  return { id: idsOf(block)(bytes), mode, stat }
519
687
  }
520
688
 
689
+ /** The folder's side of a merge read from disk (oursOfChange's shape). */
690
+ function oursOnDisk (dir, block, p) {
691
+ const cur = onDisk(dir, block, p)
692
+ if (!cur) return { bytes: null }
693
+ if (!cur.stat.isFile() && !cur.stat.isSymbolicLink()) return { bytes: undefined, mode: cur.mode, blob: cur.id }
694
+ if (cur.stat.isFile() && cur.stat.size > CDC_THRESHOLD && !block.sealed) return { bytes: undefined, mode: cur.mode, blob: cur.id }
695
+ return { bytes: readFileEntry(absOf(dir, p), cur.stat, false).bytes, mode: cur.mode, blob: cur.id }
696
+ }
697
+
521
698
  /**
522
699
  * block.rebuild (05): finishes a move the journal shows unfinished. A planned path whose file is
523
- * the plan's is recorded; one still as before (or gone) is written now; one that is neither is
524
- * the holder's edit and is kept. Then block.json takes the move's commit and the journal goes.
525
- * On a healthy block it writes nothing. Answers { finished, written, removed, kept, changes }.
700
+ * the plan's is recorded; one still as before (or gone) is written now; one the holder edited
701
+ * before it was written is merged with the move's change (planMerges), as are the planned merges
702
+ * the move did not reach. Then block.json takes the move's commit and the journal goes. On a
703
+ * healthy block it writes nothing. Answers { finished, written, removed, kept (the holder's edits,
704
+ * merged ones too), conflicted, changes }.
526
705
  */
527
706
  export async function rebuild (client, dir, block) {
528
707
  const j = readJournal(dir)
529
- if (!j) return { finished: false, written: [], removed: [], kept: [], changes: localChanges(dir, block).length }
708
+ if (!j) return { finished: false, written: [], removed: [], kept: [], conflicted: [], changes: localChanges(dir, block).length }
530
709
  const { plan, done } = j
531
710
  const same = (cur, rec) => !!(cur && rec && rec.blob && cur.id === rec.blob && cur.mode === rec.mode)
532
711
  const written = []
533
712
  const removed = []
534
713
  const kept = []
714
+ const conflicted = []
535
715
  const toWrite = []
716
+ // Paths the holder changed and the move changes too, still to merge: { path, old, theirs }.
717
+ const redo = []
536
718
  for (const w of plan.writes || []) {
537
719
  const cur = onDisk(dir, block, w.path)
538
720
  if (same(cur, w)) { block.files[w.path] = { blob: w.blob, mode: w.mode, size: cur.stat.size, mtimeMs: cur.stat.mtimeMs, ...(w.term ? { term: w.term } : {}) }; continue }
539
721
  if (done.has(w.path) && w.mode === 'conflict' && cur) { const st = cur.stat; block.files[w.path] = { blob: null, mode: 'conflict', size: st.size, mtimeMs: st.mtimeMs, term: w.term }; continue }
540
722
  if (!done.has(w.path) && (!cur || same(cur, w.old))) { toWrite.push(w); continue }
541
- // Written and edited since, or edited before it was written: the holder's. Its record is the
542
- // one the folder now stands on, so the edit shows against the new commit.
543
- kept.push(w.path)
544
- if (done.has(w.path)) block.files[w.path] = { blob: w.blob, mode: w.mode, size: w.size, mtimeMs: 0 }
723
+ // Written and edited since: the holder's edit, against the new commit.
724
+ if (done.has(w.path)) { kept.push(w.path); block.files[w.path] = { blob: w.blob, mode: w.mode, size: w.size, mtimeMs: 0 }; continue }
725
+ // Edited before it was written: the holder's edit and the move's change are merged.
726
+ redo.push({ path: w.path, old: w.old, theirs: w })
545
727
  }
546
728
  if (toWrite.length) {
547
729
  const get = await contentsFor(client, block.quilt, plan.rev, toWrite, block._codec)
@@ -551,20 +733,35 @@ export async function rebuild (client, dir, block) {
551
733
  const cur = onDisk(dir, block, r.path)
552
734
  if (!cur) { delete block.files[r.path]; if (!done.has(r.path)) removed.push(r.path); continue }
553
735
  if (!done.has(r.path) && same(cur, r.old)) { removeEntry(dir, r.path, r.old?.mode); pruneEmpty(dir, r.path); delete block.files[r.path]; removed.push(r.path); continue }
554
- kept.push(r.path)
736
+ // Removed and made again since: a new file of the holder's. Edited before the remove: merged.
737
+ if (done.has(r.path)) { kept.push(r.path); delete block.files[r.path] } else redo.push({ path: r.path, old: r.old, theirs: null })
738
+ }
739
+ for (const m of plan.merges || []) {
740
+ // A planned merge carried out (its file is the plan's) needs only its record.
741
+ const cur = onDisk(dir, block, m.path)
742
+ if (done.has(m.path) || (m.out && cur && cur.id === m.out)) { settleRecord(block, m); if (m.how === 'merged') kept.push(m.path); else if (m.how === 'conflict') conflicted.push(m.path); continue }
743
+ redo.push({ path: m.path, old: m.old, theirs: m.record ? { path: m.path, ...m.record } : null })
744
+ }
745
+ if (redo.length) {
746
+ const theirs = redo.map((r) => r.theirs).filter((e) => e && e.mode !== 'conflict')
747
+ const get = await contentsFor(client, block.quilt, plan.rev, theirs, block._codec)
748
+ for (const m of await planMerges(client, block, redo, { from: plan.from, get, to: plan.to, oursOf: (p) => oursOnDisk(dir, block, p) })) {
749
+ applyMerge(dir, block, m)
750
+ if (m.how === 'merged') kept.push(m.path); else if (m.how === 'conflict') conflicted.push(m.path)
751
+ }
555
752
  }
556
753
  block.at = plan.to
557
754
  if (plan.square) { block.square = plan.square.id; block.square_short = plan.square.short; block.head = plan.square.head; block.line = plan.square.line }
558
755
  saveBlock(dir, block)
559
756
  fs.rmSync(path.join(dir, JOURNAL_FILE), { force: true })
560
- return { finished: true, to: plan.to, written, removed, kept: kept.sort(cmpPath), changes: localChanges(dir, block).length }
757
+ return { finished: true, to: plan.to, written, removed, kept: kept.sort(cmpPath), conflicted: conflicted.sort(cmpPath), changes: localChanges(dir, block).length }
561
758
  }
562
759
 
563
760
  /** status: the folder's changes, and the square as the server sees it. */
564
761
  export async function status (client, dir, block) {
565
762
  const changes = localChanges(dir, block).map(({ bytes, open, abs, ...c }) => c)
566
763
  const square = block.square ? await client.square(block.quilt, block.square) : null
567
- const conflicted = Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p)
764
+ const conflicted = [...new Set([...Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p), ...pullConflicts(dir, block)])].sort(cmpPath)
568
765
  return { dir, quilt: block.quilt, square, line: block.line, on_line: isLineCheckout(block), paths: block.paths, at: block.at, changes, conflicted, stale: !!(square && square.state === 'open' && square.head !== block.head), ...(square && square.state && square.state !== 'open' ? { square_closed: square.state } : {}) }
569
766
  }
570
767
 
@@ -585,6 +782,7 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
585
782
  if (!block.square && !isLineCheckout(block)) throw new Error('This folder holds one revision, not a line; open a square (seam square open) to commit.')
586
783
  const changes = localChanges(dir, block, { paths })
587
784
  if (!changes.length) return { nothing: true }
785
+ refuseConflicted(pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p)), 'commit')
588
786
  // A clone (a checkout of a line): the first commit opens a square by itself, titled by the
589
787
  // message and based on the commit the folder holds, so it undoes nothing main gained since.
590
788
  const opened = block.square ? null : await openForCommit(client, dir, block, message)
@@ -646,10 +844,12 @@ async function openForCommit (client, dir, block, message) {
646
844
 
647
845
  /**
648
846
  * seam pull: a line checkout moves to the line's tip (uncommitted edits stay; a path changed both
649
- * here and there keeps this folder's version and is reported); a square folder rebases the square
650
- * onto its line and moves there.
847
+ * here and there is merged, or left conflicted and reported: syncTo); a square folder rebases the
848
+ * square onto its line and moves there. Refused while the last pull's conflicts are unsettled.
651
849
  */
652
850
  export async function pull (client, dir, block) {
851
+ // As git refuses to pull over unmerged files: the last pull's conflicts are settled first.
852
+ refuseConflicted(pullConflicts(dir, block), 'pull again')
653
853
  if (block.square) {
654
854
  // The square was merged (or abandoned) from elsewhere: the folder goes to its line's tip.
655
855
  const sq = await client.square(block.quilt, block.square)
@@ -657,19 +857,19 @@ export async function pull (client, dir, block) {
657
857
  const short = block.square_short || block.square
658
858
  const tip = (await client.show(block.quilt, sq.line, { format: 'none' })).commit.id
659
859
  const s = await toLineTip(client, dir, block, sq.line, tip)
660
- return { left: { square: short, state: sq.state }, line: sq.line, at: tip, written: s.written, kept: s.kept }
860
+ return { left: { square: short, state: sq.state }, line: sq.line, at: tip, written: s.written, merged: s.merged, conflicted: s.conflicted }
661
861
  }
662
862
  const r = await client.rebase(block.quilt, block.square, { expect_head: block.head })
663
863
  if (r.noop) return { rebased: false, noop: true, square: r.square }
664
864
  const s = await syncTo(client, dir, block, r.square.head || r.square.base, { square: r.square })
665
- return { rebased: true, square: r.square, result: r, written: s.written, kept: s.kept }
865
+ return { rebased: true, square: r.square, result: r, written: s.written, merged: s.merged, conflicted: s.conflicted }
666
866
  }
667
867
  if (!isLineCheckout(block)) throw new Error('This folder holds one revision, not a line: seam checkout <line or square> moves it.')
668
868
  const to = (await client.show(block.quilt, block.line, { format: 'none' })).commit.id
669
869
  const from = block.at
670
870
  if (to === from) return { noop: true, line: block.line, at: to }
671
871
  const s = await syncTo(client, dir, block, to)
672
- return { line: block.line, from, at: to, written: s.written, kept: s.kept }
872
+ return { line: block.line, from, at: to, written: s.written, merged: s.merged, conflicted: s.conflicted }
673
873
  }
674
874
 
675
875
  /** After a merge: the folder leaves the merged square and stands on the line's new tip. */
@@ -684,6 +884,7 @@ export async function toLineTip (client, dir, block, line, tip) {
684
884
  export async function amendFolder (client, dir, block, { message, paths = [], allow = [] }) {
685
885
  if (!block.square) throw new Error('This folder is a read-only checkout of a line; there is no commit of yours to amend.')
686
886
  const changes = localChanges(dir, block, { paths })
887
+ refuseConflicted(pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p)), 'amend')
687
888
  const blobs = changes.filter((c) => c.bytes || c.open).map((c) => c.open ? { kind: 'blob', id: c.blob, size: c.size, open: c.open } : { kind: 'blob', id: c.blob, bytes: block.sealed ? block._codec.seal(c.bytes) : c.bytes })
688
889
  if (blobs.length) await client.upload(block.quilt, blobs)
689
890
  const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode || 'file' })
package/lib/quilts/cli.js CHANGED
@@ -9,8 +9,8 @@ import { quiltClient } from './client.js'
9
9
  import { runChecks, readRunnerConfig, listenForWork } from './runner.js'
10
10
  import { startDaemon, askDaemon } from './daemon.js'
11
11
  import { rebuild as rebuildBlock, conflictStyle } from './block.js'
12
- import { describeRebuild, describeOpened } from './describe.js'
13
- import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, pull, toLineTip, restore, reset, clean, localChanges, bytesOf, livePatch } from './block.js'
12
+ import { describeRebuild, describeOpened, describeBoth } from './describe.js'
13
+ import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, pull, toLineTip, restore, reset, clean, localChanges, bytesOf, livePatch, resolvePulled } from './block.js'
14
14
  import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeImportRecord, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeHandoff, describeFinished, describeResolutions, describeLiveSet, describeLiveJoin, describeMergeBase, describeStack, describeStackDiff, describeGrep, describeBisect, describeCheck, describeReviews, describeInterdiff, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken, describeBlock, describeBlocks, describeProtections, describeTeams, describeAccess, describeRedacted, describeRedactions, describeMetrics, describeSquarePolicy, describePolicyVersions, describeOrg, describeOrgs, describeOrgLog, describeOrgDomain, describeApp, describeApps, describeAppKey, describeInstall, describeInstalls, describeRequest, describeRequests, describeOrgTeams, describeCheckRuns, describeRerun, describeAncestor, describeContains, describePipeline, describeWatches, describeSearch, describePublicReads } from './describe.js'
15
15
  import { verifyQuilt, describeVerify } from './attest.js'
16
16
  import { confirmWithLocalKey } from './linepolicy.js'
@@ -326,7 +326,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
326
326
  if (by === 'paths' && !groups.length) throw new Error('Split by which paths? seam square split [sq] docs,README.md src (one group per new square), or --by claim')
327
327
  const r = await client.split(needQuilt(), own ? pos[1] : needSquare(), { by, ...(by === 'paths' ? { groups } : {}), ...(flags['--stacked'] ? { stacked: true } : {}), ...(flags['--title'] ? { titles: [flags['--title']] } : {}) })
328
328
  const text = [`Kept in ${r.square.short}: ${r.kept.join(', ') || '(nothing)'}`, ...r.squares.map((q) => `New ${describeSquare(q)}: ${q.paths.join(', ')}`)].join('\n')
329
- if (found && block.square === r.square.id) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); return print(r, `${text}\n${st.written} file(s) updated in the folder.`) }
329
+ if (found && block.square === r.square.id) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); return print(r, `${text}\n${st.written} file(s) updated in the folder.` + describeBoth(st)) }
330
330
  return print(r, text)
331
331
  }
332
332
  if (sub === 'show') { const sq = await client.square(needQuilt(), pos[1] || needSquare()); return print(sq, describeSquareStatus(sq)) }
@@ -346,7 +346,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
346
346
  case 'pull': {
347
347
  if (!found) throw new Error('seam pull works in a block folder: run it in a folder made by seam clone or seam checkout.')
348
348
  const r = await pull(client, found.dir, block)
349
- const kept = r.kept?.length ? `\nKept your own version of ${r.kept.join(', ')} (changed on both sides): compare with seam diff.` : ''
349
+ const kept = describeBoth(r)
350
350
  if (r.noop) return print(r, block.square ? `Already up to date: ${block.square_short || block.square} sits on ${block.line}'s tip.` : `Already up to date with ${block.line}.`)
351
351
  if (r.left) return print(r, `${r.left.square} was ${r.left.state}; the folder is now on ${r.line} (${r.at.slice(0, 12)}, ${r.written} file(s) updated).` + kept)
352
352
  if (r.rebased) return print(r, describeWrite(r.result, 'Rebased') + `\n${r.written} file(s) updated in the folder.` + kept)
@@ -943,7 +943,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
943
943
  const mine = found && (target === block.square_short || target === block.square)
944
944
  const r = await client.mergeSquare(needQuilt(), target, { from, ...(mine ? { expect_head: block.head } : {}), message })
945
945
  let text = r.noop ? `Already up to date: ${from} is in ${target}.` : describeWrite(r, `Merged ${from} into ${target}:`)
946
- if (mine && !r.noop) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${st.written} file(s) updated in the folder.` }
946
+ if (mine && !r.noop) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${st.written} file(s) updated in the folder.` + describeBoth(st) }
947
947
  return print(r, text)
948
948
  }
949
949
  const sqId = pos[0] && pos[0].startsWith('sq:') ? pos[0] : needSquare()
@@ -952,26 +952,26 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
952
952
  // Merged from its own folder: the folder moves to the line's new tip, ready for the next change.
953
953
  if (found && sqId === block.square && r.ok && r.merged?.commit && !flags['--dry-run']) {
954
954
  const s = await toLineTip(client, found.dir, block, r.merged.line || into, r.merged.commit)
955
- text += `\nThe folder is now on ${r.merged.line || into} (${s.written} file(s) updated); your next commit opens a new square.`
955
+ text += `\nThe folder is now on ${r.merged.line || into} (${s.written} file(s) updated); your next commit opens a new square.` + describeBoth(s)
956
956
  }
957
957
  // The refused merge left the square rebased onto the tip with its conflicts: bring the folder there.
958
958
  if (found && sqId === block.square && !r.ok && r.head && r.head !== block.head) {
959
959
  const s = await syncTo(client, found.dir, block, r.head, { square: r.square })
960
- text += `\nThe folder now holds the rebased files (${s.written} written)${s.kept.length ? `; kept your own version of ${s.kept.join(', ')}` : ''}.`
960
+ text += `\nThe folder now holds the rebased files (${s.written} written).` + describeBoth(s)
961
961
  }
962
962
  return print(r, text)
963
963
  }
964
964
  case 'rebase': {
965
965
  const r = await client.rebase(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), onto: flags['--onto'] })
966
966
  let text = r.noop ? 'Already on the tip.' : describeWrite(r, 'Rebased')
967
- if (found && !r.noop) { const s = await syncTo(client, found.dir, block, r.square.head || r.square.base, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
967
+ if (found && !r.noop) { const s = await syncTo(client, found.dir, block, r.square.head || r.square.base, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
968
968
  return print(r, text)
969
969
  }
970
970
  case 'cherry-pick': {
971
971
  if (!pos[0]) throw new Error('seam cherry-pick <revision>')
972
972
  const r = await client.cherryPick(needQuilt(), needSquare(), { from: pos[0], ...(found ? { expect_head: block.head } : {}) })
973
973
  let text = describeWrite(r, 'Picked')
974
- if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
974
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
975
975
  return print(r, text)
976
976
  }
977
977
  case 'revert': {
@@ -988,15 +988,20 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
988
988
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
989
989
  const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), rule })), ...(flags['--no-remember'] ? { remember: false } : {}) })
990
990
  let text = describeWrite(r, 'Resolved')
991
- if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
991
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
992
992
  return print(r, text)
993
993
  }
994
994
  const choose = flags['--ours'] ? 'ours' : flags['--theirs'] ? 'theirs' : flags['--base'] ? 'base' : flags['--delete'] ? 'delete' : flags['--suggestion'] ? 'suggestion' : flags['--keep-both'] ? 'keep_both' : flags['--side'] != null ? Number(flags['--side']) : null
995
+ // A conflict seam pull left in this folder is settled here, not on the server.
996
+ if (found && block.conflicts && pos.length && (choose === 'ours' || choose === 'theirs')) {
997
+ const r = await resolvePulled(client, found.dir, block, { paths: pos.map((p) => path.resolve(cwd, p)), choose })
998
+ return print(r, `Settled ${r.settled.join(', ')}: ${choose === 'theirs' ? 'took upstream\'s version' : 'kept your version'}. Commit when ready.`)
999
+ }
995
1000
  if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete | --keep-both | --rule <step> [--no-remember] (or edit the file and commit it without the markers)')
996
1001
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
997
1002
  const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })), ...(flags['--no-remember'] ? { remember: false } : {}) })
998
1003
  let text = describeWrite(r, 'Resolved')
999
- if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
1004
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
1000
1005
  return print(r, text)
1001
1006
  }
1002
1007
  case 'resolutions': {
@@ -103,6 +103,17 @@ export function describeWarnings (warnings = []) {
103
103
  return said.length ? ['Changed under your code (a warning, not a refusal: check it still fits):', ...said] : []
104
104
  }
105
105
 
106
+ /**
107
+ * What a move of a folder (seam pull, a rebase) did with files changed on both sides: merged into
108
+ * the folder's edits, or left conflicted to settle. `diff` and `resolve` name the commands to use.
109
+ */
110
+ export function describeBoth (r, { diff = 'seam diff', resolve = 'seam resolve <path> --ours | --theirs' } = {}) {
111
+ const out = []
112
+ if (r.merged?.length) out.push(`Merged upstream's changes into your edits to ${r.merged.join(', ')}: compare with ${diff}.`)
113
+ if (r.conflicted?.length) out.push(`Conflict in ${r.conflicted.join(', ')} (changed on both sides): settle each by editing out the conflict markers, or with ${resolve}, then commit.`)
114
+ return out.map((l) => '\n' + l).join('')
115
+ }
116
+
106
117
  export function describeStatus (st) {
107
118
  const lines = []
108
119
  if (st.square) lines.push(describeSquareStatus(st.square, { prefix: 'On square ' }))
@@ -613,5 +624,5 @@ export function describePublicReads (r) {
613
624
  /** What block.rebuild did (05 "Rebuilt, never repaired"). */
614
625
  export function describeRebuild (r) {
615
626
  if (!r.finished) return `Nothing to rebuild: the folder is whole${r.changes ? ` (${r.changes} uncommitted change(s), all yours)` : ''}.`
616
- return `Finished a move that stopped part way: ${r.written.length} file(s) written, ${r.removed.length} removed${r.kept.length ? `; kept your edits to ${r.kept.join(', ')}` : ''}. The folder is on ${String(r.to).slice(0, 12)}.`
627
+ return `Finished a move that stopped part way: ${r.written.length} file(s) written, ${r.removed.length} removed${r.kept.length ? `; kept your edits to ${r.kept.join(', ')}` : ''}. The folder is on ${String(r.to).slice(0, 12)}.` + describeBoth({ conflicted: r.conflicted })
617
628
  }
@@ -0,0 +1,43 @@
1
+ // A fast front for merge-smart.js's mergeSmart (whose node-diff3 LCS is quadratic: seconds on a
2
+ // 7,000-line file). A clean line merge comes from the Myers hunks in textdiff.js (`nway`, the
3
+ // same N-way merge the conflict format uses); only a clash goes to mergeSmart's ladder, and then
4
+ // with the lines all three versions share at the start and end cut off, so it reads only the
5
+ // region that differs. Same answer, same `how`, as mergeSmart for a clean merge; a clash is
6
+ // mergeSmart's own answer on the middle, put back between the shared lines.
7
+ import { mergeSmart as mergeSmartWith, STEPS } from './merge-smart.js'
8
+ import { nway } from './conflict-format.js'
9
+
10
+ /** `opts.allow`: the highest ladder step that may settle a clash (07's auto_combine); none: every step. */
11
+ export function fastMerge (base, ours, theirs, opts = {}) {
12
+ const [o, a, b] = [base.split('\n'), ours.split('\n'), theirs.split('\n')]
13
+ const regions = nway(o, [a, b])
14
+ if (regions.every((r) => r.ok)) return { text: regions.flatMap((r) => r.ok).join('\n'), conflicts: [], how: 'lines' }
15
+ const mergeSmart = (x, y, z) => mergeSmartWith(x, y, z, opts)
16
+ // Every clash is two inserts at one place the base had nothing (07's `additive` step): ours
17
+ // then theirs, shared leading lines once. Shared trailing lines are kept on both sides, not
18
+ // once as 07 says: two added functions share their closing `}`, and taking it once dropped
19
+ // one function's closing brace (found by real agents adding functions side by side).
20
+ if ((opts.allow == null || STEPS[opts.allow] >= STEPS.additive) && regions.every((r) => r.ok || (!r.base.length && r.sides.length === 2))) {
21
+ return { text: regions.flatMap((r) => r.ok || additive(r.sides[0], r.sides[1])).join('\n'), conflicts: [], how: 'additive' }
22
+ }
23
+ // Shared lines at both ends, leaving at least one line in each middle.
24
+ const min = Math.min(o.length, a.length, b.length) - 1
25
+ let p = 0
26
+ while (p < min && o[p] === a[p] && o[p] === b[p]) p++
27
+ let s = 0
28
+ while (s < min - p && o[o.length - 1 - s] === a[a.length - 1 - s] && o[o.length - 1 - s] === b[b.length - 1 - s]) s++
29
+ if (!p && !s) return mergeSmart(base, ours, theirs)
30
+ const mid = (x) => x.slice(p, x.length - s).join('\n')
31
+ const m = mergeSmart(mid(o), mid(a), mid(b))
32
+ if (m.text === '') return mergeSmart(base, ours, theirs) // zero lines or one empty line: ambiguous
33
+ return { ...m, text: [...o.slice(0, p), m.text, ...o.slice(o.length - s)].join('\n') }
34
+ }
35
+
36
+ /** Two inserts at one place: ours, then what theirs adds after the leading lines both share. */
37
+ function additive (a, b) {
38
+ let pre = 0
39
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
40
+ if (pre === b.length) return a
41
+ if (pre === a.length) return b
42
+ return [...a, ...b.slice(pre)]
43
+ }
@@ -0,0 +1,125 @@
1
+ // The merge ladder seam settles a clash with (plans/quilt-source-control/07, steps 1 and 2): line
2
+ // by line as git does, then two inserts at one place, a one-line import both sides changed,
3
+ // whitespace-only changes, and word by word within the clashing lines. Seam's own copy, so the
4
+ // Quilt app's merges can change without changing how a quilt merges.
5
+ import { diff3Merge } from 'node-diff3'
6
+
7
+ const lines = (s) => s.split('\n')
8
+
9
+ /**
10
+ * A text's line endings: 'crlf' (every line ends \r\n), 'lf' (none does), 'mixed', or null (one line:
11
+ * nothing to tell).
12
+ */
13
+ function eolOf (s) {
14
+ if (typeof s !== 'string' || !s.includes('\n')) return null
15
+ if (!s.includes('\r\n')) return 'lf'
16
+ return s.replace(/\r\n/g, '').includes('\n') ? 'mixed' : 'crlf'
17
+ }
18
+
19
+ /**
20
+ * The merges compare lines, so a side saved with Windows line endings (\r\n) against a base and other
21
+ * side with \n differs on every line: diff3 then reads it as "ours deleted the whole file and inserted
22
+ * it again", the delete loses to theirs' edits and the insert stays, and the file comes out as both
23
+ * versions one after the other (task 7b480c8b07c4fa02: web/lib/docs.js, CRLF old + LF new, every
24
+ * export twice). When the sides' line endings differ, all three are merged with \n and the result is
25
+ * written in the line endings of the side that changed them from the base (ours when both did, or
26
+ * when there is no base to tell), as any other edit to the file would be kept.
27
+ */
28
+ function eolAware (merge) {
29
+ return function (base, ours, theirs, ...rest) {
30
+ const eols = [base, ours, theirs].map(eolOf)
31
+ const seen = new Set(eols.filter(Boolean))
32
+ if (!seen.has('crlf') || seen.size < 2) return merge(base, ours, theirs, ...rest)
33
+ const [eb, eo, et] = eols
34
+ const target = (eo && eo !== eb) ? eo : (et && et !== eb) ? et : (eo || et || eb)
35
+ const lf = (s) => typeof s === 'string' ? s.replace(/\r\n/g, '\n') : s
36
+ const back = (t) => target === 'crlf' && typeof t === 'string' ? t.replace(/\n/g, '\r\n') : t
37
+ const r = merge(lf(base), lf(ours), lf(theirs), ...rest)
38
+ return typeof r === 'string' ? back(r) : { ...r, text: back(r.text) }
39
+ }
40
+ }
41
+
42
+ /** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
43
+ function addedBoth (a, b) {
44
+ let pre = 0
45
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
46
+ let post = 0
47
+ while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
48
+ return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
49
+ }
50
+
51
+ const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*(['"][^'"]+['"]).*)$/
52
+
53
+ /** Both sides changed the same one-line import of the same module: the union of its names, or null. */
54
+ function sameImport (o, a, b) {
55
+ if (o.length !== 1 || a.length !== 1 || b.length !== 1) return null
56
+ const [mo, ma, mb] = [IMPORT.exec(o[0]), IMPORT.exec(a[0]), IMPORT.exec(b[0])]
57
+ if (!mo || !ma || !mb || mo[4] !== ma[4] || ma[4] !== mb[4]) return null
58
+ const names = (m) => m[2].split(',').map((s) => s.trim()).filter(Boolean)
59
+ const [no, na, nb] = [names(mo), names(ma), names(mb)]
60
+ const gone = new Set(no.filter((n) => !na.includes(n) || !nb.includes(n)))
61
+ const all = [...na, ...nb.filter((n) => !na.includes(n))].filter((n) => !gone.has(n))
62
+ return [`${ma[1]} ${all.join(', ')} ${ma[3]}`]
63
+ }
64
+
65
+ const normWs = (l) => l.replace(/\s+/g, ' ').trim()
66
+ const sameIgnoringWs = (a, b) => a.length === b.length && a.every((l, i) => normWs(l) === normWs(b[i]))
67
+ const TOKEN = /\s+|\w+|[^\w\s]/g
68
+
69
+ /** Two blocks of lines merged word by word against their base, or null when the same words changed on both sides. */
70
+ function mergeWords (o, a, b) {
71
+ const tok = (ls) => ls.join('\n').match(TOKEN) || []
72
+ const regions = diff3Merge(tok(a), tok(o), tok(b), { excludeFalseConflicts: true })
73
+ if (regions.some((r) => !r.ok)) return null
74
+ return regions.map((r) => r.ok.join('')).join('').split('\n')
75
+ }
76
+
77
+ /**
78
+ * The clash ladder's mechanical and structural steps (plan §7.3, steps 1 and 2): mergeAdditive, then
79
+ * each region still clashing is tried again ignoring whitespace (a side that only reindented loses
80
+ * to the side that changed something), then word by word within the clashing lines (two edits to
81
+ * different words of one line both land); a file marked merge=union takes both sides, ours first.
82
+ * Returns { text, conflicts, how } with `how` the step that settled it: 'lines', 'whitespace',
83
+ * 'words' or 'union'; `conflicts` lists what no step could settle (text then holds ours there).
84
+ */
85
+ export const mergeSmart = eolAware(function mergeSmart (base, ours, theirs, { union = false, allow = null } = {}) {
86
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
87
+ const out = []
88
+ const conflicts = []
89
+ let how = 'lines'
90
+ const rank = { lines: 0, additive: 0.5, whitespace: 1, words: 2, union: 3 }
91
+ const step = (h) => { if (rank[h] > rank[how]) how = h }
92
+ for (const r of regions) {
93
+ if (r.ok) { out.push(...r.ok); continue }
94
+ const c = r.conflict
95
+ const settled = ladder(c, { union, allow })
96
+ if (settled) { out.push(...settled.lines); step(settled.how); continue }
97
+ conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
98
+ out.push(...c.a)
99
+ }
100
+ return { text: out.join('\n'), conflicts, how }
101
+ })
102
+
103
+ // The ladder's steps in order (07's auto_combine): `allow` names the highest that may settle a
104
+ // region (words always runs; a word merge is only ever a suggestion to the caller).
105
+ export const STEPS = { lines: 0, additive: 1, whitespace: 2, words: 3 }
106
+
107
+ /**
108
+ * mergeSmart's steps for one region both sides changed ({ o, a, b }): { lines, how }, or null when
109
+ * none settles it. Without `allow` every step runs and two inserts are reported as 'lines', as the
110
+ * Quilt app's ladder does; with it, steps above it are skipped and they are reported as 'additive'.
111
+ */
112
+ function ladder (c, { union = false, allow = null } = {}) {
113
+ if (union) return { lines: [...c.a, ...c.b], how: 'union' }
114
+ const may = (s) => allow == null || STEPS[s] <= STEPS[allow]
115
+ const both = may('additive') ? (c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)) : null
116
+ if (both) return { lines: both, how: allow == null ? 'lines' : 'additive' }
117
+ if (may('whitespace')) {
118
+ if (sameIgnoringWs(c.a, c.b)) return { lines: c.a, how: 'whitespace' } // the same change, spaced differently
119
+ if (sameIgnoringWs(c.a, c.o)) return { lines: c.b, how: 'whitespace' } // ours only reindented
120
+ if (sameIgnoringWs(c.b, c.o)) return { lines: c.a, how: 'whitespace' } // theirs only reindented
121
+ }
122
+ const words = mergeWords(c.o, c.a, c.b)
123
+ if (words) return { lines: words, how: 'words' }
124
+ return null
125
+ }
@@ -16,8 +16,8 @@ import { eventLine, describeLog, describeShow, describeDiff, describeBlame, desc
16
16
  import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
17
17
  import { startDaemon, askDaemon } from './daemon.js'
18
18
  import { rebuild as rebuildBlock, conflictStyle } from './block.js'
19
- import { describeRebuild, describeOpened } from './describe.js'
20
- import { status as blockStatus, commitFolder, amendFolder, syncTo, pull, toLineTip, checkout, restore, reset, clean, localChanges, bytesOf, livePatch } from './block.js'
19
+ import { describeRebuild, describeOpened, describeBoth } from './describe.js'
20
+ import { status as blockStatus, commitFolder, amendFolder, syncTo, pull, toLineTip, checkout, restore, reset, clean, localChanges, bytesOf, livePatch, resolvePulled } from './block.js'
21
21
 
22
22
  export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_square_merged', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_import_remote', 'seam_github', 'seam_github_import', 'seam_import_status', 'seam_import_authors', 'seam_import_poke', 'seam_import_cutover', 'seam_export_remote', 'seam_export_status', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_join', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_stack', 'seam_migrations_dir', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke', 'seam_protections', 'seam_protections_set', 'seam_team_set', 'seam_verify', 'seam_redact', 'seam_redactions', 'seam_seal', 'seam_secret_scanning', 'seam_public', 'seam_sealed', 'seam_policy', 'seam_line_policy_versions', 'seam_orgs', 'seam_org_log', 'seam_org_domain', 'seam_apps', 'seam_app', 'seam_app_key', 'seam_app_manager', 'seam_app_install', 'seam_app_request', 'seam_org_member', 'seam_org_team', 'seam_agent_token_new', 'seam_agent_token_list', 'seam_agent_token_revoke', 'seam_agents_make_lines', 'seam_checks', 'seam_check_rerun', 'seam_reparent', 'seam_archive', 'seam_unarchive', 'seam_hide', 'seam_unhide', 'seam_square_edit', 'seam_share', 'seam_unshare', 'seam_export_tarball', 'seam_claim_request', 'seam_claim_withdraw', 'seam_claim_handoff', 'seam_claim_finish', 'seam_blocks', 'seam_block_open', 'seam_block_touch', 'seam_block_close', 'seam_ancestor', 'seam_contains', 'seam_pipeline', 'seam_watch', 'seam_unwatch', 'seam_absorb', 'seam_interdiff', 'seam_apply_suggestion', 'seam_infra_paths', 'seam_owner_idle_hours', 'seam_public_reads', 'seam_export_archive', 'seam_metrics', 'seam_daemon', 'seam_block_rebuild', 'seam_clone', 'seam_pull', 'seam_member_add']
23
23
 
@@ -84,7 +84,7 @@ export const SEAM_DESCRIPTIONS = {
84
84
  seam_block_touch: 'The heartbeat of an open block: keeps it open (blocks untouched for seven days are closed) and moves a live block to the square\'s current head; add and remove change its folders.',
85
85
  seam_clone: 'Checks out a line of a quilt (main by default) into a new folder on this computer, ready to work: the first commit there opens a square by itself. The folder is named after the quilt unless dir is given.',
86
86
  seam_member_add: 'Adds a member to the quilt or changes their level (an admin): email (a person; one with no Seam account yet is invited, and is in the quilt from their first sign-in) or member_id (person:… or agent:…), level read, write or admin (default write; remove takes them out).',
87
- seam_pull: 'In a block folder: brings the line\'s new patches in (uncommitted edits stay); on a square, rebases it onto its line\'s tip; on a square merged from elsewhere, moves the folder to the line\'s tip.',
87
+ seam_pull: 'In a block folder: brings the line\'s new patches in (uncommitted edits stay; a file changed on both sides is merged, or given conflict markers to settle before it is committed); on a square, rebases it onto its line\'s tip; on a square merged from elsewhere, moves the folder to the line\'s tip.',
88
88
  seam_block_rebuild: 'In a block folder: finishes a move of the folder (rebase, checkout of its square) that stopped part way, from the journal in .seam; edits made meanwhile are kept as yours. On a whole folder it writes nothing.',
89
89
  seam_block_close: 'Closes a block (its row on the server). The square, its work and any files on disk are untouched.',
90
90
  seam_token_list: 'The quilt\'s tokens (keys for one quilt, for CI and scripts): id, name, read or write, and whether revoked. Admins only.',
@@ -165,7 +165,7 @@ export const SEAM_DESCRIPTIONS = {
165
165
  seam_rebase: 'Moves your square onto the tip of its line (or onto a revision). Conflicts become conflict markers in the square, never a stop.',
166
166
  seam_revert: 'Undoes a patch (or commit): a new square from the tip whose one commit inverts it. Merge that square with seam_merge.',
167
167
  seam_cherry_pick: 'Applies one commit\'s change (from) to your square as a new commit.',
168
- seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete | keep_both }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused); when both sides renamed one file, ours or theirs keeps that side\'s path and keep_both keeps both. Or rule: additive (both inserts, in order), whitespace (the side that changed more than spacing), words (word by word) or union (both sides, ours first). (Or edit the file and commit it without the markers.) The answer is remembered: the same conflict met again in a rebase or merge is settled the same way, unless remember: false.',
168
+ seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete | keep_both }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused); when both sides renamed one file, ours or theirs keeps that side\'s path and keep_both keeps both. Or rule: additive (both inserts, in order), whitespace (the side that changed more than spacing), words (word by word) or union (both sides, ours first). (Or edit the file and commit it without the markers.) In a block folder, choose ours or theirs also settles a conflict seam_pull left there. The answer is remembered: the same conflict met again in a rebase or merge is settled the same way, unless remember: false.',
169
169
  seam_resolutions: 'The remembered resolutions: conflicts settled once that seam settles the same way when they come back, per file, and per clashing region within the same squares (path: only that file\'s; hunks: true lists the regions too). One unused for 90 days is dropped.',
170
170
  seam_forget_resolution: 'Forgets a remembered resolution (key: its key or first 8+ characters), so that conflict is asked about again next time.'
171
171
  }
@@ -327,7 +327,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
327
327
  const needQuilt = () => { if (!quilt) throw new Error('Name the quilt (quilt: its slug or id); seam_quilts lists yours.'); return quilt }
328
328
  const needSquare = () => { if (!square) throw new Error('Name the square (square: sq:…); seam_open_square opens one, seam_squares lists them.'); return square }
329
329
  const inFolder = found && block && square === block.square && quilt === block.quilt
330
- const sync = async (head, sq) => { if (inFolder && head && head !== block.head) { const s = await syncTo(client, found.dir, block, head, { square: sq }); return `\nThe folder now holds the square's files (${s.written} updated).` } return '' }
330
+ const sync = async (head, sq) => { if (inFolder && head && head !== block.head) { const s = await syncTo(client, found.dir, block, head, { square: sq }); return `\nThe folder now holds the square's files (${s.written} updated).` + describeBoth(s, { diff: 'seam_diff', resolve: 'seam_resolve (choose ours or theirs)' }) } return '' }
331
331
  try {
332
332
  switch (name) {
333
333
  case 'seam_line_require': {
@@ -592,7 +592,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
592
592
  case 'seam_pull': {
593
593
  if (!found) throw new Error('seam_pull works on a block folder: run seam mcp in a folder made by seam clone or seam checkout.')
594
594
  const r = await pull(client, found.dir, block)
595
- const kept = r.kept?.length ? `\nKept your own version of ${r.kept.join(', ')} (changed on both sides): compare with seam_diff.` : ''
595
+ const kept = describeBoth(r, { diff: 'seam_diff', resolve: 'seam_resolve (choose ours or theirs)' })
596
596
  if (r.noop) return block.square ? `Already up to date: ${block.square_short || block.square} sits on ${block.line}'s tip.` : `Already up to date with ${block.line}.`
597
597
  if (r.left) return `${r.left.square} was ${r.left.state}; the folder is now on ${r.line} (${r.at.slice(0, 12)}, ${r.written} file(s) updated).` + kept
598
598
  if (r.rebased) return describeWrite(r.result, 'Rebased') + `\n${r.written} file(s) updated in the folder.` + kept
@@ -695,7 +695,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
695
695
  const r = await client.merge(needQuilt(), args.into || block?.line || 'main', { square: needSquare(), ...(inFolder ? { expect_head: block.head } : {}), message: args.message, dry_run: !!args.dry_run, on_conflict: args.on_conflict, ...(args.commit_live === false ? { commit_live: false } : {}) })
696
696
  if (r.ok && inFolder && !args.dry_run && r.merged?.commit) {
697
697
  const st = await toLineTip(client, found.dir, block, r.merged.line, r.merged.commit)
698
- return describeMerge(r) + `\nThe folder is now on ${r.merged.line} (${st.written} file(s) updated); the next commit opens a new square.`
698
+ return describeMerge(r) + `\nThe folder is now on ${r.merged.line} (${st.written} file(s) updated); the next commit opens a new square.` + describeBoth(st, { diff: 'seam_diff', resolve: 'seam_resolve (choose ours or theirs)' })
699
699
  }
700
700
  return describeMerge(r) + (!r.ok && r.head ? await sync(r.head, r.square) : '')
701
701
  }
@@ -716,6 +716,13 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
716
716
  return describeWrite(r, 'Picked') + await sync(r.square.head, r.square)
717
717
  }
718
718
  case 'seam_resolve': {
719
+ // A conflict seam pull left in this folder is settled there, not on the server.
720
+ const choices = new Set(args.entries.map((e) => e.choose))
721
+ if (found && block?.conflicts && choices.size === 1 && (choices.has('ours') || choices.has('theirs')) && args.entries.every((e) => block.conflicts[e.path])) {
722
+ const choose = [...choices][0]
723
+ const r = await resolvePulled(client, found.dir, block, { paths: args.entries.map((e) => path.join(found.dir, e.path)), choose })
724
+ return `Settled ${r.settled.join(', ')}: ${choose === 'theirs' ? 'took upstream\'s version' : 'kept your version'}. Commit when ready.`
725
+ }
719
726
  const r = await client.resolve(needQuilt(), needSquare(), { ...(inFolder ? { expect_head: block.head } : {}), entries: args.entries, ...(args.remember === false ? { remember: false } : {}) })
720
727
  return describeWrite(r, 'Resolved') + await sync(r.square.head, r.square)
721
728
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyquilt/seam",
3
- "version": "0.140.0",
3
+ "version": "0.142.0",
4
4
  "description": "seam: Seam's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",