ignore.v

7.6 KB · raw

1//! Ignore rules.2//!3//! `.kohignore` lives at the repo root and is itself snapshotted, so the rules4//! travel between machines with the history. Patterns are matched against5//! repo-relative paths: `*` matches inside one path segment, `**` spans6//! segments, a trailing `/` restricts a pattern to directories, a leading7//! `/` anchors it to the repo root, and `\` makes the next character literal.89module snapshot1011import os1213// The seed written by `koh init`, carried over from Koh 2's default list.14pub const default_ignore = '# koh ignore rules (.kohignore)15# Generated by koh init. Edit to fit the project.1617# Patterns are matched against repo-relative paths. Suffix `/` restricts18# to directories. Leading `/` anchors to the repo root. A pattern like19# `*.log` matches any file with that extension anywhere in the tree.20#21# koh already ignores .koh, .koh-wal, .koh-shm, .koh-v2/ and .git/ automatically.2223# Build output24zig-out/25.zig-cache/26zig-cache/27target/28build/29dist/30out/3132# Dependencies33node_modules/3435# OS noise36.DS_Store37Icon\\r38Thumbs.db3940# Logs41*.log4243# Koh artifacts44*.bundle45*.koh.offer46'4748pub const ignore_file = '.kohignore'4950struct Pattern {51	segments []string52	dir_only bool53	anchored bool54}5556pub struct Ignore {57	patterns []Pattern58}5960/// global_ignore_path is the machine-local rule file, outside repository history.61pub fn global_ignore_path() string {62	return os.join_path(os.home_dir(), ignore_file)63}6465// Both scopes are additive, so a local rule never overrides a global one.66pub fn load(root string) 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}7172/// matching_rules returns the lines that make the walk skip a literal path.73pub 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		}89	}90	return rules91}9293/// ignore_rule escapes a literal path in the selected scope.94pub 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}99100// banning adds a whole-history strip's paths as anchored rules, so the tree agrees with the101// history on every machine that holds the strip, before `.kohignore` names them there too.102fn (ig Ignore) banning(paths []string) Ignore {103	mut patterns := ig.patterns.clone()104	for path in paths {105		patterns << Pattern{106			segments: literal(path).split('/')107			anchored: true108		}109	}110	return Ignore{111		patterns: patterns112	}113}114115// add_ignore appends an anchored `/<path>` rule to `.kohignore`, unless that line is already116// there, and reports whether it wrote one.117pub fn add_ignore(root string, path string) !bool {118	file := os.join_path(root, ignore_file)119	text := if os.is_file(file) { os.read_file(file)! } else { '' }120	rule := '/${literal(path)}'121	if text.split_into_lines().any(it.trim_space() == rule) {122		return false123	}124	separator := if text == '' || text.ends_with('\n') { '' } else { '\n' }125	os.write_file(file, '${text}${separator}${rule}\n')!126	return true127}128129// literal escapes a path so a rule names it and nothing else.130fn literal(path string) string {131	return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?').replace('#', '\\#')132}133134pub fn parse(text string) Ignore {135	mut patterns := []Pattern{}136	for raw in text.split_into_lines() {137		line := raw.trim_space()138		if line == '' || line.starts_with('#') {139			continue140		}141		mut body := line142		mut dir_only := false143		if body.ends_with('/') {144			dir_only = true145			body = body.trim_right('/')146		}147		mut anchored := false148		if body.starts_with('/') {149			anchored = true150			body = body.trim_left('/')151		}152		if body == '' {153			continue154		}155		patterns << Pattern{156			segments: body.split('/')157			dir_only: dir_only158			anchored: anchored159		}160	}161	return Ignore{162		patterns: patterns163	}164}165166// hard_ignored covers the files Koh writes into a repo as part of working167// there. They are never snapshotted whatever `.kohignore` says, at any depth:168// a repo inside the repo keeps its own history, and restoring an old copy of169// its `.koh` or `.git` would roll that history back.170//171// LOG.md is rewritten after every save from the history itself. Tracking it172// would make the tree dirty the moment a save lands, so `status` could never173// report a clean tree and every diff would carry the log's own churn.174pub fn hard_ignored(rel string) bool {175	if rel == 'Icon\r' {176		return true177	}178	if rel == 'LOG.md' {179		return true180	}181	return rel.split('/').any(it in hard_ignored_names)182}183184const hard_ignored_names = ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.git']185186// matches reports whether a repo-relative path is ignored. `is_dir` matters187// because a pattern ending in `/` names a directory: it covers the directory188// itself and everything below it, but not a file that happens to share the189// name.190pub fn (ig Ignore) matches(rel string, is_dir bool) bool {191	if hard_ignored(rel) {192		return true193	}194	parts := rel.split('/')195	for pat in ig.patterns {196		if ig.matches_pattern(parts, is_dir, pat) {197			return true198		}199	}200	return false201}202203fn (ig Ignore) matches_pattern(parts []string, is_dir bool, pat Pattern) bool {204	if pat.dir_only {205		// Anything inside an ignored directory is ignored too.206		if parts.len > 1 && match_any_prefix(parts[..parts.len - 1], pat.segments, pat.anchored) {207			return true208		}209		if !is_dir {210			return false211		}212	}213	// Otherwise the pattern has to match the whole path.214	for start in 0 .. parts.len {215		if pat.anchored && start != 0 {216			break217		}218		if match_segments(parts, start, pat.segments, 0, false) {219			return true220		}221	}222	return false223}224225// match_any_prefix asks whether the pattern matches a leading run of segments226// starting at any point in the path.227fn match_any_prefix(parts []string, pat []string, anchored bool) bool {228	for start in 0 .. parts.len {229		if anchored && start != 0 {230			break231		}232		for end in start + 1 .. parts.len + 1 {233			if match_segments(parts[start..end], 0, pat, 0, false) {234				return true235			}236		}237	}238	return false239}240241// match_segments consumes `pat` from `gi` over `path` from `pi`.242// `allow_trailing` lets the path keep segments the pattern does not name,243// which is what makes a directory pattern cover everything beneath it.244fn match_segments(path []string, pi int, pat []string, gi int, allow_trailing bool) bool {245	if gi == pat.len {246		return allow_trailing || pi == path.len247	}248	seg := pat[gi]249	if seg == '**' {250		for skip in pi .. path.len + 1 {251			if match_segments(path, skip, pat, gi + 1, allow_trailing) {252				return true253			}254		}255		return false256	}257	if pi >= path.len {258		return false259	}260	if !match_segment(path[pi], seg) {261		return false262	}263	return match_segments(path, pi + 1, pat, gi + 1, allow_trailing)264}265266// match_segment is shell-style matching within one path segment.267fn match_segment(name string, pat string) bool {268	mut n := 0269	mut p := 0270	mut star := -1271	mut mark := 0272	for n < name.len {273		escaped := p + 1 < pat.len && pat[p] == `\\`274		if escaped && pat[p + 1] == name[n] {275			n++276			p += 2277		} else if !escaped && p < pat.len && (pat[p] == name[n] || pat[p] == `?`) {278			n++279			p++280		} else if !escaped && p < pat.len && pat[p] == `*` {281			star = p282			mark = n283			p++284		} else if star >= 0 {285			p = star + 1286			mark++287			n = mark288		} else {289			return false290		}291	}292	for p < pat.len && pat[p] == `*` {293		p++294	}295	return p == pat.len296}