cmd_ignore.v

6.3 KB · raw

1//! Batch ignore toggles: plan from the original rules, then confirm one file replacement.23module cli45import json26import os7import rand8import src.snapshot9import src.storage1011const ignore_notice = 'Ignoring leaves files in older saves. To remove a file or folder from all history, use koh strip <path>.'1213struct IgnoreChange {14	path      string15	action    string16	rules     []string17	remaining []string18}1920struct IgnorePreview {21	file    string22	changes []IgnoreChange23	changed bool24	notice  string = ignore_notice25}2627fn 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}3334fn 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}157158fn 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}188189fn 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}