Skip to content

Replace git check-ignore with pure-Go gitignore matcher#597

Open
loganj wants to merge 1 commit intomainfrom
cheaper-git-check-ignore-solut
Open

Replace git check-ignore with pure-Go gitignore matcher#597
loganj wants to merge 1 commit intomainfrom
cheaper-git-check-ignore-solut

Conversation

@loganj
Copy link
Copy Markdown
Collaborator

@loganj loganj commented Apr 9, 2026

Problem

Penpal spawns git check-ignore subprocesses on hot paths:

  1. Full scan (scanProjectSources): a persistent git check-ignore --stdin subprocess per project scan
  2. Single-file resolution (ResolveFileInfo): N one-shot git check-ignore -q spawns per ancestor directory — called from UpsertFile on every .md file-watcher event

For a file 5 levels deep, that's 5 process spawns per file change.

Solution

New internal/gitignore package — a pure-Go gitignore matcher that parses .gitignore files, .git/info/exclude, and the global gitignore once, then answers IsIgnoredDir() queries in-process with caching.

Subprocess cost: from 1 persistent + N per-file → 0 (except a single sync.Once-cached git config core.excludesFile for global gitignore path).

Changes

Area What changed
internal/gitignore/ New package. Pattern parser (*, **, ?, [abc], negation, anchored, directory-only, escaped trailing spaces), hierarchical .gitignore loading with precedence, directory result caching.
internal/cache/cache.go Replaced gitIgnoreChecker and isAncestorDirGitIgnored with gitignore.Matcher. Added matcherCache on Cache so ResolveFileInfo reuses matchers across file events.
cmd/penpal-server/main.go Removed saveMu lock from debounce AfterFunc callback — Record() no longer blocks on activity disk I/O.
frontend/src-tauri/src/lib.rs Bug fix: Destroyed handler saves session before removing window (was only saving on last window, losing multi-window sessions). Extracted ensure_geo_entry() helper.
frontend/src/hooks/useTabs.ts Fixed double-parse of localStorage during init — loadPersistedTabs called once, shared by both useState initializers.
frontend/src/hooks/useTabs.test.ts Added happy-path test for restoring valid persisted tab state.
internal/gitignore/gitignore_test.go 15 tests: basic patterns, wildcards, negation, **, nested .gitignore, anchored, .git/info/exclude, caching, no-trailing-slash, escaped spaces.
internal/cache/cache_test.go Added TestResolveFileInfo_SkipsGitignored. Updated existing tests for new signature.
ERD.md, TESTING.md Updated to reflect pure-Go matcher and new test coverage.

Testing

  • go test ./... — all 14 Go packages pass
  • npx vitest run — all frontend tests pass
  • anchors check . — 0 warnings, 0 errors

@loganj loganj force-pushed the cheaper-git-check-ignore-solut branch from db024b9 to 1a0dc93 Compare April 9, 2026 19:17
@loganj loganj force-pushed the cheaper-git-check-ignore-solut branch from 1a0dc93 to a1bd47b Compare April 9, 2026 19:20
@loganj loganj marked this pull request as ready for review April 9, 2026 19:27
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1bd47bd88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 70 to 71
saveTimer = time.AfterFunc(5*time.Second, func() {
saveMu.Lock()
defer saveMu.Unlock()
if err := act.Save(activityPath); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize debounced activity saves

The debounce AfterFunc now calls act.Save without taking saveMu, while shutdown still performs a locked save, so two saves can run concurrently against the same activityPath. Because Tracker.Save writes to a fixed temp file (activity.json.tmp) before rename, concurrent writers can race and produce rename errors (no such file or directory), which can drop the final persisted activity state during shutdown or heavy event bursts. Please reintroduce save serialization for the timer callback (or otherwise make saves mutually exclusive).

Useful? React with 👍 / 👎.

Comment on lines +73 to +75
if m, ok := c.matcherCache[projectPath]; ok {
c.mu.RUnlock()
return m
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate cached matcher when ignore rules change

This cache path reuses a single parsed gitignore.Matcher for all subsequent UpsertFile events, but the matcher is only rebuilt on RescanWith; edits to .gitignore/.git/info/exclude during normal runtime are not observed. As a result, incremental updates can keep including files that were newly ignored (or excluding files that were unignored) until a full rescan/restart, which is a behavioral regression from the previous per-call git check-ignore checks.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant