4885b2554863

confirm and strip multiple paths in one local transaction (3.6.0)

time
2026-10-05 13:35 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
manage ignore rules
parent
9b03deeeb756
6 files VERSION+1 −1src/cli/cmd_strip.v+108 −52src/exchange/exchange_test.v+62 −0src/exchange/strip.v+17 −0tests/cli_test.v+94 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.5.01+3.6.0

view file

src/cli/cmd_strip.v+108 −52
@@ -2,7 +2,7 @@22 //!33 //! This is the only command that destroys information, so it is deliberately44 //! loud: it needs a terminal, it warns about exactly what it will destroy, and5-//! it asks twice — once the strict `y`, then the path typed out in full. Every5+//! it asks once for the batch, then has each path typed out in full. Every66 //! step is safe to repeat, so an interrupted strip finishes when it is re-run.77 //!88 //! The file itself is left on disk. A whole-history strip bans the path: it is@@ -18,67 +18,107 @@1818 import src.storage1919 import src.exchange2020 21-// cmd_strip needs a terminal and takes no --yes: a person confirms twice, and declining exits 1.21+struct StripPlan {22+	path    string23+	scope   exchange.Scope24+	pending bool25+}26+27+// All questions finish before the transaction, so cancelling the last path changes nothing.2228 pub fn cmd_strip(raw []string) int {2329 	args := parse('strip', raw, Options{2430 		values: ['save']2531 		flags:  ['json']2632 	}) or { return usage_error(err.msg()) }27-	path := args.positional_at(0)28-	if path == '' {29-		return usage_error('strip needs a path, e.g. koh strip secrets.env')33+	if args.positional.len == 0 {34+		return usage_error('strip needs paths, e.g. koh strip secrets.env private/')3035 	}3136 	// There is no --yes: a person has to be at the keyboard for this one.3237 	if !interactive() {3338 		return refuse(args.json(), 'strip needs a terminal — an agent can never strip')3439 	}3540 	store := open_store() or { return refuse(args.json(), err.msg()) }36-	defer {37-		store.close()38-	}41+	defer { store.close() }3942 	mut only_save := ''4043 	if given := args.value_or_none('save') {4144 		save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) }4245 		only_save = save.id4346 	}44-	scope := exchange.strip_scope(store, path, only_save) or { return refuse(args.json(), err.msg()) }45-	mut local := exchange.LocalStrip{}46-	if scope.files == 0 {47-		// Nothing left here, but the remote half of an earlier strip may not48-		// have finished; re-running is how it finishes.49-		if !exchange.strip_pending(store, path) {50-			ok('nothing in history matches ${path}')51-			return 047+	mut plans := []StripPlan{}48+	for item in args.positional {49+		// Old strips kept the trailing slash in their retry key.50+		path := if exchange.strip_pending(store, item) { item } else { item.trim_right('/') }51+		if plans.any(it.path == path) {52+			continue5253 		}53-		note('${path} is already stripped here — finishing the remote')54-	} else {55-		where := if only_save == '' { 'every save' } else { 'save ${only_save}' }54+		scope := exchange.strip_scope(store, path, only_save) or {55+			return refuse(args.json(), err.msg())56+		}57+		pending := exchange.strip_pending(store, path)58+		malformed := path == '' || path.starts_with('/')59+			|| path.split('/').any(it in ['', '.', '..'])60+		// Doctor must still be able to remove illegal paths held by older saves.61+		if path == '' || (malformed && scope.files == 0 && !pending) {62+			return refuse(args.json(), 'strip needs repo-relative paths: ${snapshot.escape_path(item)}')63+		}64+		plans << StripPlan{ path: path, scope: scope, pending: pending }65+	}66+	where := if only_save == '' { 'every save' } else { 'save ${only_save}' }67+	for plan in plans {68+		if plan.scope.files == 0 {69+			if plan.pending {70+				note('${snapshot.escape_path(plan.path)} is already stripped here — finishing the remote')71+			} else {72+				note('nothing in history matches ${snapshot.escape_path(plan.path)}')73+			}74+		} else {75+			note('strip ${snapshot.escape_path(plan.path)}: ${fmt.plural(plan.scope.files, 'file')} in ${fmt.plural(plan.scope.saves, 'save')}, ${fmt.plural(plan.scope.objects, 'object')} — ${where}')76+		}77+	}78+	if plans.any(it.scope.files > 0) {5679 		println('  ${fmt.red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')57-		println('  ${fmt.amber('!')} ${fmt.plural(scope.files, 'file')} in ${fmt.plural(scope.saves, 'save')}, ${fmt.plural(scope.objects, 'object')} — ${where}')58-		println('  ${fmt.dim('strip removes the path from history; the file on disk is left alone')}')80+		note('strip removes paths from history; the files on disk are left alone')5981 		approved := confirm(false) or { return refuse(args.json(), err.msg()) }6082 		if !approved {6183 			note('nothing was stripped')6284 			return 16385 		}64-		if !typed_confirm('type the path to confirm:', path) {65-			note('nothing was stripped — the path did not match')66-			return 186+		for plan in plans {87+			if plan.scope.files == 0 {88+				continue89+			}90+			note('confirm ${snapshot.escape_path(plan.path)}')91+			if !typed_confirm('type the path to confirm:', plan.path) {92+				note('nothing was stripped — the path did not match')93+				return 194+			}6795 		}68-		local = exchange.strip_local(store, path, only_save) or {69-			return refuse(args.json(), err.msg())70-		}7196 	}72-	// A remote failure is reported, never fatal: the local strip has happened,73-	// and running the same strip again finishes the remote.74-	sent := send_strip_to_remote(store, path)75-	// A whole-history strip bans the path; the rule in `.kohignore` says so where a person reads,76-	// and travels with the next save.77-	ignored := only_save == '' && (snapshot.add_ignore(store.root, path) or {97+	active := plans.filter(it.scope.files > 0 || it.pending)98+	locals := exchange.strip_local_batch(store, active.map(it.path), only_save) or {7899 		return refuse(args.json(), err.msg())79-	})80-	if args.json() {81-		emit_json(json2.encode(StripJson{100+	}101+	mut reports := []StripJson{}102+	mut index := 0103+	mut exit_code := 0104+	for plan in plans {105+		if plan.scope.files == 0 && !plan.pending {106+			reports << StripJson{ path: plan.path, save: only_save }107+			continue108+		}109+		local := locals[index]110+		index++111+		path := plan.path112+		// An interrupted remote strip keeps its retry note; try every path in the batch.113+		sent := send_strip_to_remote(store, path)114+		mut ignore_failed := ''115+		ignored := only_save == '' && (local.files_removed > 0 || plan.pending)116+			&& (snapshot.add_ignore(store.root, path) or {117+				ignore_failed = '${err.msg()} — add ${snapshot.ignore_rule(path, false, false)} to ${snapshot.ignore_file} by hand; the history ban still applies'118+				exit_code = 1119+				false120+			})121+		reports << StripJson{82122 			path:                   path83123 			save:                   only_save84124 			files_removed:          local.files_removed@@ -86,26 +126,41 @@86126 			records_rewritten:      sent.remote.records87127 			remote_objects_dropped: sent.remote.objects88128 			remote_failed:          sent.failed89-		}))90-		return 0129+			ignore_failed:          ignore_failed130+		}131+		if args.json() {132+			continue133+		}134+		if plan.scope.files > 0 {135+			ok('stripped ${snapshot.escape_path(path)} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(plan.scope.saves, 'save')}')136+			note('${fmt.plural(int(local.objects_dropped), 'object')} dropped')137+		}138+		if !sent.skipped {139+			if sent.failed == '' {140+				note('the remote was updated: ${fmt.plural(sent.remote.records, 'save record')} rewritten, ${fmt.plural(sent.remote.objects, 'object')} dropped')141+			} else {142+				warn('the remote could not be updated: ${sent.failed} — run the same strip again to finish it')143+			}144+		}145+		if ignore_failed != '' {146+			warn('could not update ${snapshot.ignore_file} for ${snapshot.escape_path(path)}: ${ignore_failed}')147+		}148+		if only_save != '' {149+			note('remember to delete ${snapshot.escape_path(path)} or add it to ${snapshot.ignore_file}')150+		} else if ignored {151+			note('added /${snapshot.escape_path(path)} to ${snapshot.ignore_file} — it can never be saved again')152+		}91153 	}92-	if scope.files > 0 {93-		ok('stripped ${path} from ${fmt.plural(local.files_removed, 'file')} across ${fmt.plural(scope.saves, 'save')}')94-		note('${fmt.plural(int(local.objects_dropped), 'object')} dropped')95-	}96-	if !sent.skipped {97-		if sent.failed == '' {98-			note('the remote was updated: ${fmt.plural(sent.remote.records, 'save record')} rewritten, ${fmt.plural(sent.remote.objects, 'object')} dropped')154+	if args.json() {155+		if plans.len == 1 {156+			emit_json(json2.encode(reports[0]))99157 		} else {100-			warn('the remote could not be updated: ${sent.failed} — run the same strip again to finish it')158+			emit_json(json2.encode({159+				'strips': reports160+			}))101161 		}102162 	}103-	if only_save != '' {104-		note('remember to delete ${path} or add it to ${snapshot.ignore_file}')105-	} else if ignored {106-		note('added /${path} to ${snapshot.ignore_file} — it can never be saved again')107-	}108-	return 0163+	return exit_code109164 }110165 111166 struct SendOutcome {@@ -145,4 +200,5 @@145200 	records_rewritten      int146201 	remote_objects_dropped int147202 	remote_failed          string203+	ignore_failed          string148204 }

