diff --git a/internal/cli/app.go b/internal/cli/app.go index db2600e..d0eeb4c 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -103,14 +103,55 @@ func hasHelpFlag(args []string) bool { return false } -func runDoctor(stdout io.Writer, args []string) error { +func parseOptionalRepoRootArg(fs *flag.FlagSet, args []string, repoRootFlag *string, defaultRepoRoot string) (string, error) { + repoRootArg := "" + parseArgs := args + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + repoRootArg = args[0] + parseArgs = args[1:] + } + if err := fs.Parse(parseArgs); err != nil { + return "", err + } + repoRoot := strings.TrimSpace(*repoRootFlag) + if repoRoot == "" { + repoRoot = strings.TrimSpace(repoRootArg) + } + if repoRoot == "" { + repoRoot = defaultRepoRoot + } + return repoRoot, nil +} + +func runDoctor(ctx context.Context, cfg config.Config, stdout io.Writer, args []string) error { fs := flag.NewFlagSet("doctor", flag.ContinueOnError) fs.SetOutput(io.Discard) fix := fs.Bool("fix", false, "apply non-destructive fixes") - if err := fs.Parse(args); err != nil { + repoRootFlag := fs.String("repo-root", "", "repository root to inspect (optional)") + + defaultRepoRoot := "" + if config.IsRepoDBDir(cfg.DBDir) { + defaultRepoRoot = "." + } + repoRoot, err := parseOptionalRepoRootArg(fs, args, repoRootFlag, defaultRepoRoot) + if err != nil { return err } - report, err := doctor.RunWithFix(*fix) + + dbPath := "" + if repoRoot != "" { + canonical, err := store.CanonicalRepoPath(repoRoot) + if err != nil { + return err + } + p, err := dbPathForRepo(cfg, repoRoot, canonical) + if err != nil { + return err + } + dbPath = p + } + + report, err := doctor.RunWithFix(*fix, dbPath) if err != nil { return err } @@ -1021,28 +1062,34 @@ func runClean(ctx context.Context, cfg config.Config, stdout io.Writer, args []s fs := flag.NewFlagSet("clean", flag.ContinueOnError) fs.SetOutput(io.Discard) vacuum := fs.Bool("vacuum", false, "run VACUUM on databases") + ftsOptimize := fs.Bool("fts-optimize", false, "run FTS optimize on databases (symbol_fts)") repoRootFlag := fs.String("repo-root", "", "repository root to clean") - if err := fs.Parse(args); err != nil { - return err - } - repoRoot := strings.TrimSpace(*repoRootFlag) - if repoRoot == "" && fs.NArg() > 0 { - repoRoot = fs.Arg(0) + + defaultRepoRoot := "" + if config.IsRepoDBDir(cfg.DBDir) { + defaultRepoRoot = "." } - if repoRoot == "" && config.IsRepoDBDir(cfg.DBDir) { - repoRoot = "." + repoRoot, err := parseOptionalRepoRootArg(fs, args, repoRootFlag, defaultRepoRoot) + if err != nil { + return err } type dbResult struct { Path string `json:"path"` Action string `json:"action"` + SizeBefore int64 `json:"size_before_bytes"` + SizeAfter int64 `json:"size_after_bytes"` ReclaimedBytes int64 `json:"reclaimed_bytes"` + Vacuumed bool `json:"vacuumed,omitempty"` + FTSOptimized bool `json:"fts_optimized,omitempty"` + FTSOptimizeMS int64 `json:"fts_optimize_ms,omitempty"` CanonicalRepo string `json:"canonical_repo,omitempty"` Error string `json:"error,omitempty"` } report := map[string]any{ - "vacuum": *vacuum, - "dbs": []dbResult{}, + "vacuum": *vacuum, + "fts_optimize": *ftsOptimize, + "dbs": []dbResult{}, } results := make([]dbResult, 0) var reclaimed int64 @@ -1058,24 +1105,37 @@ func runClean(ctx context.Context, cfg config.Config, stdout io.Writer, args []s } res := dbResult{Path: dbPath, CanonicalRepo: canonical} before := fileSize(dbPath) + res.SizeBefore = before s, err := store.OpenWithOptions(dbPath, store.OpenOptions{PerformanceProfile: cfg.DBPerformanceProfile}) if err != nil { return err } defer s.Close() + if *ftsOptimize { + dur, err := s.OptimizeFTS(ctx) + if err != nil { + return err + } + res.FTSOptimized = true + res.FTSOptimizeMS = dur.Milliseconds() + } if *vacuum { if err := s.Vacuum(ctx); err != nil { return err } - after := fileSize(dbPath) - if before > after { - res.ReclaimedBytes = before - after - reclaimed += res.ReclaimedBytes - } + res.Vacuumed = true res.Action = "vacuumed" + } else if *ftsOptimize { + res.Action = "fts_optimized" } else { res.Action = "inspected" } + after := fileSize(dbPath) + res.SizeAfter = after + if before > after { + res.ReclaimedBytes = before - after + reclaimed += res.ReclaimedBytes + } results = append(results, res) report["dbs"] = results report["reclaimed_bytes"] = reclaimed @@ -1095,7 +1155,7 @@ func runClean(ctx context.Context, cfg config.Config, stdout io.Writer, args []s } dbPath := filepath.Join(cfg.DBDir, entry.Name()) sizeBefore := fileSize(dbPath) - res := dbResult{Path: dbPath} + res := dbResult{Path: dbPath, SizeBefore: sizeBefore} s, err := store.OpenWithOptions(dbPath, store.OpenOptions{PerformanceProfile: cfg.DBPerformanceProfile}) if err != nil { res.Action = "skipped" @@ -1138,6 +1198,24 @@ func runClean(ctx context.Context, cfg config.Config, stdout io.Writer, args []s results = append(results, res) continue } + if !*vacuum { + res.Action = "kept" + } + if *ftsOptimize { + dur, err := s.OptimizeFTS(ctx) + if err != nil { + _ = s.Close() + res.Action = "skipped" + res.Error = err.Error() + results = append(results, res) + continue + } + res.FTSOptimized = true + res.FTSOptimizeMS = dur.Milliseconds() + if !*vacuum && res.Action == "kept" { + res.Action = "fts_optimized" + } + } if *vacuum { if err := s.Vacuum(ctx); err != nil { _ = s.Close() @@ -1146,16 +1224,16 @@ func runClean(ctx context.Context, cfg config.Config, stdout io.Writer, args []s results = append(results, res) continue } - after := fileSize(dbPath) - if sizeBefore > after { - res.ReclaimedBytes = sizeBefore - after - reclaimed += res.ReclaimedBytes - } + res.Vacuumed = true res.Action = "vacuumed" - } else { - res.Action = "kept" } _ = s.Close() + sizeAfter := fileSize(dbPath) + res.SizeAfter = sizeAfter + if sizeBefore > sizeAfter { + res.ReclaimedBytes = sizeBefore - sizeAfter + reclaimed += res.ReclaimedBytes + } results = append(results, res) } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 582dd18..7f6861e 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -256,10 +256,11 @@ func newCommandList() []*command { description: "run diagnostics", usageLines: []string{ " doctor", + " add --repo-root PATH to inspect a repo DB", " add --fix for non-destructive autofixes", }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, invokedName string, args []string) error { - return runDoctor(stdout, args) + return runDoctor(ctx, cfg, stdout, args) }, }, { @@ -324,13 +325,15 @@ func newCommandList() []*command { { name: "clean", description: "clean index data", - usageLines: []string{" clean [repo-path] [--vacuum]"}, + usageLines: []string{" clean [repo-path] [--vacuum] [--fts-optimize]"}, flags: []commandFlag{ {name: "--vacuum", description: "VACUUM the database after cleanup"}, + {name: "--fts-optimize", description: "run FTS optimize (symbol_fts)"}, }, examples: []string{ "codegraph clean .", "codegraph clean . --vacuum", + "codegraph clean . --fts-optimize", }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, invokedName string, args []string) error { return runClean(ctx, cfg, stdout, args) diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 287eca4..cf4fa46 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -1,17 +1,21 @@ package doctor import ( + "context" + "database/sql" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" + "strings" "github.com/isink17/codegraph/internal/appname" "github.com/isink17/codegraph/internal/config" "github.com/isink17/codegraph/internal/gotool" "github.com/isink17/codegraph/internal/platform" + "github.com/isink17/codegraph/internal/store" ) type Report struct { @@ -22,15 +26,23 @@ type Report struct { CacheDir string `json:"cache_dir"` CodegraphOnPath bool `json:"codegraph_on_path"` CodegraphPath string `json:"codegraph_path,omitempty"` + SQLiteDriver string `json:"sqlite_driver"` + DB *DBInfo `json:"db,omitempty"` AppliedFixes []string `json:"applied_fixes,omitempty"` Recommendations []string `json:"recommendations,omitempty"` } +type DBInfo struct { + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Pragmas store.DBPragmas `json:"pragmas"` +} + func Run() (Report, error) { - return RunWithFix(false) + return RunWithFix(false, "") } -func RunWithFix(fix bool) (Report, error) { +func RunWithFix(fix bool, dbPath string) (Report, error) { paths, err := platform.DefaultPaths() if err != nil { return Report{}, err @@ -44,7 +56,9 @@ func RunWithFix(fix bool) (Report, error) { binaryPath, lookErr := exec.LookPath("codegraph") onPath := lookErr == nil if lookErr != nil && !errors.Is(lookErr, exec.ErrNotFound) { - return Report{}, lookErr + // Be conservative: doctor should still run even if the PATH lookup found something unusable. + onPath = false + binaryPath = "" } appliedFixes := []string{} if fix { @@ -81,6 +95,16 @@ func RunWithFix(fix bool) (Report, error) { recommendations = append(recommendations, "verify after reopening shell: "+gotool.VerifyCommandHint(appname.BinaryName)) } + var dbInfo *DBInfo + if strings.TrimSpace(dbPath) != "" { + info, err := inspectDB(context.Background(), dbPath) + if err != nil { + recommendations = append(recommendations, "repo DB inspect failed: "+err.Error()) + } else { + dbInfo = info + } + } + return Report{ GOOS: runtime.GOOS, ConfigPath: filepath.Clean(configPath), @@ -89,6 +113,8 @@ func RunWithFix(fix bool) (Report, error) { CacheDir: paths.CacheDir, CodegraphOnPath: onPath, CodegraphPath: binaryPath, + SQLiteDriver: store.SQLiteDriverName(), + DB: dbInfo, AppliedFixes: appliedFixes, Recommendations: recommendations, }, nil @@ -101,3 +127,25 @@ func firstPathHint() string { } return hints[0] } + +func inspectDB(ctx context.Context, dbPath string) (*DBInfo, error) { + st, err := os.Stat(dbPath) + if err != nil { + return nil, err + } + db, err := sql.Open(store.SQLiteDriverName(), dbPath) + if err != nil { + return nil, err + } + defer db.Close() + pragmas, err := store.QueryDBPragmas(ctx, db) + if err != nil { + return nil, err + } + + return &DBInfo{ + Path: filepath.Clean(dbPath), + SizeBytes: st.Size(), + Pragmas: pragmas, + }, nil +} diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 2f43890..f05298b 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -58,7 +58,7 @@ func TestRunReportsCodegraphMissingFromPath(t *testing.T) { func TestRunWithFixCreatesConfig(t *testing.T) { home := filepath.Join(t.TempDir(), "codegraph-home") t.Setenv("CODEGRAPH_HOME", home) - report, err := RunWithFix(true) + report, err := RunWithFix(true, "") if err != nil { t.Fatalf("RunWithFix(true) error = %v", err) } diff --git a/internal/store/store.go b/internal/store/store.go index dabdde1..3a3d764 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -503,6 +503,78 @@ func (s *Store) Vacuum(ctx context.Context) error { return err } +func (s *Store) OptimizeFTS(ctx context.Context) (time.Duration, error) { + start := time.Now() + _, err := s.db.ExecContext(ctx, `INSERT INTO symbol_fts(symbol_fts) VALUES('optimize')`) + return time.Since(start), err +} + +type DBPragmas struct { + SQLiteVersion string `json:"sqlite_version"` + JournalMode string `json:"journal_mode"` + Synchronous string `json:"synchronous"` + TempStore string `json:"temp_store"` + AutoVacuum int64 `json:"auto_vacuum"` + PageSize int64 `json:"page_size"` + BusyTimeoutMS int64 `json:"busy_timeout_ms"` + ForeignKeys bool `json:"foreign_keys"` + WalAutocheckpoint int64 `json:"wal_autocheckpoint"` + UserVersion int64 `json:"user_version"` + SymbolFTSPresent bool `json:"symbol_fts_present"` +} + +func (s *Store) DBPragmas(ctx context.Context) (DBPragmas, error) { + return QueryDBPragmas(ctx, s.db) +} + +func QueryDBPragmas(ctx context.Context, db *sql.DB) (DBPragmas, error) { + var out DBPragmas + + if err := db.QueryRowContext(ctx, `SELECT sqlite_version()`).Scan(&out.SQLiteVersion); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA journal_mode`).Scan(&out.JournalMode); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA synchronous`).Scan(&out.Synchronous); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA temp_store`).Scan(&out.TempStore); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA auto_vacuum`).Scan(&out.AutoVacuum); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&out.PageSize); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA busy_timeout`).Scan(&out.BusyTimeoutMS); err != nil { + return DBPragmas{}, err + } + var foreignKeys int64 + if err := db.QueryRowContext(ctx, `PRAGMA foreign_keys`).Scan(&foreignKeys); err != nil { + return DBPragmas{}, err + } + out.ForeignKeys = foreignKeys != 0 + if err := db.QueryRowContext(ctx, `PRAGMA wal_autocheckpoint`).Scan(&out.WalAutocheckpoint); err != nil { + return DBPragmas{}, err + } + if err := db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&out.UserVersion); err != nil { + return DBPragmas{}, err + } + + var symbolFTSName string + err := db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name='symbol_fts'`).Scan(&symbolFTSName) + if err == nil && symbolFTSName == "symbol_fts" { + out.SymbolFTSPresent = true + } else if errors.Is(err, sql.ErrNoRows) { + out.SymbolFTSPresent = false + } else if err != nil { + return DBPragmas{}, err + } + return out, nil +} + func (s *Store) ExistingFiles(ctx context.Context, repoID int64) (map[string]FileRecord, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, path, language, size_bytes, mtime_unix_ns, content_sha256, is_deleted