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
158 lines
6.7 KiB
Go
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")
|
|
}
|