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
↓ koh-4885b255.tar.gzatlas at this save
files changed6 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 −1src/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 }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+}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 {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 }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 }