ignore.v

6.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// load reads `.kohignore`; a repo without one simply has no extra rules.61pub fn load(root string) Ignore {62	path := os.join_path(root, ignore_file)63	if !os.is_file(path) {64		return Ignore{}65	}66	return parse(os.read_file(path) or { return Ignore{} })67}6869// banning adds a whole-history strip's paths as anchored rules, so the tree agrees with the70// history on every machine that holds the strip, before `.kohignore` names them there too.71fn (ig Ignore) banning(paths []string) Ignore {72	mut patterns := ig.patterns.clone()73	for path in paths {74		patterns << Pattern{75			segments: literal(path).split('/')76			anchored: true77		}78	}79	return Ignore{80		patterns: patterns81	}82}8384/// add_ignore appends an anchored `/<path>` rule to `.kohignore`, unless that line is already85/// there, and reports whether it wrote one.86pub fn add_ignore(root string, path string) !bool {87	file := os.join_path(root, ignore_file)88	text := if os.is_file(file) { os.read_file(file)! } else { '' }89	rule := '/${literal(path)}'90	if text.split_into_lines().any(it.trim_space() == rule) {91		return false92	}93	separator := if text == '' || text.ends_with('\n') { '' } else { '\n' }94	os.write_file(file, '${text}${separator}${rule}\n')!95	return true96}9798// literal escapes a path so a rule names it and nothing else.99fn literal(path string) string {100	return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?')101}102103pub fn parse(text string) Ignore {104	mut patterns := []Pattern{}105	for raw in text.split_into_lines() {106		line := raw.trim_space()107		if line == '' || line.starts_with('#') {108			continue109		}110		mut body := line111		mut dir_only := false112		if body.ends_with('/') {113			dir_only = true114			body = body.trim_right('/')115		}116		mut anchored := false117		if body.starts_with('/') {118			anchored = true119			body = body.trim_left('/')120		}121		if body == '' {122			continue123		}124		patterns << Pattern{125			segments: body.split('/')126			dir_only: dir_only127			anchored: anchored128		}129	}130	return Ignore{131		patterns: patterns132	}133}134135// hard_ignored covers the files Koh writes into a repo as part of working136// there. They are never snapshotted whatever `.kohignore` says, at any depth:137// a repo inside the repo keeps its own history, and restoring an old copy of138// its `.koh` or `.git` would roll that history back.139//140// LOG.md is rewritten after every save from the history itself. Tracking it141// would make the tree dirty the moment a save lands, so `status` could never142// report a clean tree and every diff would carry the log's own churn.143pub fn hard_ignored(rel string) bool {144	if rel == 'Icon\r' {145		return true146	}147	if rel == 'LOG.md' {148		return true149	}150	return rel.split('/').any(it in hard_ignored_names)151}152153const hard_ignored_names = ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.git']154155// matches reports whether a repo-relative path is ignored. `is_dir` matters156// because a pattern ending in `/` names a directory: it covers the directory157// itself and everything below it, but not a file that happens to share the158// name.159pub fn (ig Ignore) matches(rel string, is_dir bool) bool {160	if hard_ignored(rel) {161		return true162	}163	parts := rel.split('/')164	for pat in ig.patterns {165		if ig.matches_pattern(parts, is_dir, pat) {166			return true167		}168	}169	return false170}171172fn (ig Ignore) matches_pattern(parts []string, is_dir bool, pat Pattern) bool {173	if pat.dir_only {174		// Anything inside an ignored directory is ignored too.175		if parts.len > 1 && match_any_prefix(parts[..parts.len - 1], pat.segments, pat.anchored) {176			return true177		}178		if !is_dir {179			return false180		}181	}182	// Otherwise the pattern has to match the whole path.183	for start in 0 .. parts.len {184		if pat.anchored && start != 0 {185			break186		}187		if match_segments(parts, start, pat.segments, 0, false) {188			return true189		}190	}191	return false192}193194// match_any_prefix asks whether the pattern matches a leading run of segments195// starting at any point in the path.196fn match_any_prefix(parts []string, pat []string, anchored bool) bool {197	for start in 0 .. parts.len {198		if anchored && start != 0 {199			break200		}201		for end in start + 1 .. parts.len + 1 {202			if match_segments(parts[start..end], 0, pat, 0, false) {203				return true204			}205		}206	}207	return false208}209210// match_segments consumes `pat` from `gi` over `path` from `pi`.211// `allow_trailing` lets the path keep segments the pattern does not name,212// which is what makes a directory pattern cover everything beneath it.213fn match_segments(path []string, pi int, pat []string, gi int, allow_trailing bool) bool {214	if gi == pat.len {215		return allow_trailing || pi == path.len216	}217	seg := pat[gi]218	if seg == '**' {219		for skip in pi .. path.len + 1 {220			if match_segments(path, skip, pat, gi + 1, allow_trailing) {221				return true222			}223		}224		return false225	}226	if pi >= path.len {227		return false228	}229	if !match_segment(path[pi], seg) {230		return false231	}232	return match_segments(path, pi + 1, pat, gi + 1, allow_trailing)233}234235// match_segment is shell-style matching within one path segment.236fn match_segment(name string, pat string) bool {237	mut n := 0238	mut p := 0239	mut star := -1240	mut mark := 0241	for n < name.len {242		escaped := p + 1 < pat.len && pat[p] == `\\`243		if escaped && pat[p + 1] == name[n] {244			n++245			p += 2246		} else if !escaped && p < pat.len && (pat[p] == name[n] || pat[p] == `?`) {247			n++248			p++249		} else if !escaped && p < pat.len && pat[p] == `*` {250			star = p251			mark = n252			p++253		} else if star >= 0 {254			p = star + 1255			mark++256			n = mark257		} else {258			return false259		}260	}261	for p < pat.len && pat[p] == `*` {262		p++263	}264	return p == pat.len265}