9b03deeeb756
add confirmed batch ignore and machine-wide rules (3.5.0)
- time
- 2026-10-05 13:19 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- gpt-6
- session
- manage ignore rules
- parent
- f65a96914d11
↓ koh-9b03deee.tar.gzatlas at this save
files changed13 files
VERSION+1 −1shell/koh3.plugin.zsh+2 −0src/cli/cli.v+4 −1src/cli/cmd_back_test.v+22 −0src/cli/cmd_ignore.v+207 −0src/exchange/exchange_test.v+5 −0src/history/history_test.v+5 −0src/migrate/migrate_test.v+5 −0src/release/release_test.v+5 −0src/snapshot/ignore.v+37 −6src/snapshot/snapshot_test.v+15 −0tests/cli_test.v+136 −0v.mod+1 −1shell/koh3.plugin.zsh+2 −0
@@ -136,6 +136,7 @@136136 'peek:restore a save into a temp folder'137137 'init:create .koh here'138138 'doctor:integrity check and cleanup'139+ 'ignore:ignore or unignore files and folders'139140 'strip:remove a path from history'140141 'build:build from koh.build'141142 'update:update Koh itself'@@ -163,6 +164,7 @@163164 revert) _arguments '--yes' ;;164165 remote) _arguments '1:kind:(folder s3)' '--forget' '--json' ;;165166 steal) _arguments '--list' '--mine' '--theirs' '--json' ;;167+ ignore) _arguments '--global[edit ~/.kohignore for every repo]' '--yes[skip the question]' '--json' '*:path:_files' ;;166168 strip) _arguments '--save[one save]:save:_koh3_ids' '*:path:_files' ;;167169 doctor) _arguments '--backup[copy .koh to]:path:_files' ;;168170 build) _arguments '*--platform[target platform]:platform:(macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64)' '--all' ;;src/cli/cli.v+4 −1
@@ -33,7 +33,7 @@3333 text += '\n ${fmt.gold(row[0])}${' '.repeat(12 - row[0].len)}${fmt.muted(row[1])}\n'3434 }3535 text += '\n ${fmt.dim('also: remote · flag · pluck · peek · build · update')}\n'36- text += ' ${fmt.dim('init · doctor · strip · migrate')}\n'36+ text += ' ${fmt.dim('init · doctor · ignore · strip · migrate')}\n'3737 text += '\n ${fmt.dim('koh man <command> for options and examples')}\n'3838 text += '\n ${fmt.dim('Asha Software · asha.software')}\n'3939 return text@@ -95,6 +95,9 @@9595 }9696 'doctor' {9797 return cmd_doctor(rest)98+ }99+ 'ignore' {100+ return cmd_ignore(rest)98101 }99102 'strip' {100103 return cmd_strip(rest)src/cli/cmd_back_test.v+22 −0
@@ -7,6 +7,11 @@77 88 // The history view's `l` key goes through restore_now, which a CLI test cannot99 // reach without a terminal, so it is checked here directly.10+// Global rules must not make source fixtures depend on the developer’s home.11+fn testsuite_begin() {12+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)13+}14+1015 fn test_restore_now_refuses_to_overwrite_unsaved_work() {1116 dir := os.join_path(os.temp_dir(), 'koh3_cli_restore_now_${os.getpid()}')1217 os.rmdir_all(dir) or {}@@ -101,4 +106,21 @@101106 assert false, 'recording a save with a missing object should fail'102107 }103108 assert snapshot.restore_pending(store)109+}110+111+fn test_ignore_write_refuses_stale_rules_and_preserves_permissions() {112+ dir := os.join_path(os.temp_dir(), 'koh3_cli_ignore_${os.getpid()}')113+ os.mkdir_all(dir)!114+ defer { os.rmdir_all(dir) or {} }115+ file := os.join_path(dir, '.kohignore')116+ os.write_file(file, '# changed\n')!117+ if _ := write_ignore(file, '# original\n', '/cache/\n') {118+ assert false, 'stale preview replaced changed rules'119+ }120+ assert os.read_file(file)! == '# changed\n'121+ os.chmod(file, 0o600)!122+ write_ignore(file, '# changed\n', '# changed\n/cache/\n')!123+ assert os.stat(file)!.mode & 0o777 == 0o600124+ assert os.read_file(file)! == '# changed\n/cache/\n'125+ assert os.ls(dir)! == ['.kohignore']104126 }src/cli/cmd_ignore.v+207 −0
@@ -0,0 +1,207 @@1+//! Batch ignore toggles: plan from the original rules, then confirm one file replacement.2+3+module cli4+5+import json26+import os7+import rand8+import src.snapshot9+import src.storage10+11+const ignore_notice = 'Ignoring leaves files in older saves. To remove a file or folder from all history, use koh strip <path>.'12+13+struct IgnoreChange {14+ path string15+ action string16+ rules []string17+ remaining []string18+}19+20+struct IgnorePreview {21+ file string22+ changes []IgnoreChange23+ changed bool24+ notice string = ignore_notice25+}26+27+fn ignore_text(file string, editing bool) !string {28+ if (editing && os.is_link(file)) || (os.exists(file) && !os.is_file(file)) {29+ return error('${file} must be a regular file')30+ }31+ return if os.exists(file) { os.read_file(file)! } else { '' }32+}33+34+fn cmd_ignore(raw []string) int {35+ args := parse('ignore', raw, flags: ['global', 'yes', 'json']) or {36+ return usage_error(err.msg())37+ }38+ if args.positional.len == 0 {39+ return usage_error('usage: koh ignore <path>... [--global] [--yes] [--json]')40+ }41+ global := args.flag('global')42+ root := if global {43+ os.getwd()44+ } else {45+ repo_root() or { return refuse(args.json(), err.msg()) }46+ }47+ file := if global {48+ snapshot.global_ignore_path()49+ } else {50+ os.join_path(root, snapshot.ignore_file)51+ }52+ original := ignore_text(file, true) or { return refuse(args.json(), err.msg()) }53+ other_file := if global {54+ if repo := storage.find_root(os.getwd()) { os.join_path(repo, snapshot.ignore_file) } else { '' }55+ } else {56+ snapshot.global_ignore_path()57+ }58+ other := if other_file != '' && other_file != file {59+ ignore_text(other_file, false) or { return refuse(args.json(), err.msg()) }60+ } else {61+ ''62+ }63+ mut stripped := []string{}64+ if !global {65+ mut store := open_store() or { return refuse(args.json(), err.msg()) }66+ defer { store.close() }67+ stripped = store.stripped_paths() or { return refuse(args.json(), err.msg()) }68+ }69+ mut changes := []IgnoreChange{}70+ mut remove := []string{}71+ mut add := []string{}72+ for item in args.positional {73+ path, is_dir := normalize_ignore_path(root, item, global) or {74+ return refuse(args.json(), err.msg())75+ }76+ if changes.any(it.path == path) {77+ continue78+ }79+ if stripped.any(path == it || path.starts_with(it + '/')) {80+ return refuse(args.json(), '${path} is permanently stripped from history')81+ }82+ rules := snapshot.matching_rules(original, path, is_dir)83+ remaining_path := if global && other_file != '' {84+ os.abs_path(item)[os.dir(other_file).len + 1..]85+ } else {86+ path87+ }88+ remaining := snapshot.matching_rules(other, remaining_path, is_dir)89+ action := if rules.len > 0 { 'unignore' } else { 'ignore' }90+ chosen := if rules.len > 0 { rules } else { [snapshot.ignore_rule(path, is_dir, global)] }91+ changes << IgnoreChange{ path: path, action: action, rules: chosen, remaining: remaining }92+ if rules.len > 0 {93+ for rule in rules {94+ if rule !in remove { remove << rule }95+ }96+ } else {97+ add << chosen[0]98+ }99+ }100+ mut updated := ''101+ // Keep each unrelated line byte-for-byte, including its newline convention.102+ lines := original.split('\n')103+ for i, line in lines {104+ if line.trim_space() !in remove {105+ updated += line106+ if i < lines.len - 1 {107+ updated += '\n'108+ }109+ }110+ }111+ if add.len > 0 {112+ if updated != '' && !updated.ends_with('\n') {113+ updated += '\n'114+ }115+ updated += add.join('\n') + '\n'116+ }117+ preview := IgnorePreview{ file: file, changes: changes }118+ if args.json() {119+ if !args.flag('yes') {120+ emit_json(json2.encode(preview))121+ return refuse(true, 'needs --yes with --json')122+ }123+ } else {124+ note('change ${file}:')125+ for change in changes {126+ note(' ${change.action} ${change.path}')127+ for rule in change.rules {128+ note(' ${if change.action == 'ignore' { 'add' } else { 'remove' }} ${snapshot.escape_path(rule)}')129+ }130+ if change.remaining.len > 0 {131+ warn(' still ignored by ${other_file}: ${change.remaining.map(snapshot.escape_path(it)).join(', ')}')132+ }133+ }134+ if remove.len > 0 { warn('removing these rules may unignore other items they match') }135+ note(ignore_notice)136+ }137+ approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }138+ if !approved {139+ return refuse(args.json(), 'nothing changed')140+ }141+ other_now := if other_file != '' && other_file != file {142+ ignore_text(other_file, false) or { return refuse(args.json(), err.msg()) }143+ } else {144+ ''145+ }146+ if other_now != other {147+ return refuse(args.json(), 'ignore rules changed since the preview — run koh ignore again')148+ }149+ write_ignore(file, original, updated) or { return refuse(args.json(), err.msg()) }150+ if args.json() {151+ emit_json(json2.encode(IgnorePreview{ file: file, changes: changes, changed: true }))152+ } else {153+ ok('updated ${file}')154+ }155+ return 0156+}157+158+fn normalize_ignore_path(root string, item string, global bool) !(string, bool) {159+ if global && os.is_abs_path(item) {160+ return error('global paths must be relative: ${snapshot.escape_path(item)}')161+ }162+ absolute := os.abs_path(item)163+ path := if global {164+ os.norm_path(item.trim_right('/'))165+ } else {166+ if !absolute.starts_with(root + '/') {167+ return error('path must be inside ${root}: ${snapshot.escape_path(item)}')168+ }169+ absolute[root.len + 1..]170+ }171+ if !snapshot.legal_path(path) || path.trim_space() != path172+ || snapshot.hard_ignored(path) || path.split('/').contains(snapshot.ignore_file) {173+ return error('cannot ignore this path: ${snapshot.escape_path(item)}')174+ }175+ mut parent := root176+ for part in path.split('/') {177+ parent = os.join_path(parent, part)178+ if os.is_link(parent) {179+ return error('cannot ignore a symlink: ${snapshot.escape_path(item)}')180+ }181+ }182+ is_dir := os.is_dir(absolute) || item.ends_with('/')183+ if item.ends_with('/') && os.is_file(absolute) {184+ return error('${snapshot.escape_path(item)} is a file')185+ }186+ return path, is_dir187+}188+189+fn write_ignore(file string, original string, updated string) ! {190+ if ignore_text(file, true)! != original {191+ return error('ignore rules changed since the preview — run koh ignore again')192+ }193+ // A private directory beside the destination keeps the rename on one filesystem.194+ temporary := '${file}.tmp-${rand.uuid_v4()}'195+ os.mkdir(temporary, mode: 0o700)!196+ staged := os.join_path(temporary, 'rules')197+ defer {198+ os.rm(staged) or {}199+ os.rmdir(temporary) or {}200+ }201+ os.write_file(staged, updated)!202+ if os.exists(file) { os.chmod(staged, int(os.stat(file)!.mode & 0o777))! }203+ if ignore_text(file, true)! != original {204+ return error('ignore rules changed since the preview — run koh ignore again')205+ }206+ os.rename(staged, file)!207+}src/exchange/exchange_test.v+5 −0
@@ -11,6 +11,11 @@1111 import src.snapshot1212 import src.storage1313 14+// Global rules must not make source fixtures depend on the developer’s home.15+fn testsuite_begin() {16+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)17+}18+1419 fn test_arriving_record_drops_unsafe_paths_and_reports_them() {1520 mut record := SaveRecord{1621 files: [src/history/history_test.v+5 −0
@@ -4,6 +4,11 @@44 import src.snapshot55 import src.storage66 7+// Global rules must not make source fixtures depend on the developer’s home.8+fn testsuite_begin() {9+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)10+}11+712 fn temp_repo(name string) !(storage.Store, string) {813 dir := os.join_path(os.temp_dir(), 'koh3_hist_${os.getpid()}_${name}')914 os.rmdir_all(dir) or {}src/migrate/migrate_test.v+5 −0
@@ -22,6 +22,11 @@2222 }2323 2424 // koh2_binary finds Koh 2: `koh2` since the cutover, `koh` before it, and never Koh 3.25+// Global rules must not make source fixtures depend on the developer’s home.26+fn testsuite_begin() {27+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)28+}29+2530 fn quiet(stage string, done int, total int) {}2631 2732 fn koh2_binary() ?string {src/release/release_test.v+5 −0
@@ -13,6 +13,11 @@1313 import src.snapshot1414 import src.storage1515 16+// Global rules must not make source fixtures depend on the developer’s home.17+fn testsuite_begin() {18+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)19+}20+1621 fn scratch(name string) !string {1722 dir := os.join_path(os.temp_dir(), 'koh3_release_${os.getpid()}_${name}')1823 os.rmdir_all(dir) or {}src/snapshot/ignore.v+37 −6
@@ -57,15 +57,46 @@5757 patterns []Pattern5858 }5959 60-// load reads `.kohignore`; a repo without one simply has no extra rules.60+/// global_ignore_path is the machine-local rule file, outside repository history.61+pub fn global_ignore_path() string {62+ return os.join_path(os.home_dir(), ignore_file)63+}64+65+// Both scopes are additive, so a local rule never overrides a global one.6166 pub fn load(root string) Ignore {62- path := os.join_path(root, ignore_file)63- if !os.is_file(path) {64- return Ignore{}67+ local := os.read_file(os.join_path(root, ignore_file)) or { '' }68+ global := os.read_file(global_ignore_path()) or { '' }69+ return parse(global + '\n' + local)70+}71+72+/// matching_rules returns the lines that make the walk skip a literal path.73+pub fn matching_rules(text string, rel string, is_dir bool) []string {74+ mut rules := []string{}75+ for raw in text.split_into_lines() {76+ rule := raw.trim_space()77+ if rule == '' || rule.starts_with('#') {78+ continue79+ }80+ ig := parse(rule)81+ mut matched := ig.matches(rel, is_dir)82+ parts := rel.split('/')83+ for end in 1 .. parts.len {84+ matched = matched || ig.matches(parts[..end].join('/'), true)85+ }86+ if matched && rule !in rules {87+ rules << rule88+ }6589 }66- return parse(os.read_file(path) or { return Ignore{} })90+ return rules6791 }6892 93+/// ignore_rule escapes a literal path in the selected scope.94+pub fn ignore_rule(path string, is_dir bool, global bool) string {95+ prefix := if global { '' } else { '/' }96+ suffix := if is_dir { '/' } else { '' }97+ return prefix + literal(path) + suffix98+}99+69100 // banning adds a whole-history strip's paths as anchored rules, so the tree agrees with the70101 // history on every machine that holds the strip, before `.kohignore` names them there too.71102 fn (ig Ignore) banning(paths []string) Ignore {@@ -97,7 +128,7 @@97128 98129 // literal escapes a path so a rule names it and nothing else.99130 fn literal(path string) string {100- return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?')131+ return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?').replace('#', '\\#')101132 }102133 103134 pub fn parse(text string) Ignore {src/snapshot/snapshot_test.v+15 −0
@@ -4,6 +4,11 @@44 import time55 import src.storage66 7+// Global rules must not make source fixtures depend on the developer’s home.8+fn testsuite_begin() {9+ os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)10+}11+712 fn temp_root(name string) !string {813 dir := os.join_path(os.temp_dir(), 'koh3_snap_${os.getpid()}_${name}')914 os.rmdir_all(dir) or {}@@ -370,4 +375,14 @@370375 371376 fn full_path(root string, rel string) string {372377 return os.join_path(root, rel)378+}379+380+fn test_ignore_toggle_rules_follow_directory_walk_and_escape_literals() {381+ assert matching_rules('# keep\ncache\n*.log\n', 'nested/cache/a', false) == ['cache']382+ assert matching_rules('/cache/\ncache/**\n', 'cache/a', false) == ['/cache/', 'cache/**']383+ assert matching_rules('*.log\n', 'notes.txt', false).len == 0384+ rule := ignore_rule('#draft*.txt', false, true)385+ assert parse(rule).matches('#draft*.txt', false)386+ assert !parse(rule).matches('#draftX.txt', false)387+ assert ignore_rule('cache', true, false) == '/cache/'373388 }tests/cli_test.v+136 −0
@@ -1749,3 +1749,139 @@17491749 assert r.err.contains("doesn't hand out bucket access")17501750 assert !os.exists(os.join_path(home, '.config', 'koh', 'remote'))17511751 }1752+1753+fn test_ignore_batch_preview_cancel_and_toggle() {1754+ repo := temp_repo('ignore-batch')1755+ home := temp_repo('ignore-home')1756+ defer {1757+ os.rmdir_all(repo) or {}1758+ os.rmdir_all(home) or {}1759+ }1760+ assert run_home(repo, home, 'init').code == 01761+ assert run_home(repo, home, 'save', 'initial').code == 01762+ write(repo, 'one/a.txt', 'a')1763+ write(repo, 'two/b.txt', 'b')1764+ write(repo, 'literal*.txt', 'literal')1765+ before := os.read_file(os.join_path(repo, '.kohignore'))!1766+ refused := run_home(repo, home, 'ignore', 'one', 'two', 'literal*.txt', '--json')1767+ assert refused.code == 11768+ assert refused.out.contains('"changed":false')1769+ assert refused.out.contains('koh strip <path>')1770+ assert os.read_file(os.join_path(repo, '.kohignore'))! == before1771+ cancel := run_pty_home(repo, home, [''], 'ignore', 'one', 'two', 'literal*.txt')1772+ assert cancel.code == 11773+ assert cancel.out.contains('/one/') && cancel.out.contains('/two/')1774+ assert os.read_file(os.join_path(repo, '.kohignore'))! == before1775+ assert run_home(repo, home, 'ignore', 'one').code == 11776+ assert os.read_file(os.join_path(repo, '.kohignore'))! == before1777+ assert run_pty_home(repo, home, ['y'], 'ignore', 'confirmed.txt').code == 01778+ added := run_home(repo, home, 'ignore', 'one', './one', 'two', 'literal*.txt', '--yes', '--json')1779+ assert added.code == 01780+ assert added.out.contains('"changed":true')1781+ text := os.read_file(os.join_path(repo, '.kohignore'))!1782+ assert text.count('/one/\n') == 11783+ assert text.contains('/literal\\*.txt\n')1784+ status := run_home(repo, home, 'status', '--json')1785+ assert status.code == 01786+ assert !status.out.contains('one/a.txt') && !status.out.contains('literal*.txt')1787+ assert run_home(repo, home, 'ignore', 'one', '--yes').code == 01788+ assert run_home(repo, home, 'status', '--json').out.contains('one/a.txt')1789+ assert run_home(repo, home, 'ignore', 'two/b.txt', '--yes').code == 01790+ assert !os.read_file(os.join_path(repo, '.kohignore'))!.contains('/two/')1791+}1792+1793+fn test_ignore_global_rules_and_scope_overlap() {1794+ repo := temp_repo('ignore-global')1795+ other := temp_repo('ignore-other')1796+ home := temp_repo('ignore-home')1797+ defer {1798+ os.rmdir_all(repo) or {}1799+ os.rmdir_all(other) or {}1800+ os.rmdir_all(home) or {}1801+ }1802+ assert run_home(repo, home, 'init').code == 01803+ assert run_home(repo, home, 'save', 'initial').code == 01804+ assert run_home(other, home, 'init').code == 01805+ assert run_home(other, home, 'save', 'initial').code == 01806+ write(repo, 'cache/a', 'a')1807+ write(other, 'nested/cache/b', 'b')1808+ assert run_home(repo, home, 'ignore', 'cache', '--yes').code == 01809+ assert run_home(repo, home, 'ignore', 'cache', '--global', '--yes').code == 01810+ assert os.read_file(os.join_path(home, '.kohignore'))! == 'cache/\n'1811+ assert run_home(other, home, 'save', 'global rules applied').code == 11812+ assert !run_home(other, home, 'status', '--json').out.contains('nested/cache/b')1813+ result := run_home(repo, home, 'ignore', 'cache', '--yes')1814+ assert result.code == 0 && result.out.contains('still ignored')1815+ assert !run_home(repo, home, 'status', '--json').out.contains('cache/a')1816+ assert run_home(repo, home, 'ignore', 'cache', '--global', '--yes').code == 01817+ assert run_home(repo, home, 'status', '--json').out.contains('cache/a')1818+ assert run_home(other, home, 'status', '--json').out.contains('nested/cache/b')1819+ assert run_home(home, home, 'ignore', 'missing/', '--global', '--yes').code == 01820+}1821+1822+fn test_ignore_broad_rules_mixed_batch_and_invalid_paths() {1823+ repo := temp_repo('ignore-rules')1824+ home := temp_repo('ignore-home')1825+ defer {1826+ os.rmdir_all(repo) or {}1827+ os.rmdir_all(home) or {}1828+ }1829+ assert run_home(repo, home, 'init').code == 01830+ assert run_home(repo, home, 'save', 'initial').code == 01831+ write(repo, '.kohignore', '# keep this\n*.log\n/folder/\n')1832+ write(repo, 'folder/a', 'a')1833+ os.symlink(home, os.join_path(repo, 'link'))!1834+ before := os.read_file(os.join_path(repo, '.kohignore'))!1835+ for bad in ['../outside', '.koh', '.kohignore', '.', 'link/a', 'bad\nname'] {1836+ assert run_home(repo, home, 'ignore', 'good', bad, '--yes').code == 11837+ assert os.read_file(os.join_path(repo, '.kohignore'))! == before1838+ }1839+ mixed := run_home(repo, home, 'ignore', 'a.log', 'new.txt', '--yes')1840+ assert mixed.code == 0 && mixed.out.contains('other items')1841+ assert os.read_file(os.join_path(repo, '.kohignore'))! == '# keep this\n/folder/\n/new.txt\n'1842+ assert run_home(os.join_path(repo, 'folder'), home, 'ignore', 'a', '--yes').code == 01843+ assert !os.read_file(os.join_path(repo, '.kohignore'))!.contains('/folder/')1844+ assert run_home(repo, home, 'ignore').code == 21845+}1846+1847+fn test_ignore_reads_symlinked_other_scope_and_subdirectory_rules() {1848+ repo := temp_repo('ignore-symlink')1849+ home := temp_repo('ignore-home')1850+ defer {1851+ os.rmdir_all(repo) or {}1852+ os.rmdir_all(home) or {}1853+ }1854+ assert run_home(repo, home, 'init').code == 01855+ write(home, 'rules', 'cache/\n')1856+ os.symlink(os.join_path(home, 'rules'), os.join_path(home, '.kohignore'))!1857+ local := run_home(repo, home, 'ignore', 'cache/', '--yes')1858+ assert local.code == 0 && local.out.contains('still ignored')1859+ assert run_home(repo, home, 'ignore', 'cache/', '--global', '--yes').code == 11860+ os.rm(os.join_path(home, '.kohignore'))!1861+ write(home, '.kohignore', 'cache/\n')1862+ write(repo, '.kohignore', '/sub/cache/\n')1863+ write(repo, 'sub/cache/a', 'a')1864+ global := run_home(os.join_path(repo, 'sub'), home, 'ignore', 'cache', '--global', '--yes')1865+ assert global.code == 0 && global.out.contains('still ignored')1866+ assert os.read_file(os.join_path(repo, '.kohignore'))! == '/sub/cache/\n'1867+ assert os.read_file(os.join_path(home, '.kohignore'))! == ''1868+}1869+1870+fn test_ignore_refuses_stripped_paths_and_preserves_crlf_comments() {1871+ repo := temp_repo('ignore-stripped')1872+ home := temp_repo('ignore-home')1873+ defer {1874+ os.rmdir_all(repo) or {}1875+ os.rmdir_all(home) or {}1876+ }1877+ assert run_home(repo, home, 'init').code == 01878+ write(repo, 'secret', 'secret')1879+ assert run_home(repo, home, 'save', 'initial').code == 01880+ assert run_pty_home(repo, home, ['y', 'secret'], 'strip', 'secret').code == 01881+ before := os.read_file(os.join_path(repo, '.kohignore'))!1882+ assert run_home(repo, home, 'ignore', 'secret', '--yes').code == 11883+ assert os.read_file(os.join_path(repo, '.kohignore'))! == before1884+ write(repo, '.kohignore', '# keep\r\n*.log\r\nlast')1885+ assert run_home(repo, home, 'ignore', 'one.log', '--yes').code == 01886+ assert os.read_file(os.join_path(repo, '.kohignore'))! == '# keep\r\nlast'1887+}v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.4.6'3+ version: '3.5.0'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }