From 85d25ce8f16385edd5e7695b4205da322cecd9b9 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Wed, 17 Jun 2026 12:21:32 -0400 Subject: [PATCH] wip: "full" finder --- findr.odin | 4 +--- findr_test.odin | 49 +++++++++++++++++++++++++++++++++++++++++++-- gitignore.odin | 2 +- gitignore_test.odin | 40 +++++++++++++++++++++++------------- test_env.odin | 4 +--- walker.odin | 21 ++++++++++++------- 6 files changed, 90 insertions(+), 30 deletions(-) diff --git a/findr.odin b/findr.odin index 597ca25..ce89474 100644 --- a/findr.odin +++ b/findr.odin @@ -73,9 +73,7 @@ main :: proc() { } thread_count := os.get_processor_core_count() - for dir in paths { - walk(dir, &results, opts, thread_count) - } + walk(paths[:], &results, opts, thread_count) for r in results { fmt.println(r) diff --git a/findr_test.odin b/findr_test.odin index 15aa34d..58f2d98 100644 --- a/findr_test.odin +++ b/findr_test.odin @@ -1,6 +1,7 @@ package findr import "core:os" +import "core:sort" import "core:strings" import "core:sys/linux" import "core:testing" @@ -218,6 +219,7 @@ test_multiple_search_dirs :: proc(t: ^testing.T) { create_git_repo(env, "dir1/repo") create_file(env, "dir1/repo/.gitignore", "*.env\n") create_file(env, "dir1/repo/a.env") + create_file(env, "dir1/repo/normal.txt") create_git_repo(env, "dir2/repo") create_file(env, "dir2/repo/.gitignore", "*.env\n") @@ -236,9 +238,31 @@ test_multiple_search_dirs :: proc(t: ^testing.T) { opts := WalkOptions{include_hidden = true, ignore_mode = .Ignored} thread_count := os.get_processor_core_count() - walk(dir1, &results, opts, thread_count) - walk(dir2, &results, opts, thread_count) + walk({dir1, dir2}, &results, opts, thread_count) + testing.expect_value(t, len(results), 2) + + actual := make([dynamic]string, 0, len(results)) + for r in results { + stripped := r + if strings.has_prefix(stripped, env.temp_dir) { + stripped = stripped[len(env.temp_dir):] + if len(stripped) > 0 && stripped[0] == '/' { + stripped = stripped[1:] + } + } + append(&actual, stripped) + } + defer delete(actual) + + expected := []string{"dir1/repo/a.env", "dir2/repo/b.env"} + + sort.quick_sort(actual[:]) + sort.quick_sort(expected[:]) + + for i in 0 ..< len(expected) { + testing.expect_value(t, actual[i], expected[i]) + } } // ============================================================================ @@ -391,6 +415,27 @@ test_fifo_emitted :: proc(t: ^testing.T) { ) } +// ============================================================================ +// in_repo propagation tests +// ============================================================================ + +@(test) +test_repo_without_root_gitignore :: proc(t: ^testing.T) { + env := create_test_env() + defer destroy_test_env(&env) + + create_git_repo(env, "repo") + create_dir(env, "repo/sub") + create_file(env, "repo/sub/.gitignore", "*.tmp\n") + create_file(env, "repo/sub/file.tmp") + create_file(env, "repo/sub/file.txt") + + assert_output(t, env, nil, + {include_hidden = true, ignore_mode = .Respected}, + {"repo/", "repo/sub/", "repo/sub/.gitignore", "repo/sub/file.txt"}, + ) +} + // ============================================================================ // .ignore file support tests (fd respects .ignore in addition to .gitignore) // ============================================================================ diff --git a/gitignore.odin b/gitignore.odin index 3c3c98e..8157b9b 100644 --- a/gitignore.odin +++ b/gitignore.odin @@ -92,7 +92,7 @@ glob_to_regex :: proc(pattern: string, anchored: bool) -> string { } } - fmt.sbprintf(&sb, "(/.*)?$") + fmt.sbprintf(&sb, "$") s := strings.to_string(sb) result, _ := strings.clone(s) diff --git a/gitignore_test.odin b/gitignore_test.odin index 3db97d5..9094dab 100644 --- a/gitignore_test.odin +++ b/gitignore_test.odin @@ -6,98 +6,98 @@ import "core:testing" test_glob_simple :: proc(t: ^testing.T) { result := glob_to_regex("foo", false) defer delete(result) - testing.expect_value(t, result, "(^|/)foo(/.*)?$") + testing.expect_value(t, result, "(^|/)foo$") } @(test) test_glob_anchored :: proc(t: ^testing.T) { result := glob_to_regex("foo", true) defer delete(result) - testing.expect_value(t, result, "^foo(/.*)?$") + testing.expect_value(t, result, "^foo$") } @(test) test_glob_star :: proc(t: ^testing.T) { result := glob_to_regex("*.log", false) defer delete(result) - testing.expect_value(t, result, "(^|/)[^/]*\\.log(/.*)?$") + testing.expect_value(t, result, "(^|/)[^/]*\\.log$") } @(test) test_glob_question :: proc(t: ^testing.T) { result := glob_to_regex("?.log", false) defer delete(result) - testing.expect_value(t, result, "(^|/)[^/]\\.log(/.*)?$") + testing.expect_value(t, result, "(^|/)[^/]\\.log$") } @(test) test_glob_char_class :: proc(t: ^testing.T) { result := glob_to_regex("[abc].log", false) defer delete(result) - testing.expect_value(t, result, "(^|/)[abc]\\.log(/.*)?$") + testing.expect_value(t, result, "(^|/)[abc]\\.log$") } @(test) test_glob_negated_class :: proc(t: ^testing.T) { result := glob_to_regex("[!abc].log", false) defer delete(result) - testing.expect_value(t, result, "(^|/)[^abc]\\.log(/.*)?$") + testing.expect_value(t, result, "(^|/)[^abc]\\.log$") } @(test) test_glob_dot_escaped :: proc(t: ^testing.T) { result := glob_to_regex(".env", false) defer delete(result) - testing.expect_value(t, result, "(^|/)\\.env(/.*)?$") + testing.expect_value(t, result, "(^|/)\\.env$") } @(test) test_glob_globstar_prefix :: proc(t: ^testing.T) { result := glob_to_regex("**/foo", false) defer delete(result) - testing.expect_value(t, result, "(^|/)(.*/)?foo(/.*)?$") + testing.expect_value(t, result, "(^|/)(.*/)?foo$") } @(test) test_glob_globstar_suffix :: proc(t: ^testing.T) { result := glob_to_regex("abc/**", false) defer delete(result) - testing.expect_value(t, result, "(^|/)abc/.*(/.*)?$") + testing.expect_value(t, result, "(^|/)abc/.*$") } @(test) test_glob_globstar_middle :: proc(t: ^testing.T) { result := glob_to_regex("foo/**/bar", false) defer delete(result) - testing.expect_value(t, result, "(^|/)foo/(.*/)?bar(/.*)?$") + testing.expect_value(t, result, "(^|/)foo/(.*/)?bar$") } @(test) test_glob_backslash_escape :: proc(t: ^testing.T) { result := glob_to_regex("\\!foo", false) defer delete(result) - testing.expect_value(t, result, "(^|/)!foo(/.*)?$") + testing.expect_value(t, result, "(^|/)!foo$") } @(test) test_glob_hash_escaped :: proc(t: ^testing.T) { result := glob_to_regex("#foo", false) defer delete(result) - testing.expect_value(t, result, "(^|/)\\#foo(/.*)?$") + testing.expect_value(t, result, "(^|/)\\#foo$") } @(test) test_glob_hash_in_pattern :: proc(t: ^testing.T) { result := glob_to_regex("#*#", false) defer delete(result) - testing.expect_value(t, result, "(^|/)\\#[^/]*\\#(/.*)?$") + testing.expect_value(t, result, "(^|/)\\#[^/]*\\#$") } @(test) test_glob_empty :: proc(t: ^testing.T) { result := glob_to_regex("", false) defer delete(result) - testing.expect_value(t, result, "(^|/)(/.*)?$") + testing.expect_value(t, result, "(^|/)$") } @(test) @@ -190,6 +190,18 @@ test_is_ignored_globstar :: proc(t: ^testing.T) { testing.expect_value(t, is_ignored(&gi, "foo/bar/cache", false), true) } +@(test) +test_star_negation_subpath :: proc(t: ^testing.T) { + gi := parse("*\n!public/\n") + defer destroy(&gi) + + // public dir itself is un-ignored + testing.expect_value(t, is_ignored(&gi, "public", true), false) + // children of public/ should still be ignored by * + testing.expect_value(t, is_ignored(&gi, "public/uuid-dir", true), true) + testing.expect_value(t, is_ignored(&gi, "public/uuid-dir/file.txt", false), true) +} + @(test) test_is_ignored_hash_pattern :: proc(t: ^testing.T) { gi := parse("\\#*\\#\n") diff --git a/test_env.odin b/test_env.odin index f45adec..221c345 100644 --- a/test_env.odin +++ b/test_env.odin @@ -133,9 +133,7 @@ collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dyn for a in args {append(&full_args, a)} thread_count := os.get_processor_core_count() - for dir in full_args { - walk(dir, &results, opts, thread_count) - } + walk(full_args[:], &results, opts, thread_count) for i in 0 ..< len(results) { r := results[i] diff --git a/walker.odin b/walker.odin index 4ad7129..9e40968 100644 --- a/walker.odin +++ b/walker.odin @@ -36,6 +36,7 @@ WorkItem :: struct { path: string, // absolute directory path rel: string, // relative path from repo root ("" = root) gi_ctx: ^GIContext, // gitignore chain (nil = outside any repo) + in_repo: bool, // true if inside a git repo } WalkerPool :: struct { @@ -55,11 +56,13 @@ WalkerPool :: struct { contexts_lock: sync.Mutex, } -walk :: proc(root: string, results: ^[dynamic]string, opts: WalkOptions, thread_count: int) { +walk :: proc(roots: []string, results: ^[dynamic]string, opts: WalkOptions, thread_count: int) { + if len(roots) == 0 do return + pool := new(WalkerPool) pool.queue = make([dynamic]WorkItem) pool.results = results - pool.active = 1 + pool.active = i64(len(roots)) pool.threads = make([dynamic]^thread.Thread) pool.all_contexts = make([dynamic]^GIContext) pool.opts = opts @@ -86,9 +89,11 @@ walk :: proc(root: string, results: ^[dynamic]string, opts: WalkOptions, thread_ strings.builder_destroy(&sb) } - root_clone, _ := strings.clone(root) - append(&pool.queue, WorkItem{path = root_clone}) - sync.atomic_sema_post(&pool.queue_sema) + for root in roots { + root_clone, _ := strings.clone(root) + append(&pool.queue, WorkItem{path = root_clone}) + sync.atomic_sema_post(&pool.queue_sema) + } for i in 0 ..< thread_count { t := thread.create(walk_worker) @@ -181,7 +186,9 @@ process_dir :: proc(pool: ^WalkerPool, item: WorkItem) { rel = "" } - gi := load_ignore_patterns(dir_path, has_git || gi_ctx != nil) + child_in_repo := has_git || item.in_repo + + gi := load_ignore_patterns(dir_path, child_in_repo) if gi != nil { new_ctx := new(GIContext) new_ctx.gi = gi @@ -237,7 +244,7 @@ process_dir :: proc(pool: ^WalkerPool, item: WorkItem) { if !ignored { child_rel, _ := strings.clone(entry_rel) child_path := join_path(dir_path, entry.name) - push_work(pool, WorkItem{path = child_path, rel = child_rel, gi_ctx = gi_ctx}) + push_work(pool, WorkItem{path = child_path, rel = child_rel, gi_ctx = gi_ctx, in_repo = child_in_repo}) } } else if is_nondir { if should_emit && matches_pattern(pool, entry.name) {