From f7d9a699520cf495132f2c7f51047c6c89e6164b Mon Sep 17 00:00:00 2001 From: Luxferre Date: Fri, 21 Aug 2026 13:56:57 +0300 Subject: [PATCH] added GG_GIT_ALL behavior --- README.md | 28 ++++- gg.c | 371 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 382 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4f0fc40..3489cfb 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,17 @@ It is intentionally narrow: skipped. A literal match that occurs *before* the first NUL in an otherwise-text file is still reported. - **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 programs. Just `libc` + `libpthread`. `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 `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 @@ -113,8 +117,10 @@ gg "def main" src/main.py - **Symlinks are not followed.** Symbolic links to files or directories are skipped, which prevents infinite loops on symlink cycles. -- **Hidden files and directories are searched.** Unlike `rg`'s default, `gg` - does not consult `.gitignore` or skip hidden paths. +- **Hidden files and directories are searched.** `gg` does not skip hidden + 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, 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). @@ -214,8 +220,20 @@ against symlink cycles and avoids double-counting. ### Does it respect `.gitignore`? -No. `gg` searches everything it can read, including hidden files and directories. -This is why its results match `rg --no-ignore --hidden`. +Yes, by default. `gg` reads every `.gitignore` found while walking and skips +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? diff --git a/gg.c b/gg.c index 8e5f9ee..f9196e6 100644 --- a/gg.c +++ b/gg.c @@ -4,6 +4,7 @@ * Usage: gg SEARCH_TERM [FILE_OR_DIRECTORY] * * Created by Luxferre in 2026, released into the public domain + * */ #ifndef _POSIX_C_SOURCE #define _POSIX_C_SOURCE 200809L @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -34,16 +36,39 @@ static const char *needle; static size_t needle_len; static int found_any = 0; 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) ---------- */ 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; } DirJob; static DirJob *dir_stack = NULL; 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); if(j == NULL) return; @@ -52,6 +77,13 @@ static void push_dir(const char *path) { free(j); return; } + j->rel = strdup(rel); + if(j->rel == NULL) { + free(j->path); + free(j); + return; + } + j->gi = gi; pthread_mutex_lock(&dir_mx); j->next = dir_stack; dir_stack = j; @@ -63,17 +95,283 @@ static void die(const char *msg) { exit(2); } -static char *pop_dir(void) { +static void pop_dir(char **path, char **rel, GitIgnore **gi) { pthread_mutex_lock(&dir_mx); DirJob *j = dir_stack; if(j != NULL) dir_stack = j->next; pthread_mutex_unlock(&dir_mx); - if(j == NULL) - return NULL; - char *p = j->path; + if(j == NULL) { + *path = NULL; + *rel = NULL; + *gi = NULL; + return; + } + *path = j->path; + *rel = j->rel; + *gi = j->gi; 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) ---------- */ @@ -154,10 +452,19 @@ static void *worker(void *arg) { return NULL; } char child[PATH_MAX]; + char relbuf[PATH_MAX]; + for(;;) { - char *dir = pop_dir(); + char *dir, *rel; + GitIgnore *parent_gi; + pop_dir(&dir, &rel, &parent_gi); if(dir == NULL) break; + + GitIgnore *my_gi = parent_gi; + if(!git_all) + my_gi = load_gitignore(dir, parent_gi, rel); + DIR *d = opendir(dir); if(d != NULL) { struct dirent *ent; @@ -165,13 +472,35 @@ static void *worker(void *arg) { if(strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) 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) 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) { scan_file(child, &sb); } else if(ent->d_type == DT_DIR) { - push_dir(child); + push_dir(child, relbuf, my_gi); } else if(ent->d_type == DT_LNK) { continue; /* do not follow symlinks: avoids cycles */ } else { @@ -179,7 +508,7 @@ static void *worker(void *arg) { if(lstat(child, &st) != 0) continue; if(S_ISDIR(st.st_mode)) - push_dir(child); + push_dir(child, relbuf, my_gi); else if(S_ISREG(st.st_mode)) scan_file(child, &sb); } @@ -187,12 +516,27 @@ static void *worker(void *arg) { closedir(d); } free(dir); + free(rel); } free(sb.buf); free(sb.carry); 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) { if(argc < 2 || argc > 3) { fprintf(stderr, "usage: %s SEARCH_TERM [FILE_OR_DIRECTORY]\n", argv[0]); @@ -206,6 +550,8 @@ int main(int argc, char **argv) { return 2; } + git_all = (getenv("GG_GIT_ALL") != NULL); + char cwd[PATH_MAX]; const char *root; if(argc == 3) { @@ -239,7 +585,7 @@ int main(int argc, char **argv) { scan_file(root, &(ScanBufs){ malloc(IO_BUFSZ + needle_len), malloc(needle_len) }); } else { - push_dir(root); + push_dir(root, "", NULL); } long nproc = sysconf(_SC_NPROCESSORS_ONLN); @@ -259,5 +605,6 @@ int main(int argc, char **argv) { for(int i = 0; i < started; i++) pthread_join(t[i], NULL); + free_gitignores(); return found_any ? 0 : 1; }