//! Batch ignore toggles: plan from the original rules, then confirm one file replacement. module cli import json2 import os import rand import src.snapshot import src.storage const ignore_notice = 'Ignoring leaves files in older saves. To remove a file or folder from all history, use koh strip .' struct IgnoreChange { path string action string rules []string remaining []string } struct IgnorePreview { file string changes []IgnoreChange changed bool notice string = ignore_notice } fn ignore_text(file string, editing bool) !string { if (editing && os.is_link(file)) || (os.exists(file) && !os.is_file(file)) { return error('${file} must be a regular file') } return if os.exists(file) { os.read_file(file)! } else { '' } } fn cmd_ignore(raw []string) int { args := parse('ignore', raw, flags: ['global', 'yes', 'json']) or { return usage_error(err.msg()) } if args.positional.len == 0 { return usage_error('usage: koh ignore ... [--global] [--yes] [--json]') } global := args.flag('global') root := if global { os.getwd() } else { repo_root() or { return refuse(args.json(), err.msg()) } } file := if global { snapshot.global_ignore_path() } else { os.join_path(root, snapshot.ignore_file) } original := ignore_text(file, true) or { return refuse(args.json(), err.msg()) } other_file := if global { if repo := storage.find_root(os.getwd()) { os.join_path(repo, snapshot.ignore_file) } else { '' } } else { snapshot.global_ignore_path() } other := if other_file != '' && other_file != file { ignore_text(other_file, false) or { return refuse(args.json(), err.msg()) } } else { '' } mut stripped := []string{} if !global { mut store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } stripped = store.stripped_paths() or { return refuse(args.json(), err.msg()) } } mut changes := []IgnoreChange{} mut remove := []string{} mut add := []string{} for item in args.positional { path, is_dir := normalize_ignore_path(root, item, global) or { return refuse(args.json(), err.msg()) } if changes.any(it.path == path) { continue } if stripped.any(path == it || path.starts_with(it + '/')) { return refuse(args.json(), '${path} is permanently stripped from history') } rules := snapshot.matching_rules(original, path, is_dir) remaining_path := if global && other_file != '' { os.abs_path(item)[os.dir(other_file).len + 1..] } else { path } remaining := snapshot.matching_rules(other, remaining_path, is_dir) action := if rules.len > 0 { 'unignore' } else { 'ignore' } chosen := if rules.len > 0 { rules } else { [snapshot.ignore_rule(path, is_dir, global)] } changes << IgnoreChange{ path: path, action: action, rules: chosen, remaining: remaining } if rules.len > 0 { for rule in rules { if rule !in remove { remove << rule } } } else { add << chosen[0] } } mut updated := '' // Keep each unrelated line byte-for-byte, including its newline convention. lines := original.split('\n') for i, line in lines { if line.trim_space() !in remove { updated += line if i < lines.len - 1 { updated += '\n' } } } if add.len > 0 { if updated != '' && !updated.ends_with('\n') { updated += '\n' } updated += add.join('\n') + '\n' } preview := IgnorePreview{ file: file, changes: changes } if args.json() { if !args.flag('yes') { emit_json(json2.encode(preview)) return refuse(true, 'needs --yes with --json') } } else { note('change ${file}:') for change in changes { note(' ${change.action} ${change.path}') for rule in change.rules { note(' ${if change.action == 'ignore' { 'add' } else { 'remove' }} ${snapshot.escape_path(rule)}') } if change.remaining.len > 0 { warn(' still ignored by ${other_file}: ${change.remaining.map(snapshot.escape_path(it)).join(', ')}') } } if remove.len > 0 { warn('removing these rules may unignore other items they match') } note(ignore_notice) } approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) } if !approved { return refuse(args.json(), 'nothing changed') } other_now := if other_file != '' && other_file != file { ignore_text(other_file, false) or { return refuse(args.json(), err.msg()) } } else { '' } if other_now != other { return refuse(args.json(), 'ignore rules changed since the preview — run koh ignore again') } write_ignore(file, original, updated) or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(IgnorePreview{ file: file, changes: changes, changed: true })) } else { ok('updated ${file}') } return 0 } fn normalize_ignore_path(root string, item string, global bool) !(string, bool) { if global && os.is_abs_path(item) { return error('global paths must be relative: ${snapshot.escape_path(item)}') } absolute := os.abs_path(item) path := if global { os.norm_path(item.trim_right('/')) } else { if !absolute.starts_with(root + '/') { return error('path must be inside ${root}: ${snapshot.escape_path(item)}') } absolute[root.len + 1..] } if !snapshot.legal_path(path) || path.trim_space() != path || snapshot.hard_ignored(path) || path.split('/').contains(snapshot.ignore_file) { return error('cannot ignore this path: ${snapshot.escape_path(item)}') } mut parent := root for part in path.split('/') { parent = os.join_path(parent, part) if os.is_link(parent) { return error('cannot ignore a symlink: ${snapshot.escape_path(item)}') } } is_dir := os.is_dir(absolute) || item.ends_with('/') if item.ends_with('/') && os.is_file(absolute) { return error('${snapshot.escape_path(item)} is a file') } return path, is_dir } fn write_ignore(file string, original string, updated string) ! { if ignore_text(file, true)! != original { return error('ignore rules changed since the preview — run koh ignore again') } // A private directory beside the destination keeps the rename on one filesystem. temporary := '${file}.tmp-${rand.uuid_v4()}' os.mkdir(temporary, mode: 0o700)! staged := os.join_path(temporary, 'rules') defer { os.rm(staged) or {} os.rmdir(temporary) or {} } os.write_file(staged, updated)! if os.exists(file) { os.chmod(staged, int(os.stat(file)!.mode & 0o777))! } if ignore_text(file, true)! != original { return error('ignore rules changed since the preview — run koh ignore again') } os.rename(staged, file)! }