From 6c08124d2207cee6e8b145b211935f6bc6455d02 Mon Sep 17 00:00:00 2001 From: Tien Dung Dao Date: Fri, 24 Jul 2026 22:11:35 +0700 Subject: [PATCH] fix(init): layer the repo's .gitignore into the init index walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gortex init` resolved its config with `config.Load("")`. That argument is a *config file* path, not a repo path, so the per-repo layering never ran and `cfg.Index.Exclude` came back empty. `indexer.New` then hit its documented "no layering was applied" fallback and used `excludes.Builtin` alone, so the init walk admitted every gitignored tree that does not happen to match a builtin pattern. On a repo whose `.gitignore` excludes `tmp/` (holding linked worktrees) and a generated output directory, init queued 15,496 files against a tracked set of 2,693 — 5.7x — and was still parsing at 18m46s. `node_modules/` and `.next/` were correctly skipped because they match builtin patterns, which is what made the failure look inconsistent rather than absent. `respect_gitignore` is documented as defaulting to true, so this was a gap between the promise and the init path, not a design choice: only the daemon reached the layering, through `ConfigManager.GetRepoConfig`. Resolve the config for the actual root through that same path. The new `initRepoConfig` helper keeps the previous best-effort fallback when the global config cannot be read, and takes an explicit global path so tests never read the developer's real `~/.gortex/config.yaml`. Fixes #324 --- cmd/gortex/init.go | 37 ++++++++++++++++--- cmd/gortex/init_gitignore_test.go | 60 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 cmd/gortex/init_gitignore_test.go diff --git a/cmd/gortex/init.go b/cmd/gortex/init.go index e24a595b2..454e9728e 100644 --- a/cmd/gortex/init.go +++ b/cmd/gortex/init.go @@ -409,6 +409,38 @@ func toEnvSkills(src []genskills.GeneratedSkill) []agents.GeneratedSkill { return out } +// initRepoConfig resolves the layered per-repo config for root exactly the way +// the daemon does — builtin baseline, the repo's own `.gitignore` (unless +// `respect_gitignore: false`), global config, and workspace excludes — and +// returns it with Index.Exclude populated. +// +// init previously loaded the config with an empty *config file* path, which +// carries no repo, so the `.gitignore` layer never ran. indexer.New then saw an +// empty Index.Exclude and fell back to excludes.Builtin alone, so the init walk +// admitted gitignored trees (nested worktrees under an ignored directory, +// generated output) that the daemon's indexer prunes (#324). +// +// globalPath is empty in production (the default ~/.gortex/config.yaml); tests +// pass a temp path so they never read the developer's real global config. +func initRepoConfig(globalPath, root string, logger *zap.Logger) *config.Config { + cm, err := config.NewConfigManager(globalPath) + if err != nil { + // Preserve the previous best-effort behaviour: an unreadable global + // config should not block indexing outright. + cfg, loadErr := config.Load("") + if loadErr != nil { + return &config.Config{} + } + return cfg + } + if logger != nil { + cm.SetLogger(logger) + } + prefix := config.ResolvePrefix(config.RepoEntry{Path: root}) + cm.LoadWorkspaceConfig(prefix, root) + return cm.GetRepoConfig(prefix) +} + // indexRepoForInit runs a one-shot index of the repo. Kept inside // cmd/gortex (not an adapter) because the indexer pulls in many // gortex-internal packages we'd rather not leak into internal/agents. @@ -431,10 +463,7 @@ func indexRepoForInit(ctx context.Context, root string, logger *zap.Logger) (gra } defer func() { _ = logger.Sync() }() - cfg, err := config.Load("") - if err != nil { - cfg = &config.Config{} - } + cfg := initRepoConfig("", root, logger) tmpDir, err := os.MkdirTemp("", "gortex-init-store-*") if err != nil { diff --git a/cmd/gortex/init_gitignore_test.go b/cmd/gortex/init_gitignore_test.go new file mode 100644 index 000000000..d0e185958 --- /dev/null +++ b/cmd/gortex/init_gitignore_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zzet/gortex/internal/excludes" +) + +// TestInitRepoConfigLayersRepoGitignore pins #324. `gortex init` resolved its +// config without a repo path, so the repo's own .gitignore never reached +// indexer.New; the init walk then admitted gitignored trees (nested worktrees +// under an ignored directory, generated output) that the daemon's indexer +// prunes. Builtin-only exclusion is not enough — it covers node_modules/ and +// .next/ but not repo-specific ignores. +func TestInitRepoConfigLayersRepoGitignore(t *testing.T) { + root := t.TempDir() + writeInitConfigFile(t, filepath.Join(root, ".gitignore"), "tmp/\n/generated-out/\n") + globalPath := filepath.Join(t.TempDir(), "config.yaml") + writeInitConfigFile(t, globalPath, "") + + cfg := initRepoConfig(globalPath, root, nil) + matcher := excludes.New(cfg.Index.Exclude) + + for _, dir := range []string{"tmp", "generated-out"} { + if !matcher.MatchAbsDir(filepath.Join(root, dir), root, true) { + t.Errorf("gitignored %q must be excluded from the init walk; Index.Exclude=%v", + dir, cfg.Index.Exclude) + } + } + // A tracked directory must still be walked, so the fix cannot be + // "exclude everything". + if matcher.MatchAbsDir(filepath.Join(root, "internal"), root, true) { + t.Errorf("tracked directory must not be excluded; Index.Exclude=%v", cfg.Index.Exclude) + } +} + +// TestInitRepoConfigHonoursRespectGitignoreOptOut keeps the documented escape +// hatch working: a repo that opts out must still have its ignored trees walked. +func TestInitRepoConfigHonoursRespectGitignoreOptOut(t *testing.T) { + root := t.TempDir() + writeInitConfigFile(t, filepath.Join(root, ".gitignore"), "tmp/\n") + writeInitConfigFile(t, filepath.Join(root, ".gortex.yaml"), "respect_gitignore: false\n") + globalPath := filepath.Join(t.TempDir(), "config.yaml") + writeInitConfigFile(t, globalPath, "") + + cfg := initRepoConfig(globalPath, root, nil) + if excludes.New(cfg.Index.Exclude).MatchAbsDir(filepath.Join(root, "tmp"), root, true) { + t.Errorf("respect_gitignore: false must not layer .gitignore; Index.Exclude=%v", + cfg.Index.Exclude) + } +} + +func writeInitConfigFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +}