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
13 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.4.61+3.5.0

view file

shell/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' ;;

view file

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)

view file

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 }

view file

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+}

view file

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: [

view file

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 {}

view file

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 {

view file

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 {}

view file

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 {

view file

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 }

view file

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+}

view file

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 }

view file