added GG_GIT_ALL behavior

This commit is contained in:
Luxferre
2026-08-21 13:56:57 +03:00
parent 86082d67c0
commit f7d9a69952
2 changed files with 382 additions and 17 deletions
+23 -5
View File
@@ -26,13 +26,17 @@ It is intentionally narrow:
skipped. A literal match that occurs *before* the first NUL in an skipped. A literal match that occurs *before* the first NUL in an
otherwise-text file is still reported. otherwise-text file is still reported.
- **Recursive by default.** Given a directory it walks the whole tree. - **Recursive by default.** Given a directory it walks the whole tree.
- **Git-aware by default.** When walking a tree, `gg` honours every `.gitignore`
it encounters and always skips `.git` directories — just like `git` and `rg`.
Set `GG_GIT_ALL=1` to search everything instead (equivalent to `rg
--no-ignore`).
- **Self-contained.** No third-party libraries, no shelling out to other - **Self-contained.** No third-party libraries, no shelling out to other
programs. Just `libc` + `libpthread`. programs. Just `libc` + `libpthread`.
`gg` was built to be a clean, readable reference implementation that is also `gg` was built to be a clean, readable reference implementation that is also
fast: on a 16 GB / ~209 000-file tree it completes the equivalent of fast: on a 16 GB / ~209 000-file tree it completes the equivalent of
`rg --no-ignore --hidden -l` in roughly **0.4 s**, about **1.5× faster than `rg --no-ignore --hidden -l` in roughly **0.4 s**, about **1.5× faster than
`rg`** on the same machine and query, using only portable POSIX interfaces. `rg`** on the same machine and query, using only portable POSIX interfaces. (With `GG_GIT_ALL` unset, the equivalent is `rg --hidden -l`.)
## Installation ## Installation
@@ -113,8 +117,10 @@ gg "def main" src/main.py
- **Symlinks are not followed.** Symbolic links to files or directories are - **Symlinks are not followed.** Symbolic links to files or directories are
skipped, which prevents infinite loops on symlink cycles. skipped, which prevents infinite loops on symlink cycles.
- **Hidden files and directories are searched.** Unlike `rg`'s default, `gg` - **Hidden files and directories are searched.** `gg` does not skip hidden
does not consult `.gitignore` or skip hidden paths. paths. It *does* consult `.gitignore` files and skips `.git` directories by
default; set `GG_GIT_ALL=1` to disable that and search everything (including
ignored and `.git` paths).
- **Binary files are skipped.** A file containing a NUL byte is never reported, - **Binary files are skipped.** A file containing a NUL byte is never reported,
even if it also contains the search term (except when the match occurs before even if it also contains the search term (except when the match occurs before
the first NUL, in which case it is reported — see FAQ). the first NUL, in which case it is reported — see FAQ).
@@ -214,8 +220,20 @@ against symlink cycles and avoids double-counting.
### Does it respect `.gitignore`? ### Does it respect `.gitignore`?
No. `gg` searches everything it can read, including hidden files and directories. Yes, by default. `gg` reads every `.gitignore` found while walking and skips
This is why its results match `rg --no-ignore --hidden`. matching files and directories, and it always skips `.git` directories — the
same rules `git` itself uses (including `!` negation, `**` globs, and
trailing-`/` directory-only patterns). Hidden files and directories are still
searched; only `.gitignore`-matched paths and `.git` are excluded.
To search everything — ignored files, `.git`, the lot — set the `GG_GIT_ALL`
environment variable (to any value) before running `gg`:
```sh
GG_GIT_ALL=1 gg "needle" .
```
With `GG_GIT_ALL` set, `gg`'s results match `rg --no-ignore --hidden`.
### Why not `mmap` the files? ### Why not `mmap` the files?
+359 -12
View File
@@ -4,6 +4,7 @@
* Usage: gg SEARCH_TERM [FILE_OR_DIRECTORY] * Usage: gg SEARCH_TERM [FILE_OR_DIRECTORY]
* *
* Created by Luxferre in 2026, released into the public domain * Created by Luxferre in 2026, released into the public domain
*
*/ */
#ifndef _POSIX_C_SOURCE #ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L #define _POSIX_C_SOURCE 200809L
@@ -15,6 +16,7 @@
#include <dirent.h> #include <dirent.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <fnmatch.h>
#include <pthread.h> #include <pthread.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
@@ -34,16 +36,39 @@ static const char *needle;
static size_t needle_len; static size_t needle_len;
static int found_any = 0; static int found_any = 0;
static pthread_mutex_t found_mx = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t found_mx = PTHREAD_MUTEX_INITIALIZER;
static int git_all = 0; /* if set, ignore .gitignore / .git entirely */
/* ---------- .gitignore handling ---------- */
typedef struct Pattern {
int negate; /* pattern began with '!' */
int dir_only; /* pattern ended with '/' (matches dirs only) */
int has_slash; /* pattern contains a '/' (anchored to its .gitignore dir) */
char *pat; /* pattern text (without '!' / trailing '/') */
size_t pat_len;
} Pattern;
typedef struct GitIgnore {
Pattern *pats;
size_t npats;
const char *rel; /* directory this ignore governs, relative to root */
struct GitIgnore *parent;
struct GitIgnore *gnext; /* global list, for cleanup */
} GitIgnore;
static GitIgnore *gi_all = NULL;
/* ---------- directory work queue (parallel walk + scan) ---------- */ /* ---------- directory work queue (parallel walk + scan) ---------- */
typedef struct DirJob { typedef struct DirJob {
char *path; char *path; /* full path for opendir/open */
char *rel; /* path relative to the search root ("" for root) */
GitIgnore *gi; /* active ignore set for this directory's children */
struct DirJob *next; struct DirJob *next;
} DirJob; } DirJob;
static DirJob *dir_stack = NULL; static DirJob *dir_stack = NULL;
static pthread_mutex_t dir_mx = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t dir_mx = PTHREAD_MUTEX_INITIALIZER;
static void push_dir(const char *path) { static void push_dir(const char *path, const char *rel, GitIgnore *gi) {
DirJob *j = malloc(sizeof *j); DirJob *j = malloc(sizeof *j);
if(j == NULL) if(j == NULL)
return; return;
@@ -52,6 +77,13 @@ static void push_dir(const char *path) {
free(j); free(j);
return; return;
} }
j->rel = strdup(rel);
if(j->rel == NULL) {
free(j->path);
free(j);
return;
}
j->gi = gi;
pthread_mutex_lock(&dir_mx); pthread_mutex_lock(&dir_mx);
j->next = dir_stack; j->next = dir_stack;
dir_stack = j; dir_stack = j;
@@ -63,17 +95,283 @@ static void die(const char *msg) {
exit(2); exit(2);
} }
static char *pop_dir(void) { static void pop_dir(char **path, char **rel, GitIgnore **gi) {
pthread_mutex_lock(&dir_mx); pthread_mutex_lock(&dir_mx);
DirJob *j = dir_stack; DirJob *j = dir_stack;
if(j != NULL) if(j != NULL)
dir_stack = j->next; dir_stack = j->next;
pthread_mutex_unlock(&dir_mx); pthread_mutex_unlock(&dir_mx);
if(j == NULL) if(j == NULL) {
return NULL; *path = NULL;
char *p = j->path; *rel = NULL;
*gi = NULL;
return;
}
*path = j->path;
*rel = j->rel;
*gi = j->gi;
free(j); free(j);
return p; }
/* Read an entire fd into a malloc'd, NUL-terminated buffer. Returns the buffer
* (caller frees) and stores the length in *len, or NULL on error. */
static char *read_all(int fd, size_t *len) {
size_t cap = 4096, n = 0;
char *buf = malloc(cap);
if(buf == NULL)
return NULL;
for(;;) {
if(n == cap) {
cap *= 2;
char *nb = realloc(buf, cap);
if(nb == NULL) {
free(buf);
return NULL;
}
buf = nb;
}
ssize_t r = read(fd, buf + n, cap - n);
if(r < 0) {
if(errno == EINTR)
continue;
free(buf);
return NULL;
}
if(r == 0)
break;
n += (size_t)r;
}
buf = realloc(buf, n + 1);
if(buf == NULL)
return NULL;
buf[n] = '\0';
*len = n;
return buf;
}
/* Parse a .gitignore buffer into patterns, chained under `parent`. Returns the
* new GitIgnore node (sharing `parent` if the file is empty/absent). */
static GitIgnore *load_gitignore(const char *dir, GitIgnore *parent,
const char *rel) {
char path[PATH_MAX];
int n = snprintf(path, sizeof path, "%s/.gitignore", dir);
if(n < 0 || (size_t)n >= sizeof path)
return parent;
int fd = open(path, O_RDONLY | O_CLOEXEC);
if(fd < 0)
return parent;
size_t len = 0;
char *buf = read_all(fd, &len);
close(fd);
if(buf == NULL)
return parent;
Pattern *pa = NULL;
size_t np = 0, cap = 0;
char *p = buf, *end = buf + len;
while(p < end) {
char *nl = memchr(p, '\n', (size_t)(end - p));
size_t llen = (nl != NULL) ? (size_t)(nl - p) : (size_t)(end - p);
if(llen > 0 && p[llen - 1] == '\r')
llen--;
size_t s = 0;
while(s < llen && (p[s] == ' ' || p[s] == '\t'))
s++;
size_t e = llen;
while(e > s && (p[e - 1] == ' ' || p[e - 1] == '\t'))
e--;
if(s >= e || p[s] == '#') {
p = (nl != NULL) ? nl + 1 : end;
continue;
}
char *t = malloc(e - s + 1);
if(t == NULL)
break;
memcpy(t, p + s, e - s);
t[e - s] = '\0';
int negate = 0, dir_only = 0;
if(t[0] == '!') {
negate = 1;
memmove(t, t + 1, strlen(t));
}
size_t tl = strlen(t);
if(tl > 0 && t[tl - 1] == '/') {
dir_only = 1;
t[tl - 1] = '\0';
tl--;
}
int has_slash = (strchr(t, '/') != NULL);
if(np == cap) {
cap = cap ? cap * 2 : 16;
Pattern *na = realloc(pa, cap * sizeof *pa);
if(na == NULL) {
free(t);
break;
}
pa = na;
}
pa[np].negate = negate;
pa[np].dir_only = dir_only;
pa[np].has_slash = has_slash;
pa[np].pat = t;
pa[np].pat_len = tl;
np++;
p = (nl != NULL) ? nl + 1 : end;
}
free(buf);
if(np == 0) {
free(pa);
return parent;
}
GitIgnore *g = malloc(sizeof *g);
if(g == NULL) {
for(size_t i = 0; i < np; i++)
free(pa[i].pat);
free(pa);
return parent;
}
g->pats = pa;
g->npats = np;
g->rel = strdup(rel);
g->parent = parent;
g->gnext = gi_all;
gi_all = g;
return g;
}
/* Match a single path segment `s` (len sl) against a glob `p` (len pl) where
* '*' matches a run of non-'/' chars and '?' matches one char. */
static int seg_eq(const char *p, size_t pl, const char *s, size_t sl) {
size_t i = 0, j = 0;
size_t star = SIZE_MAX, si = 0, sj = 0;
while(i < pl || j < sl) {
if(i < pl && p[i] == '*') {
star = i;
si = i + 1;
sj = j;
i++;
continue;
}
if(i < pl && (p[i] == '?' || (j < sl && p[i] == s[j]))) {
i++;
j++;
continue;
}
if(star != SIZE_MAX) {
/* Extend what the '*' matches by one more char of s, if possible. */
if(sj + 1 > sl)
break;
i = si;
sj++;
j = sj;
continue;
}
return 0;
}
return i == pl && j == sl;
}
/* Match a slash-separated pattern `p` against path `s`, supporting `**`. */
static int match_path(const char *p, const char *s) {
for(;;) {
if(p[0] == '*' && p[1] == '*') {
p += 2;
while(*p == '/')
p++;
if(*p == '\0')
return 1;
const char *sp = s;
for(;;) {
if(match_path(p, sp))
return 1;
while(*sp && *sp != '/')
sp++;
if(*sp == '\0')
break;
sp++;
}
return 0;
}
if(*p == '\0')
return *s == '\0';
if(*p == '/') {
if(*s != '/')
return 0;
p++;
s++;
continue;
}
const char *pe = p;
while(*pe && *pe != '/')
pe++;
const char *se = s;
while(*se && *se != '/')
se++;
if(!seg_eq(p, (size_t)(pe - p), s, (size_t)(se - s)))
return 0;
p = pe;
s = se;
}
}
/* Match a gitignore pattern against a path (path relative to the owning
* .gitignore directory). A leading '/' only marks the pattern as anchored to
* that directory (it is already implied by `path`), so it is stripped before
* matching. Falls back to fnmatch for patterns without `**`. */
static int git_match(const char *pat, const char *path) {
while(*pat == '/')
pat++;
if(*pat == '\0')
return *path == '\0';
if(strstr(pat, "**") == NULL)
return fnmatch(pat, path, FNM_PATHNAME) == 0;
return match_path(pat, path);
}
/* Decide whether an entry named `name` (full relative path `entry_rel`, a
* directory iff `is_dir`) is ignored by the active ignore set `gi`. The
* deepest matching rule wins (git semantics). */
static int is_ignored(const char *entry_rel, const char *name, GitIgnore *gi,
int is_dir) {
GitIgnore *chain[1024];
int n = 0;
for(GitIgnore *g = gi; g != NULL && n < 1024; g = g->parent)
chain[n++] = g;
int ignored = 0;
for(int i = n - 1; i >= 0; i--) { /* root -> child (deepest wins) */
GitIgnore *g = chain[i];
for(size_t p = 0; p < g->npats; p++) {
Pattern *pat = &g->pats[p];
int m;
if(pat->has_slash) {
const char *rp;
if(g->rel[0] == '\0') {
rp = entry_rel;
} else {
size_t rl = strlen(g->rel);
if(strncmp(entry_rel, g->rel, rl) != 0)
continue;
if(entry_rel[rl] != '/')
continue;
rp = entry_rel + rl + 1;
}
m = git_match(pat->pat, rp);
} else {
if(pat->dir_only && !is_dir)
continue;
m = git_match(pat->pat, name);
}
if(m)
ignored = pat->negate ? 0 : 1;
}
}
return ignored;
} }
/* ---------- scan one open fd (chunked read + memmem, binary-skipping) ---------- */ /* ---------- scan one open fd (chunked read + memmem, binary-skipping) ---------- */
@@ -154,10 +452,19 @@ static void *worker(void *arg) {
return NULL; return NULL;
} }
char child[PATH_MAX]; char child[PATH_MAX];
char relbuf[PATH_MAX];
for(;;) { for(;;) {
char *dir = pop_dir(); char *dir, *rel;
GitIgnore *parent_gi;
pop_dir(&dir, &rel, &parent_gi);
if(dir == NULL) if(dir == NULL)
break; break;
GitIgnore *my_gi = parent_gi;
if(!git_all)
my_gi = load_gitignore(dir, parent_gi, rel);
DIR *d = opendir(dir); DIR *d = opendir(dir);
if(d != NULL) { if(d != NULL) {
struct dirent *ent; struct dirent *ent;
@@ -165,13 +472,35 @@ static void *worker(void *arg) {
if(strcmp(ent->d_name, ".") == 0 || if(strcmp(ent->d_name, ".") == 0 ||
strcmp(ent->d_name, "..") == 0) strcmp(ent->d_name, "..") == 0)
continue; continue;
int n = snprintf(child, sizeof child, "%s/%s", dir, ent->d_name); const char *name = ent->d_name;
int n = snprintf(child, sizeof child, "%s/%s", dir, name);
if(n < 0 || (size_t)n >= sizeof child) if(n < 0 || (size_t)n >= sizeof child)
continue; continue;
n = snprintf(relbuf, sizeof relbuf, "%s%s%s",
rel, (rel[0] == '\0') ? "" : "/", name);
if(n < 0 || (size_t)n >= sizeof relbuf)
continue;
int is_dir = (ent->d_type == DT_DIR);
if(ent->d_type == DT_UNKNOWN) {
struct stat st;
if(lstat(child, &st) == 0)
is_dir = S_ISDIR(st.st_mode);
}
if(!git_all) {
/* Always skip .git directories, just like git. */
if(strcmp(name, ".git") == 0 && is_dir)
continue;
if(is_ignored(relbuf, name, my_gi, is_dir))
continue;
}
if(ent->d_type == DT_REG) { if(ent->d_type == DT_REG) {
scan_file(child, &sb); scan_file(child, &sb);
} else if(ent->d_type == DT_DIR) { } else if(ent->d_type == DT_DIR) {
push_dir(child); push_dir(child, relbuf, my_gi);
} else if(ent->d_type == DT_LNK) { } else if(ent->d_type == DT_LNK) {
continue; /* do not follow symlinks: avoids cycles */ continue; /* do not follow symlinks: avoids cycles */
} else { } else {
@@ -179,7 +508,7 @@ static void *worker(void *arg) {
if(lstat(child, &st) != 0) if(lstat(child, &st) != 0)
continue; continue;
if(S_ISDIR(st.st_mode)) if(S_ISDIR(st.st_mode))
push_dir(child); push_dir(child, relbuf, my_gi);
else if(S_ISREG(st.st_mode)) else if(S_ISREG(st.st_mode))
scan_file(child, &sb); scan_file(child, &sb);
} }
@@ -187,12 +516,27 @@ static void *worker(void *arg) {
closedir(d); closedir(d);
} }
free(dir); free(dir);
free(rel);
} }
free(sb.buf); free(sb.buf);
free(sb.carry); free(sb.carry);
return NULL; return NULL;
} }
static void free_gitignores(void) {
GitIgnore *g = gi_all;
while(g != NULL) {
GitIgnore *next = g->gnext;
for(size_t i = 0; i < g->npats; i++)
free(g->pats[i].pat);
free(g->pats);
free((void *)g->rel);
free(g);
g = next;
}
gi_all = NULL;
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
if(argc < 2 || argc > 3) { if(argc < 2 || argc > 3) {
fprintf(stderr, "usage: %s SEARCH_TERM [FILE_OR_DIRECTORY]\n", argv[0]); fprintf(stderr, "usage: %s SEARCH_TERM [FILE_OR_DIRECTORY]\n", argv[0]);
@@ -206,6 +550,8 @@ int main(int argc, char **argv) {
return 2; return 2;
} }
git_all = (getenv("GG_GIT_ALL") != NULL);
char cwd[PATH_MAX]; char cwd[PATH_MAX];
const char *root; const char *root;
if(argc == 3) { if(argc == 3) {
@@ -239,7 +585,7 @@ int main(int argc, char **argv) {
scan_file(root, &(ScanBufs){ malloc(IO_BUFSZ + needle_len), scan_file(root, &(ScanBufs){ malloc(IO_BUFSZ + needle_len),
malloc(needle_len) }); malloc(needle_len) });
} else { } else {
push_dir(root); push_dir(root, "", NULL);
} }
long nproc = sysconf(_SC_NPROCESSORS_ONLN); long nproc = sysconf(_SC_NPROCESSORS_ONLN);
@@ -259,5 +605,6 @@ int main(int argc, char **argv) {
for(int i = 0; i < started; i++) for(int i = 0; i < started; i++)
pthread_join(t[i], NULL); pthread_join(t[i], NULL);
free_gitignores();
return found_any ? 0 : 1; return found_any ? 0 : 1;
} }