123module 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 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 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}