| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | fileencoding "reasonix/internal/fileutil/encoding" |
| 10 | ) |
| 11 | |
| 12 | func writeFileT(t *testing.T, path, body string) { |
| 13 | t.Helper() |
| 14 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | func mkRepo(t *testing.T) string { |
| 23 | t.Helper() |
| 24 | dir := t.TempDir() |
| 25 | if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | return dir |
| 29 | } |
| 30 | |
| 31 | func TestGrepNestedGitignore(t *testing.T) { |
| 32 | dir := mkRepo(t) |
| 33 | writeFileT(t, filepath.Join(dir, ".gitignore"), "*.log\n") |
| 34 | writeFileT(t, filepath.Join(dir, "pkg", ".gitignore"), "secret.txt\n") |
| 35 | writeFileT(t, filepath.Join(dir, "pkg", "keep.go"), "NEEDLE\n") |
| 36 | writeFileT(t, filepath.Join(dir, "pkg", "secret.txt"), "NEEDLE\n") // ignored by nested |
| 37 | writeFileT(t, filepath.Join(dir, "pkg", "app.log"), "NEEDLE\n") // ignored by root |
| 38 | |
| 39 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": dir}) |
| 40 | if !strings.Contains(out, "keep.go") { |
| 41 | t.Fatalf("kept file must be found: %q", out) |
| 42 | } |
| 43 | if strings.Contains(out, "secret.txt") { |
| 44 | t.Fatalf("a nested .gitignore must apply: %q", out) |
| 45 | } |
| 46 | if strings.Contains(out, "app.log") { |
| 47 | t.Fatalf("an ancestor .gitignore must apply in subdirs: %q", out) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestGrepNestedNegationReincludes(t *testing.T) { |
| 52 | dir := mkRepo(t) |
| 53 | writeFileT(t, filepath.Join(dir, ".gitignore"), "*.log\n") |
| 54 | writeFileT(t, filepath.Join(dir, "pkg", ".gitignore"), "!keep.log\n") |
| 55 | writeFileT(t, filepath.Join(dir, "pkg", "keep.log"), "NEEDLE\n") // re-included by nested negation |
| 56 | writeFileT(t, filepath.Join(dir, "pkg", "drop.log"), "NEEDLE\n") // still ignored by root |
| 57 | |
| 58 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": dir}) |
| 59 | if !strings.Contains(out, "keep.log") { |
| 60 | t.Fatalf("a nested !negation must re-include: %q", out) |
| 61 | } |
| 62 | if strings.Contains(out, "drop.log") { |
| 63 | t.Fatalf("a non-negated .log must stay ignored: %q", out) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestGrepSkipsHidden(t *testing.T) { |
| 68 | dir := mkRepo(t) |
| 69 | writeFileT(t, filepath.Join(dir, "visible.txt"), "NEEDLE\n") |
| 70 | writeFileT(t, filepath.Join(dir, ".env"), "NEEDLE\n") |
| 71 | writeFileT(t, filepath.Join(dir, ".github", "ci.yml"), "NEEDLE\n") |
| 72 | |
| 73 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": dir}) |
| 74 | if !strings.Contains(out, "visible.txt") { |
| 75 | t.Fatalf("a visible file must be found: %q", out) |
| 76 | } |
| 77 | if strings.Contains(out, ".env") || strings.Contains(out, "ci.yml") { |
| 78 | t.Fatalf("hidden files/dirs must be skipped by default: %q", out) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestGrepExplicitHiddenRootSearched(t *testing.T) { |
| 83 | dir := mkRepo(t) |
| 84 | writeFileT(t, filepath.Join(dir, ".github", "ci.yml"), "NEEDLE\n") |
| 85 | // Pointing grep straight at a hidden dir searches it in full. |
| 86 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": filepath.Join(dir, ".github")}) |
| 87 | if !strings.Contains(out, "ci.yml") { |
| 88 | t.Fatalf("an explicitly targeted hidden dir should be searched: %q", out) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // repo scaffolds a fake git repo: a .git marker, a .gitignore, and files both |
| 93 | // kept and ignored — enough for the native grep walk to exercise .gitignore. |
| 94 | func gitignoreRepo(t *testing.T) string { |
| 95 | t.Helper() |
| 96 | dir := t.TempDir() |
| 97 | if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | writeFileT(t, filepath.Join(dir, ".gitignore"), "ignored.txt\nbuild/\n") |
| 101 | writeFileT(t, filepath.Join(dir, "keep.txt"), "NEEDLE in kept file\n") |
| 102 | writeFileT(t, filepath.Join(dir, "ignored.txt"), "NEEDLE in ignored file\n") |
| 103 | writeFileT(t, filepath.Join(dir, "build", "out.txt"), "NEEDLE in ignored dir\n") |
| 104 | writeFileT(t, filepath.Join(dir, "src", "deep.txt"), "NEEDLE deep in tree\n") |
| 105 | return dir |
| 106 | } |
| 107 | |
| 108 | func TestGrepSkipsGitignored(t *testing.T) { |
| 109 | dir := gitignoreRepo(t) |
| 110 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": dir}) |
| 111 | |
| 112 | if !strings.Contains(out, "keep.txt") || !strings.Contains(out, "deep.txt") { |
| 113 | t.Fatalf("kept files must be searched: %q", out) |
| 114 | } |
| 115 | if strings.Contains(out, "ignored.txt") { |
| 116 | t.Fatalf("a .gitignore'd file must be skipped: %q", out) |
| 117 | } |
| 118 | if strings.Contains(out, "out.txt") { |
| 119 | t.Fatalf("files under a .gitignore'd dir must be skipped: %q", out) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestGrepDecodesGB18030Gitignore(t *testing.T) { |
| 124 | dir := mkRepo(t) |
| 125 | if err := os.WriteFile(filepath.Join(dir, ".gitignore"), fileencoding.Encode("秘密.txt\n", fileencoding.GB18030), 0o644); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | writeFileT(t, filepath.Join(dir, "秘密.txt"), "NEEDLE ignored\n") |
| 129 | writeFileT(t, filepath.Join(dir, "公开.txt"), "NEEDLE kept\n") |
| 130 | |
| 131 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": dir}) |
| 132 | if !strings.Contains(out, "公开.txt") { |
| 133 | t.Fatalf("kept Chinese file must be searched: %q", out) |
| 134 | } |
| 135 | if strings.Contains(out, "秘密.txt") { |
| 136 | t.Fatalf("GB18030 .gitignore pattern should skip Chinese file: %q", out) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestScanGitConfigExcludesDecodesGB18030Path(t *testing.T) { |
| 141 | dir := t.TempDir() |
| 142 | path := filepath.Join(dir, ".gitconfig") |
| 143 | want := filepath.Join(dir, "中文忽略规则.txt") |
| 144 | body := "[core]\n\texcludesFile = " + want + "\n" |
| 145 | if err := os.WriteFile(path, fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | |
| 149 | if got := scanGitConfigExcludes(path); got != want { |
| 150 | t.Fatalf("scanGitConfigExcludes = %q, want %q", got, want) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestGrepExplicitIgnoredRootStillSearched(t *testing.T) { |
| 155 | dir := gitignoreRepo(t) |
| 156 | // Pointing grep straight at a gitignored directory still searches it — the |
| 157 | // walk root is never pruned. |
| 158 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": filepath.Join(dir, "build")}) |
| 159 | if !strings.Contains(out, "out.txt") { |
| 160 | t.Fatalf("an explicitly targeted ignored dir should still be searched: %q", out) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestGrepNoRepoIgnoresNothing(t *testing.T) { |
| 165 | // Same layout but without a .git marker: there is no repository, so |
| 166 | // .gitignore is not consulted and every file is searched. |
| 167 | dir := t.TempDir() |
| 168 | writeFileT(t, filepath.Join(dir, ".gitignore"), "ignored.txt\n") |
| 169 | writeFileT(t, filepath.Join(dir, "ignored.txt"), "NEEDLE here\n") |
| 170 | |
| 171 | out := runTool(t, grepTool{}, map[string]any{"pattern": "NEEDLE", "path": dir}) |
| 172 | if !strings.Contains(out, "ignored.txt") { |
| 173 | t.Fatalf("outside a git repo, .gitignore must not be applied: %q", out) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestFindRepoRoot(t *testing.T) { |
| 178 | dir := t.TempDir() |
| 179 | if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | sub := filepath.Join(dir, "a", "b") |
| 183 | if err := os.MkdirAll(sub, 0o755); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | got, err := filepath.EvalSymlinks(findRepoRoot(sub)) |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | want, _ := filepath.EvalSymlinks(dir) |
| 191 | if got != want { |
| 192 | t.Fatalf("findRepoRoot(%q) = %q, want %q", sub, got, want) |
| 193 | } |
| 194 | if rr := findRepoRoot(t.TempDir()); rr != "" { |
| 195 | t.Fatalf("a dir with no .git ancestor should return \"\", got %q", rr) |
| 196 | } |
| 197 | } |
| 198 |