ccb80e87aea2

make a strip ban its path: every save loses it whenever made, no walk picks it up again, and strip adds it to .kohignore (3.0.109)

time
2026-09-28 05:48 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
strip removes a path for good
parent
2d6d872724e3
11 files VERSION+1 −1src/cli/cmd_strip.v+12 −4src/exchange/exchange.v+8 −12src/exchange/exchange_test.v+46 −41src/exchange/steal.v+5 −7src/exchange/strip.v+0 −20src/snapshot/ignore.v+29 −0src/snapshot/tree.v+1 −1src/storage/storage.v+14 −0tests/cli_test.v+26 −1v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.1081+3.0.109

view file

src/cli/cmd_strip.v+12 −4
@@ -5,9 +5,8 @@55 //! it asks twice — once the strict `y`, then the path typed out in full. Every66 //! step is safe to repeat, so an interrupted strip finishes when it is re-run.77 //!8-//! The working tree is never touched. Afterwards the file is still on disk and9-//! still untracked by the saves it was removed from, so the reminder to delete10-//! or ignore it matters.8+//! The file itself is left on disk. A whole-history strip bans the path: it is9+//! added to `.kohignore`, and no walk of the tree picks it up again.1110 1211 module cli1312 @@ -72,6 +71,11 @@7271 	// A remote failure is reported, never fatal: the local strip has happened,7372 	// and running the same strip again finishes the remote.7473 	push := push_strip_to_remote(store, path)74+	// A whole-history strip bans the path; the rule in `.kohignore` says so where a person reads,75+	// and travels with the next save.76+	ignored := only_save == '' && (snapshot.add_ignore(store.root, path) or {77+		return refuse(args.json(), err.msg())78+	})7579 	if args.json() {7680 		emit_json(json2.encode(StripJson{7781 			path:                   path@@ -95,7 +99,11 @@9599 			warn('the remote could not be updated: ${push.failed} — run the same strip again to finish it')96100 		}97101 	}98-	note('remember to delete ${path} or add it to ${snapshot.ignore_file}')102+	if only_save != '' {103+		note('remember to delete ${path} or add it to ${snapshot.ignore_file}')104+	} else if ignored {105+		note('added /${path} to ${snapshot.ignore_file} — it can never be saved again')106+	}99107 	return 0100108 }101109 

view file

src/exchange/exchange.v+8 −12
@@ -272,9 +272,8 @@272272 }273273 274274 // write_fetched lands a fetch in one transaction: objects first (verified),275-// then the events, then the saves they belong to. A new strip reaches every276-// save already here, but an arriving save only when `reaches` says so: one277-// made after the strip is new work, not what the strip removed.275+// then the events, then the saves they belong to. A strip bans its path from276+// all of history, so it reaches every save, here or arriving, whenever made.278277 pub fn write_fetched(store &storage.Store, fetched Fetched) ! {279278 	for hash, blob in fetched.repo_objects {280279 		store.put_object_blob(hash, blob)!@@ -285,7 +284,7 @@285284 			continue286285 		}287286 		if event.kind == events.kind_strip {288-			remove_event_path(store, event)!287+			remove_path(store, event.path, event.save_id)!289288 			strips << event290289 		}291290 	}@@ -316,16 +315,13 @@316315 		return317316 	}318317 	files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {319-		return !strips.any(reaches(it, record.id, record.created_at) && covers(it.path, file.path))318+		return !strips.any(reaches(it, record.id) && covers(it.path, file.path))320319 	})321320 	history.import_save(store, to_save(record), files)!322321 }323322 324-// reaches reports whether a strip event applies to a save arriving from the325-// remote. A strip of every save covers the saves made up to its own time.326-fn reaches(strip events.Event, save_id string, created_at i64) bool {327-	if strip.save_id != '' {328-		return strip.save_id == save_id329-	}330-	return created_at <= strip.created_at323+// reaches reports whether a strip event applies to a save: its one save, or324+// every save there is, whenever it was made.325+fn reaches(strip events.Event, save_id string) bool {326+	return strip.save_id == '' || strip.save_id == save_id331327 }

view file