view file

src/exchange/exchange_test.v+62 −0
@@ -1657,3 +1657,65 @@16571657 		assert false, cut16581658 	}16591659 }1660+1661+fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {1662+	mut store := open_repo(machine('strip_batch')!)!1663+	defer { store.close() }1664+	put(store.root, 'private/a', 'a')!1665+	put(store.root, 'secret', 'secret')!1666+	put(store.root, 'keep', 'keep')!1667+	save := make_save(store, 'initial')!1668+	store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files1669+		WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!1670+	if _ := strip_local_batch(store, ['private', 'secret'], '') {1671+		assert false, 'batch succeeded despite the second path failing'1672+	}1673+	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',1674+		'secret']1675+	assert events.strip_events(store)!.len == 01676+	assert !strip_pending(store, 'private')1677+	store.db.exec('DROP TRIGGER block_secret')!1678+	reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!1679+	assert reports.map(it.files_removed) == [1, 0, 1]1680+	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']1681+	assert events.strip_events(store)!.len == 21682+}1683+1684+fn test_strip_batch_limits_every_path_to_selected_save() {1685+	mut store := open_repo(machine('strip_batch_scope')!)!1686+	defer { store.close() }1687+	put(store.root, 'one', 'one')!1688+	put(store.root, 'two', 'two')!1689+	first := make_save(store, 'initial')!1690+	put(store.root, 'keep', 'keep')!1691+	second := make_save(store, 'next')!1692+	strip_local_batch(store, ['one', 'two'], first.id)!1693+	assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']1694+	assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']1695+	assert events.strip_events(store)!.all(it.save_id == first.id)1696+}1697+1698+fn test_strip_batch_keeps_independent_remote_retry_notes() {1699+	mut backend := remote.new_folder(scratch('strip_batch_remote')!)!1700+	mut store := open_repo(machine('strip_batch_retry')!)!1701+	defer { store.close() }1702+	put(store.root, 'one', 'secret')!1703+	put(store.root, 'two', 'secret')!1704+	save := make_save(store, 'initial')!1705+	offer_now(store, mut backend)!1706+	strip_local_batch(store, ['one', 'two'], '')!1707+	mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }1708+	if _ := send_strip(store, mut flaky, 'one') {1709+		assert false, 'injected remote failure was not reported'1710+	}1711+	send_strip(store, mut backend, 'two')!1712+	assert strip_pending(store, 'one') && !strip_pending(store, 'two')1713+	partial := decode_save(backend.get(save_key('proj', save.id))!.body)!1714+	assert partial.files.map(it.path) == ['.kohignore', 'one']1715+	assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found1716+	send_strip(store, mut backend, 'one')!1717+	assert !strip_pending(store, 'one')1718+	finished := decode_save(backend.get(save_key('proj', save.id))!.body)!1719+	assert finished.files.map(it.path) == ['.kohignore']1720+	assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found1721+}

