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_Store37Thumbs.db3839# Logs40*.log4142# Koh artifacts43*.bundle44*.koh.offer45'4647pub const ignore_file = '.kohignore'4849struct Pattern {50	segments []string51	dir_only bool52	anchored bool53}5455pub struct Ignore {56	patterns []Pattern57}5859// load reads `.kohignore`; a repo without one simply has no extra rules.60pub fn load(root string) Ignore {61	path := os.join_path(root, ignore_file)62	if !os.is_file(path) {63		return Ignore{}64	}65	return parse(os.read_file(path) or { return Ignore{} })66}6768// banning adds a whole-history strip's paths as anchored rules, so the tree agrees with the69// history on every machine that holds the strip, before `.kohignore` names them there too.70fn (ig Ignore) banning(paths []string) Ignore {71	mut patterns := ig.patterns.clone()72	for path in paths {73		patterns << Pattern{74			segments: literal(path).split('/')75			anchored: true76		}77	}78	return Ignore{79		patterns: patterns80	}81}8283/// add_ignore appends an anchored `/<path>` rule to `.kohignore`, unless that line is already84/// there, and reports whether it wrote one.85pub fn add_ignore(root string, path string) !bool {86	file := os.join_path(root, ignore_file)87	text := if os.is_file(file) { os.read_file(file)! } else { '' }88	rule := '/${literal(path)}'89	if text.split_into_lines().any(it.trim_space() == rule) {90		return false91	}92	separator := if text == '' || text.ends_with('\n') { '' } else { '\n' }93	os.write_file(file, '${text}${separator}${rule}\n')!94	return true95}9697// literal escapes a path so a rule names it and nothing else.98fn literal(path string) string {99	return path.replace('\\', '\\\\').replace('*', '\\*').replace('?', '\\?')100}101102pub fn parse(text string) Ignore {103	mut patterns := []Pattern{}104	for raw in text.split_into_lines() {105		line := raw.trim_space()106		if line == '' || line.starts_with('#') {107			continue108		}109		mut body := line110		mut dir_only := false111		if body.ends_with('/') {112			dir_only = true113			body = body.trim_right('/')114		}115		mut anchored := false116		if body.starts_with('/') {117			anchored = true118			body = body.trim_left('/')119		}120		if body == '' {121			continue122		}123		patterns << Pattern{124			segments: body.split('/')125			dir_only: dir_only126			anchored: anchored127		}128	}129	return Ignore{130		patterns: patterns131	}132}133134// hard_ignored covers the files Koh writes into a repo as part of working135// there. They are never snapshotted whatever `.kohignore` says, at any depth:136// a repo inside the repo keeps its own history, and restoring an old copy of137// its `.koh` or `.git` would roll that history back.138//139// LOG.md is rewritten after every save from the history itself. Tracking it140// would make the tree dirty the moment a save lands, so `status` could never141// report a clean tree and every diff would carry the log's own churn.142pub fn hard_ignored(rel string) bool {143	if rel == 'LOG.md' {144		return true145	}146	return rel.split('/').any(it in hard_ignored_names)147}148149const hard_ignored_names = ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.git']150151// matches reports whether a repo-relative path is ignored. `is_dir` matters152// because a pattern ending in `/` names a directory: it covers the directory153// itself and everything below it, but not a file that happens to share the154// name.155pub fn (ig Ignore) matches(rel string, is_dir bool) bool {156	if hard_ignored(rel) {157		return true158	}159	parts := rel.split('/')160	for pat in ig.patterns {161		if ig.matches_pattern(parts, is_dir, pat) {162			return true163		}164	}165	return false166}167168fn (ig Ignore) matches_pattern(parts []string, is_dir bool, pat Pattern) bool {169	if pat.dir_only {170		// Anything inside an ignored directory is ignored too.171		if parts.len > 1 && match_any_prefix(parts[..parts.len - 1], pat.segments, pat.anchored) {172			return true173		}174		if !is_dir {175			return false176		}177	}178	// Otherwise the pattern has to match the whole path.179	for start in 0 .. parts.len {180		if pat.anchored && start != 0 {181			break182		}183		if match_segments(parts, start, pat.segments, 0, false) {184			return true185		}186	}187	return false188}189190// match_any_prefix asks whether the pattern matches a leading run of segments191// starting at any point in the path.192fn match_any_prefix(parts []string, pat []string, anchored bool) bool {193	for start in 0 .. parts.len {194		if anchored && start != 0 {195			break196		}197		for end in start + 1 .. parts.len + 1 {198			if match_segments(parts[start..end], 0, pat, 0, false) {199				return true200			}201		}202	}203	return false204}205206// match_segments consumes `pat` from `gi` over `path` from `pi`.207// `allow_trailing` lets the path keep segments the pattern does not name,208// which is what makes a directory pattern cover everything beneath it.209fn match_segments(path []string, pi int, pat []string, gi int, allow_trailing bool) bool {210	if gi == pat.len {211		return allow_trailing || pi == path.len212	}213	seg := pat[gi]214	if seg == '**' {215		for skip in pi .. path.len + 1 {216			if match_segments(path, skip, pat, gi + 1, allow_trailing) {217				return true218			}219		}220		return false221	}222	if pi >= path.len {223		return false224	}225	if !match_segment(path[pi], seg) {226		return false227	}228	return match_segments(path, pi + 1, pat, gi + 1, allow_trailing)229}230231// match_segment is shell-style matching within one path segment.232fn match_segment(name string, pat string) bool {233	mut n := 0234	mut p := 0235	mut star := -1236	mut mark := 0237	for n < name.len {238		escaped := p + 1 < pat.len && pat[p] == `\\`239		if escaped && pat[p + 1] == name[n] {240			n++241			p += 2242		} else if !escaped && p < pat.len && (pat[p] == name[n] || pat[p] == `?`) {243			n++244			p++245		} else if !escaped && p < pat.len && pat[p] == `*` {246			star = p247			mark = n248			p++249		} else if star >= 0 {250			p = star + 1251			mark++252			n = mark253		} else {254			return false255		}256	}257	for p < pat.len && pat[p] == `*` {258		p++259	}260	return p == pat.len261}