src/exchange/exchange_test.v+46 −41
@@ -895,53 +895,63 @@895895 	assert 'keep.txt' in paths896896 897897 	// The event arrives under its own ID, so it is applied once: B's offer adds898-	// no second copy, and a file B saves again after the strip stays saved899-	// through B's next steal.898+	// no second copy. The path is banned for good: written again, the file is899+	// neither unsaved work nor ever walked into a save.900900 	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)901901 	put(b.root, 'secrets.env', 'rotated\n')!902-	readded := make_save(b, 'add the rotated key back')!902+	assert !dirty(b, history.head(b))!903+	put(b.root, 'keep.txt', 'still fine\n')!904+	later := make_save(b, 'edit keep')!903905 	offer_now(b, mut backend)!904906 	assert backend.list('proj/events')!.len == 1905-	b.begin()!906-	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!907-	b.commit()!908-	assert 'secrets.env' in history.files_of(b, readded.id)!.map(it.path)907+	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)909908 }910909 911-// A strip removes a path from the saves that existed when it was made. A key912-// rotated and saved again afterwards is new work: a machine that clones the913-// repo later must get it, not strip it on arrival.914-fn test_a_clone_keeps_a_file_saved_again_after_a_strip() {915-	base := scratch('strip_clone')!910+// A strip removes a path from all of history, whenever a save was made. A911+// machine that saved the file after the strip, before it heard of it, loses912+// it from that save too on its next steal, so no copy anywhere keeps it.913+fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {914+	base := scratch('strip_later')!916915 	defer {917916 		os.rmdir_all(base) or {}918917 	}919918 	mut backend := remote.new_folder(os.join_path(base, 'remote'))!920-	mut a := open_repo(machine('strip_clone_a')!)!919+	mut a := open_repo(machine('strip_later_a')!)!921920 	defer {922921 		a.close()923922 	}923+	mut b := open_repo(machine('strip_later_b')!)!924+	defer {925+		b.close()926+	}924927 	put(a.root, 'secrets.env', 'api_key=1\n')!925928 	old := make_save(a, 'leak the key')!926929 	offer_now(a, mut backend)!930+	join(b, mut backend)!927931 	strip_local(a, 'secrets.env', '')!928932 	push_strip(a, mut backend, 'secrets.env')!929-	// Saves after the strip must be later in time, as they would be for real.930-	put(a.root, 'secrets.env', 'api_key=2\n')!931-	rotated := history.save_tree(a, a.root, snapshot.walk(a, a.root, snapshot.load(a.root))!, history.SaveInput{933+	// B has not heard, and saves the file again an hour after the strip.934+	put(b.root, 'secrets.env', 'api_key=2\n')!935+	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{932936 		message:    'rotate the key'933-		created_at: events.strip_events(a)!.last().created_at + 60937+		created_at: events.strip_events(a)!.last().created_at + 3600934938 	})!935-	offer_now(a, mut backend)!939+	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)940+	b.begin()!941+	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!942+	b.commit()!943+	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)944+	offer_now(b, mut backend)!936945 937-	mut c := open_repo(machine('strip_clone_c')!)!946+	mut c := open_repo(machine('strip_later_c')!)!938947 	defer {939948 		c.close()940949 	}941950 	join(c, mut backend)!942-	assert 'secrets.env' in history.files_of(c, rotated.id)!.map(it.path)943-	assert 'secrets.env' !in history.files_of(c, old.id)!.map(it.path)944-	assert os.read_file(os.join_path(c.root, 'secrets.env'))! == 'api_key=2\n'951+	for save in [old.id, later.id] {952+		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)953+	}954+	assert !os.exists(os.join_path(c.root, 'secrets.env'))945955 }946956 947957 // The local half commits before the remote hears anything, so a remote failure@@ -1033,9 +1043,9 @@10331043 	assert 'secrets.env' !in record.files.map(it.path)10341044 }10351045 1036-// A strip whose object deletions failed, followed by a second strip of the same1037-// path, must still delete the first strip's objects when it finishes.1038-fn test_a_second_strip_keeps_the_first_ones_unfinished_objects() {1046+// A strip whose object deletions failed must still delete them when it is run1047+// again, and meanwhile the banned path cannot come back in through a save.1048+fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {10391049 	base := scratch('strip_twice')!10401050 	defer {10411051 		os.rmdir_all(base) or {}@@ -1058,9 +1068,9 @@10581068 		assert false, 'the injected delete failure should have surfaced'10591069 	}10601070 	put(a.root, 'secrets.env', 'second key\n')!1061-	make_save(a, 'second key')!1071+	put(a.root, 'other.txt', 'more work\n')!1072+	make_save(a, 'more work')!10621073 	offer_now(a, mut backend)!1063-	strip_local(a, 'secrets.env', '')!10641074 	push_strip(a, mut backend, 'secrets.env')!10651075 	for key in ['first key\n', 'second key\n'] {10661076 		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found@@ -1105,9 +1115,9 @@11051115 	}11061116 }11071117 1108-// Stripping a path, re-adding it, and stripping it again is two strips; a1109-// machine that heard about the first must still apply the second.1110-fn test_a_second_strip_of_the_same_path_reaches_other_machines() {1118+// A strip bans its path on every machine that has heard of it: written again,1119+// the file makes no save, here or there, so there is never a second copy to strip.1120+fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {11111121 	base := scratch('strip_again')!11121122 	defer {11131123 		os.rmdir_all(base) or {}@@ -1128,17 +1138,12 @@11281138 	strip_local(a, 'secrets.env', '')!11291139 	push_strip(a, mut backend, 'secrets.env')!11301140 	steal_now(b, mut backend)!1131-	// Strip leaves the file on disk; deleting it is the user's part.1132-	os.rm(os.join_path(b.root, 'secrets.env'))!1133-	put(a.root, 'secrets.env', 'second key\n')!1134-	second := make_save(a, 'second key')!1135-	offer_now(a, mut backend)!1136-	steal_now(b, mut backend)!1137-	assert 'secrets.env' in history.files_of(b, second.id)!.map(it.path)1138-	strip_local(a, 'secrets.env', '')!1139-	push_strip(a, mut backend, 'secrets.env')!1140-	steal_now(b, mut backend)!1141-	assert 'secrets.env' !in history.files_of(b, second.id)!.map(it.path)1141+	for repo in [a, b] {1142+		put(repo.root, 'secrets.env', 'second key\n')!1143+		if _ := make_save(repo, 'second key') {1144+			assert false, 'a banned path must not make a save'1145+		}1146+	}11421147 }11431148 11441149 // A clash on a path the other machine stripped is no clash: the strip removes

view file

src/exchange/steal.v+5 −7
@@ -397,11 +397,9 @@397397 	// clashes are found on the trees as they will be then, or the answers398398 	// would be about files that are no longer there.399399 	strips := fetched.events_out.filter(it.kind == events.kind_strip)400-	// Base and mine are here already, so every strip reaches them (created 0).401400 	remote_head := fetched.saves.last()402-	mut clashes := find_clashes(after_strips(base_files, fetched.base, 0, strips), after_strips(history.files_of(store,403-		head.id)!, head.id, 0, strips), after_strips(to_files(remote_head), remote_head.id,404-		remote_head.created_at, strips))401+	mut clashes := find_clashes(after_strips(base_files, fetched.base, strips), after_strips(history.files_of(store,402+		head.id)!, head.id, strips), after_strips(to_files(remote_head), remote_head.id, strips))405403 	for mut clash in clashes {406404 		base := fetched_bytes(store, fetched, clash.base)!407405 		mine := fetched_bytes(store, fetched, clash.mine)!@@ -484,9 +482,9 @@484482 485483 // after_strips is a save's file list once the given strip events are applied,486484 // by the same rule write_fetched uses.487-fn after_strips(files []snapshot.FileEntry, save_id string, created_at i64, strips []events.Event) []snapshot.FileEntry {488-	return files.filter(fn [strips, save_id, created_at] (file snapshot.FileEntry) bool {489-		return !strips.any(reaches(it, save_id, created_at) && covers(it.path, file.path))485+fn after_strips(files []snapshot.FileEntry, save_id string, strips []events.Event) []snapshot.FileEntry {486+	return files.filter(fn [strips, save_id] (file snapshot.FileEntry) bool {487+		return !strips.any(reaches(it, save_id) && covers(it.path, file.path))490488 	})491489 }492490 

view file

src/exchange/strip.v+0 −20
@@ -211,26 +211,6 @@211211 	return store.db.changes()212212 }213213 214-// remove_event_path applies a received strip by the time rule it carried.215-fn remove_event_path(store &storage.Store, event events.Event) !int {216-	scope := if event.save_id != '' {217-		' AND save_id = ?3'218-	} else {219-		' AND save_id IN (SELECT id FROM saves WHERE created_at <= ?3)'220-	}221-	mut stmt := store.db.prepare('DELETE FROM save_files WHERE (path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}')!222-	defer { stmt.finalize() }223-	stmt.bind_text(1, event.path)!224-	stmt.bind_text(2, '${event.path.trim_right('/')}/')!225-	if event.save_id != '' {226-		stmt.bind_text(3, event.save_id)!227-	} else {228-		stmt.bind_int(3, event.created_at)!229-	}230-	stmt.step()!231-	return store.db.changes()232-}233-234214 // match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`235215 // and `%` as wildcards; a strip destroys, so the prefix is compared exactly.236216 fn match_clause(only_save string) string {

view file

src/snapshot/ignore.v+29 −0
@@ -65,6 +65,35 @@6565 	return parse(os.read_file(path) or { return Ignore{} })6666 }6767 68+// banning adds a whole-history strip's paths as anchored rules, so the tree agrees with the69+// history on every machine that holds the strip, before `.kohignore` names them there too.70+fn (ig Ignore) banning(paths []string) Ignore {71+	mut patterns := ig.patterns.clone()72+	for path in paths {73+		patterns << Pattern{74+			segments: path.split('/')75+			anchored: true76+		}77+	}78+	return Ignore{79+		patterns: patterns80+	}81+}82+83+/// add_ignore appends an anchored `/<path>` rule to `.kohignore`, unless that line is already84+/// there, and reports whether it wrote one.85+pub fn add_ignore(root string, path string) !bool {86+	file := os.join_path(root, ignore_file)87+	text := if os.is_file(file) { os.read_file(file)! } else { '' }88+	rule := '/${path}'89+	if text.split_into_lines().any(it.trim_space() == rule) {90+		return false91+	}92+	separator := if text == '' || text.ends_with('\n') { '' } else { '\n' }93+	os.write_file(file, '${text}${separator}${rule}\n')!94+	return true95+}96+6897 pub fn parse(text string) Ignore {6998 	mut patterns := []Pattern{}7099 	for raw in text.split_into_lines() {

view file

src/snapshot/tree.v+1 −1
@@ -24,7 +24,7 @@2424 	cache := store.load_cache()!2525 	mut entries := []FileEntry{}2626 	mut fresh := []storage.CacheRow{}27-	walk_dir(root, '', ig, cache, mut entries, mut fresh)!27+	walk_dir(root, '', ig.banning(store.stripped_paths()!), cache, mut entries, mut fresh)!2828 	// Skipped while another command writes, or inside a caller's transaction:2929 	// the next walk hashes those files again, which is all a missing row costs.3030 	store.save_cache_rows(fresh) or {}

view file

src/storage/storage.v+14 −0
@@ -177,6 +177,20 @@177177 	s.db.exec('ROLLBACK') or {}178178 }179179 180+/// stripped_paths are the paths a whole-history strip removed. A strip bans its path for good,181+/// so no walk of the tree may pick it up again.182+pub fn (s &Store) stripped_paths() ![]string {183+	mut stmt := s.db.prepare("SELECT DISTINCT path FROM events WHERE kind = 'strip' AND IFNULL(save_id, '') = ''")!184+	defer {185+		stmt.finalize()186+	}187+	mut paths := []string{}188+	for stmt.step()! {189+		paths << stmt.text(0)190+	}191+	return paths192+}193+180194 pub fn (s &Store) state(key string) ?string {181195 	mut stmt := s.db.prepare('SELECT value FROM state WHERE key = ?') or { return none }182196 	defer {

view file

tests/cli_test.v+26 −1
@@ -83,7 +83,7 @@8383 	// Each answer waits for its prompt: typed ahead, it would be flushed when the pager takes the8484 	// terminal raw. `close` ends a run that is still asking, so `wait` can never hang the suite.8585 	for answer in answers {86-		script += 'expect -re {› \$|message: \$|\\[y/N\\] \$|q quit}\nsend -- "${answer}\\r"\n'86+		script += 'expect -re {› \$|message: \$|confirm: \$|\\[y/N\\] \$|q quit}\nsend -- "${answer}\\r"\n'8787 	}8888 	script += 'expect eof\ncatch close\nset status [wait]\nexit [lindex $status 3]\n'8989 	os.write_file(script_path, script) or { panic(err) }@@ -821,6 +821,31 @@821821 	assert run(dir, 'view', '--json').out == before_view822822 	assert os.read_file(os.join_path(dir, 'secret.txt'))! == 'keep\n'823823 	assert run(dir, 'view', '--json').out.contains('secret')824+}825+826+// A whole-history strip bans the path: `.kohignore` names it, once, and the file left on disk827+// is never unsaved work or part of a save again.828+fn test_strip_adds_the_path_to_kohignore_and_it_stays_out() {829+	dir := temp_repo('strip-ignore')830+	home := temp_repo('strip-ignore-home')831+	defer { os.rmdir_all(dir) or {} }832+	defer { os.rmdir_all(home) or {} }833+	init_repo(dir)834+	write(dir, 'secret.txt', 'leaked\n')835+	write(dir, 'keep.txt', 'fine\n')836+	assert run_home(dir, home, 'save', 'leak').code == 0837+	stripped := run_pty_home(dir, home, ['y', 'secret.txt'], 'strip', 'secret.txt')838+	assert stripped.code == 0, stripped.out839+	assert stripped.out.contains('added /secret.txt to .kohignore')840+	ignore_path := os.join_path(dir, '.kohignore')841+	assert os.read_file(ignore_path)!.split_into_lines().filter(it == '/secret.txt').len == 1842+	assert run_home(dir, home, 'save', 'ignore the stripped secret').code == 0843+	write(dir, 'secret.txt', 'leaked again\n')844+	assert run_home(dir, home, 'status', '--json').out.contains('"dirty":false')845+	// Running the strip again (with no remote, it stays pending) adds no second line.846+	again := run_pty_home(dir, home, [], 'strip', 'secret.txt')847+	assert again.code == 0, again.out848+	assert os.read_file(ignore_path)!.split_into_lines().filter(it == '/secret.txt').len == 1824849 }825850 826851 // `koh view` is the one way into the history: the whole line by default,

view file

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

view file