view file

src/exchange/strip.v+17 −0
@@ -93,6 +93,23 @@9393 	return report9494 }9595 96+/// strip_local_batch commits every selected path together, or leaves history unchanged.97+pub fn strip_local_batch(store &storage.Store, paths []string, only_save string) ![]LocalStrip {98+	store.begin()!99+	mut reports := []LocalStrip{}100+	for path in paths {101+		reports << strip_rows(store, path, only_save) or {102+			store.rollback()103+			return err104+		}105+	}106+	store.end() or {107+		store.rollback()108+		return err109+	}110+	return reports111+}112+96113 // strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history97114 // strip (spec §10.2), and that empty id is what bans the path from every later walk.98115 fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {

view file

tests/cli_test.v+94 −0
@@ -4,6 +4,8 @@44 import net.http55 import os66 import time7+import src.exchange8+import src.storage79 810 const root = @VMODROOT911 const binary = os.join_path(root, 'build', 'koh3')@@ -1884,4 +1886,96 @@18841886 	write(repo, '.kohignore', '# keep\r\n*.log\r\nlast')18851887 	assert run_home(repo, home, 'ignore', 'one.log', '--yes').code == 018861888 	assert os.read_file(os.join_path(repo, '.kohignore'))! == '# keep\r\nlast'1889+}1890+1891+fn test_strip_multiple_paths_confirms_entire_batch_before_changes() {1892+	repo := temp_repo('strip-batch')1893+	home := temp_repo('strip-home')1894+	defer {1895+		os.rmdir_all(repo) or {}1896+		os.rmdir_all(home) or {}1897+	}1898+	assert run_home(repo, home, 'init').code == 01899+	write(repo, 'private/a', 'a')1900+	write(repo, 'secret.txt', 'secret')1901+	write(repo, 'keep.txt', 'keep')1902+	assert run_home(repo, home, 'save', 'initial').code == 01903+	before := os.read_file(os.join_path(repo, '.kohignore'))!1904+	cancel := run_pty_home(repo, home, ['y', 'private', ''], 'strip', 'private/', 'secret.txt')1905+	assert cancel.code == 11906+	assert cancel.out.contains('private') && cancel.out.contains('secret.txt')1907+	assert os.read_file(os.join_path(repo, '.kohignore'))! == before1908+	assert run_home(repo, home, 'status', '--json').out.contains('"dirty":false')1909+	assert run_pty_home(repo, home, [], 'strip', 'private', '../bad').code == 11910+	assert os.read_file(os.join_path(repo, '.kohignore'))! == before1911+	stripped := run_pty_home(repo, home, ['y', 'private', 'private/a', 'secret.txt'], 'strip', 'private/', 'private', 'private/a', 'secret.txt', '--json')1912+	assert stripped.code == 0, stripped.out1913+	assert stripped.out.contains('"strips":[')1914+	assert stripped.out.contains('"path":"private"') && stripped.out.contains('"path":"secret.txt"')1915+	assert os.read_file(os.join_path(repo, 'secret.txt'))! == 'secret'1916+	assert os.read_file(os.join_path(repo, 'private/a'))! == 'a'1917+	text := os.read_file(os.join_path(repo, '.kohignore'))!1918+	assert text.count('/private\n') == 1 && text.count('/secret.txt\n') == 11919+	assert !text.contains('/private/a\n')1920+	assert run_home(repo, home, 'save', 'rules').code == 01921+	assert run_home(repo, home, 'status', '--json').out.contains('"dirty":false')1922+}1923+1924+fn test_strip_legacy_paths_and_trailing_slash_remote_retry() {1925+	repo := temp_repo('strip-legacy')1926+	home := temp_repo('strip-home')1927+	remote_dir := temp_repo('strip-remote')1928+	defer {1929+		os.rmdir_all(repo) or {}1930+		os.rmdir_all(home) or {}1931+		os.rmdir_all(remote_dir) or {}1932+	}1933+	assert run_home(repo, home, 'init').code == 01934+	write(repo, 'secret', 'secret')1935+	assert run_home(repo, home, 'save', 'legacy').code == 01936+	store := storage.open(repo)!1937+	store.db.exec("UPDATE save_files SET path = '../unsafe' WHERE path = 'secret'")!1938+	store.close()1939+	legacy := run_pty_home(repo, home, ['y', '../unsafe'], 'strip', '../unsafe')1940+	assert legacy.code == 0, legacy.out1941+	assert run_home(repo, home, 'doctor', '--json').code == 01942+	write(repo, 'private/a', 'private')1943+	assert run_home(repo, home, 'remote', 'folder', remote_dir).code == 01944+	assert run_home(repo, home, 'save', 'folder').code == 01945+	old_store := storage.open(repo)!1946+	exchange.strip_local(old_store, 'private/', '')!1947+	old_store.close()1948+	retried := run_pty_home(repo, home, [], 'strip', 'private/')1949+	assert retried.code == 0 && retried.out.contains('already stripped'), retried.out1950+	checked := storage.open(repo)!1951+	defer { checked.close() }1952+	assert !exchange.strip_pending(checked, 'private/')1953+}1954+1955+fn test_strip_batch_reports_each_ignore_write_failure() {1956+	repo := temp_repo('strip-rule-failure')1957+	home := temp_repo('strip-home')1958+	remote_dir := temp_repo('strip-rule-remote')1959+	defer {1960+		os.rmdir_all(repo) or {}1961+		os.rmdir_all(home) or {}1962+		os.rmdir_all(remote_dir) or {}1963+	}1964+	assert run_home(repo, home, 'init').code == 01965+	write(repo, 'one', 'one')1966+	write(repo, 'two', 'two')1967+	assert run_home(repo, home, 'remote', 'folder', remote_dir).code == 01968+	assert run_home(repo, home, 'save', 'initial').code == 01969+	os.rename(os.join_path(repo, '.kohignore'), os.join_path(repo, 'rules-backup'))!1970+	os.mkdir(os.join_path(repo, '.kohignore'))!1971+	failed := run_pty_home(repo, home, ['y', 'one', 'two'], 'strip', 'one', 'two', '--json')1972+	assert failed.code == 1, failed.out1973+	assert failed.out.count('"ignore_failed":') == 21974+	assert failed.out.contains('by hand') && failed.out.contains('history ban still applies')1975+	assert failed.out.contains('"path":"one"') && failed.out.contains('"path":"two"')1976+	store := storage.open(repo)!1977+	defer { store.close() }1978+	assert exchange.strip_scope(store, 'one', '')!.files == 01979+	assert exchange.strip_scope(store, 'two', '')!.files == 01980+	assert !exchange.strip_pending(store, 'one') && !exchange.strip_pending(store, 'two')18871981 }

view file

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

view file