Files
zzet--gortex/internal/indexer/watcher_delete_mtime_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

158 lines
6.7 KiB
Go

package indexer
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/graph/store_sqlite"
)
// newSqliteWatcher builds a repo of the given files, full-indexes it into a
// fresh sqlite store (so the per-file mtime rows a warm restart reads are
// actually persisted), and returns a Watcher wired to it ready to drive
// patchGraph / patchGraphNoResolve directly. The sqlite store implements the
// FileMtime{Writer,Reader,Deleter} capabilities the watcher's patch paths key
// off, so the in-memory graph backend's no-op behavior is covered separately.
func newSqliteWatcher(t *testing.T, files map[string]string) (dir string, idx *Indexer, w *Watcher, s *store_sqlite.Store) {
t.Helper()
dir = t.TempDir()
for name, content := range files {
writeTestFile(t, filepath.Join(dir, name), content)
}
s = openTestSqlite(t)
idx = New(graph.Store(s), newTestRegistry(), config.Default().Index, zap.NewNop())
idx.SetRootPath(dir)
_, err := idx.IndexCtx(context.Background(), dir)
require.NoError(t, err)
w, err = NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
return dir, idx, w, s
}
// TestWatcher_DeletePatchPrunesPersistedMtime is the core regression: when the
// live watcher patches a deleted file it must drop that file's persisted mtime
// row. Before the fix EvictFile removed the file's nodes but left its mtime
// behind, so the next warm restart read the orphaned row back, found the path
// gone from disk, and treated it as a phantom deletion — forcing a scoped
// reconcile for an already-correct repo on every boot.
func TestWatcher_DeletePatchPrunesPersistedMtime(t *testing.T) {
dir, idx, w, s := newSqliteWatcher(t, map[string]string{
"gone.go": "package main\n\nfunc Gone() {}\n",
"keep.go": "package main\n\nfunc Keep() {}\n",
})
before := s.LoadFileMtimes("")
require.Contains(t, before, "gone.go", "the full index must persist gone.go's mtime")
require.Contains(t, before, "keep.go", "the full index must persist keep.go's mtime")
gonePath := filepath.Join(dir, "gone.go")
require.NoError(t, os.Remove(gonePath))
w.patchGraph(gonePath, ChangeDeleted)
after := s.LoadFileMtimes("")
assert.NotContains(t, after, "gone.go",
"a delete patch must prune the vanished file's persisted mtime row")
assert.Contains(t, after, "keep.go",
"a delete patch must not touch a sibling file's mtime row")
// The in-memory map must be pruned too, or a later snapshot persist would
// resurrect the store row from the stale in-memory entry.
assert.NotContains(t, idx.FileMtimes(), "gone.go",
"a delete patch must also drop the in-memory mtime entry")
}
// TestWatcher_StormDrainDeletePrunesPersistedMtime covers the batched delete
// path: storm-drain re-indexing routes deletes through patchGraphNoResolve,
// which must prune the persisted mtime just like the debounced patchGraph does.
func TestWatcher_StormDrainDeletePrunesPersistedMtime(t *testing.T) {
dir, idx, w, s := newSqliteWatcher(t, map[string]string{
"batch.go": "package main\n\nfunc Batch() {}\n",
})
require.Contains(t, s.LoadFileMtimes(""), "batch.go")
path := filepath.Join(dir, "batch.go")
require.NoError(t, os.Remove(path))
w.patchGraphNoResolve(path, ChangeDeleted)
assert.NotContains(t, s.LoadFileMtimes(""), "batch.go",
"the storm-drain delete path must prune the persisted mtime too")
assert.NotContains(t, idx.FileMtimes(), "batch.go",
"the storm-drain delete path must drop the in-memory mtime entry")
}
// TestWatcher_ModifyPatchPersistsMtimeToStore is the other half of the
// contract: a modify patch must persist the file's advanced mtime so a warm
// restart does not re-detect the already-patched file as changed. A structural
// modify reindexes through IndexFile, whose recordFileMtime does the persist.
func TestWatcher_ModifyPatchPersistsMtimeToStore(t *testing.T) {
dir, _, w, s := newSqliteWatcher(t, map[string]string{
"main.go": "package main\n\nfunc First() {}\n",
})
path := filepath.Join(dir, "main.go")
before := s.LoadFileMtimes("")["main.go"]
require.NotZero(t, before)
// Stamp a strictly later mtime so the advance is observable, and add a
// function so the change is structural (forces the reindex path).
future := time.Now().Add(2 * time.Second)
writeTestFile(t, path, "package main\n\nfunc First() {}\n\nfunc Second() {}\n")
require.NoError(t, os.Chtimes(path, future, future))
w.patchGraph(path, ChangeModified)
info, statErr := os.Stat(path)
require.NoError(t, statErr)
after := s.LoadFileMtimes("")
assert.Equal(t, info.ModTime().UnixNano(), after["main.go"],
"a modify patch must persist the file's current on-disk mtime to the store")
assert.Greater(t, after["main.go"], before,
"the persisted mtime must advance past the modify")
}
// TestWatcher_DeleteEventForPresentFileKeepsMtime guards the interaction with
// reconcileKindWithDisk: a stale delete/rename event whose path is still a
// regular file is a replace/revert, downgraded to a modify — so the mtime must
// be refreshed, never pruned. This proves the prune fires only on a genuine
// on-disk deletion.
func TestWatcher_DeleteEventForPresentFileKeepsMtime(t *testing.T) {
dir, _, w, s := newSqliteWatcher(t, map[string]string{
"revert.go": "package main\n\nfunc Revert() {}\n",
})
path := filepath.Join(dir, "revert.go")
require.Contains(t, s.LoadFileMtimes(""), "revert.go")
w.patchGraph(path, ChangeDeleted)
assert.Contains(t, s.LoadFileMtimes(""), "revert.go",
"a delete event for a still-present file (a revert) must not prune its mtime")
}
// TestWatcher_DeletePatchInMemoryBackendSkipsStore proves the store prune
// self-skips cleanly on a backend that implements no FileMtimeDeleter (the
// in-memory graph): no panic, no capability required, and the in-memory mtime
// entry is still pruned. Injecting a store whose DeleteFileMtimes returns an
// error is not done here — it would require a full fake graph.Store (AddBatch /
// GetFileNodes / EvictFile / ...) for a path whose only effect is a logged,
// non-fatal warn — so the realistic "store cannot persist" branch is the
// capability-absent one exercised below.
func TestWatcher_DeletePatchInMemoryBackendSkipsStore(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "solo.go", "package main\n\nfunc Solo() {}\n")
path := filepath.Join(dir, "solo.go")
require.Contains(t, idx.FileMtimes(), "solo.go")
require.NoError(t, os.Remove(path))
w.patchGraph(path, ChangeDeleted)
assert.NotContains(t, idx.FileMtimes(), "solo.go",
"the in-memory mtime entry must be pruned even without a store deleter")
}