@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.
- package/lib/quilts/block.js +226 -25
- package/lib/quilts/cli.js +16 -11
- package/lib/quilts/describe.js +12 -1
- package/lib/quilts/fastmerge.js +43 -0
- package/lib/quilts/merge-smart.js +125 -0
- package/lib/quilts/tools.js +14 -7
- package/package.json +1 -1
package/lib/quilts/block.js
CHANGED
|
@@ -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
|
-
|
|
178
|
-
const
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
if (!
|
|
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,
|
|
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
|
|
524
|
-
*
|
|
525
|
-
*
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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)
|
|
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': {
|
package/lib/quilts/describe.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/quilts/tools.js
CHANGED
|
@@ -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
|
|
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