55fa35690dab

leave files the remote changed out of combine's replay, so an undone local edit no longer blocks every steal (3.0.27, codex review pending)

time
2026-09-23 22:42 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 reliability campaign
parent
84f2455205b5
4 files VERSION+1 −1src/sync/steal.v+27 −2src/sync/sync_test.v+44 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.261+3.0.27

view file

src/sync/steal.v+27 −2
@@ -179,6 +179,13 @@179179 		mine_files = history.files_of(store, our_head.id)!180180 	}181181 182+	// A file the remote changed is a clash, decided below, or one this machine183+	// left as it was or changed the same way, so the remote's version stands.184+	// Replaying this machine's steps on it could only conflict: an edit it185+	// later undid would no longer apply.186+	base_files := history.files_of(store, fetched.base)!187+	settled := changed_paths(base_files, remote_files)188+182189 	// Take the remote's line first, so the replayed saves chain onto it.183190 	history.set_head(store, fetched.head)!184191 	mut current := remote_files.clone()@@ -189,12 +196,12 @@189196 			[]snapshot.FileEntry{}190197 		}191198 		target_files := history.files_of(store, save.id)!192-		plan := apply.three_way(current, parent_files, target_files, [], clashes.map(it.path))199+		plan := apply.three_way(current, parent_files, target_files, [], settled)193200 		if apply.conflicts(plan) {194201 			return error('combine could not apply ${plan.conflict} — it is not a clash file')195202 		}196203 		if plan.applied.len == 0 {197-			// Everything this save touched was a clash resolved as theirs.204+			// Everything this save touched was a file the remote changed.198205 			continue199206 		}200207 		history.record_save(store, '', plan.files, history.SaveInput{@@ -277,6 +284,24 @@277284 		}278285 	}279286 	return clashes287+}288+289+// changed_paths lists the paths whose entry differs between two trees.290+fn changed_paths(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {291+	was := snapshot.index_files(before)292+	now := snapshot.index_files(after)293+	mut paths := []string{}294+	for path, entry in now {295+		if !same(entry, was[path] or { snapshot.FileEntry{} }) {296+			paths << path297+		}298+	}299+	for path, _ in was {300+		if path !in now {301+			paths << path302+		}303+	}304+	return paths280305 }281306 282307 // combine_clashes finds the files both lines changed, with the diff stat each

view file

src/sync/sync_test.v+44 −0
@@ -264,6 +264,50 @@264264 	assert line_head == history.head(b)!.id265265 }266266 267+// An agent that edits a file and then undoes the edit leaves no net change, so268+// the file is not a clash when the other machine changed it. Replaying the269+// edit itself must not stop the steal: the remote's version simply stands.270+// Likewise a file both machines ended up changing the same way.271+fn test_a_local_change_undone_does_not_block_a_combine() {272+	base := scratch('undone')!273+	defer {274+		os.rmdir_all(base) or {}275+	}276+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!277+	mut a := open_repo(machine('undone_a')!)!278+	defer {279+		a.close()280+	}281+	mut b := open_repo(machine('undone_b')!)!282+	defer {283+		b.close()284+	}285+	put(a.root, 'f.txt', 'base\n')!286+	put(a.root, 'g.txt', 'base\n')!287+	commit(a, 'base')!288+	offer_now(a, mut backend)!289+	join(b, mut backend)!290+291+	put(a.root, 'f.txt', 'from A\n')!292+	put(a.root, 'g.txt', 'same\n')!293+	commit(a, 'A edits both')!294+	offer_now(a, mut backend)!295+296+	put(b.root, 'f.txt', 'tried on B\n')!297+	put(b.root, 'g.txt', 'first try\n')!298+	commit(b, 'B tries')!299+	put(b.root, 'f.txt', 'base\n')!300+	put(b.root, 'g.txt', 'same\n')!301+	put(b.root, 'h.txt', 'B\n')!302+	commit(b, 'B undoes f, lands on the same g, adds h')!303+304+	report := steal_now(b, mut backend)!305+	assert report.clashes.len == 0306+	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'307+	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'308+	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'309+}310+267311 // A person deciding a clash may take minutes; the question must come before268312 // the write lock, or every agent's save on this repo waits and then fails.269313 fn test_clash_questions_are_asked_before_the_write_lock() {

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.26'3+	version: '3.0.27'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file