diff --git a/internal/collector/git_failure_test.go b/internal/collector/git_failure_test.go new file mode 100644 index 00000000..df10df48 --- /dev/null +++ b/internal/collector/git_failure_test.go @@ -0,0 +1,153 @@ +package collector + +import ( + "bytes" + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/labstack/driga/internal/cleanup" + "github.com/labstack/driga/internal/gitscan" +) + +func TestGitWorktreeFailuresAreIsolated(t *testing.T) { + for _, kind := range []string{"deleted", "no-marker", "corrupt"} { + t.Run(kind, func(t *testing.T) { + d, st, plat, a := setup(t) + a.obs = nil + plat.procs = nil + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"} { + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + } + repo := t.TempDir() + linked := filepath.Join(t.TempDir(), "linked") + git := func(args ...string) { + t.Helper() + c := exec.Command("git", args...) + c.Dir = repo + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + git("init", "-b", "main") + git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial") + git("worktree", "add", "-b", "topic", linked) + d.Cfg.Roots = []string{repo} + pass(t, d, st, Plan{Git: true, GitFull: true}) + marker := filepath.Join(linked, ".git") + original, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + switch kind { + case "deleted": + err = os.RemoveAll(linked) + case "no-marker": + err = os.Remove(marker) + case "corrupt": + err = os.WriteFile(marker, []byte("gitdir: missing-gitdir\n"), 0600) + } + if err != nil { + t.Fatal(err) + } + git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "healthy update") + var logs bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + defer slog.SetDefault(old) + at := d.Now().Add(time.Hour) + d.Now = func() time.Time { return at } + for i := 0; i < 2; i++ { + // A fresh scanner also proves diagnostics survive service restarts. + d.Git = gitscan.NewScanner(2) + r, err := Pass(context.Background(), d, st, Plan{Git: true, GitFull: true}) + if err != nil || r.SourceErrors["git"] != "" { + t.Fatalf("git source failed: %v %+v", err, r.SourceErrors) + } + ws, err := st.Worktrees(0) + if err != nil { + t.Fatal(err) + } + want := 1 + if kind == "corrupt" { + want = 2 + } + if len(ws) != want { + t.Fatalf("inventory: %+v", ws) + } + for _, w := range ws { + if w.Path == gitscan.NormalizePath(repo) && w.LastCommitSubject != "healthy update" { + t.Fatalf("healthy checkout stale: %+v", w) + } + if w.Path == gitscan.NormalizePath(linked) { + if w.ScanError == "" || w.LastCommitSubject != "initial" { + t.Fatalf("missing failure or lost previous observation: %+v", w) + } + } + } + sources, err := st.Sources() + if err != nil { + t.Fatal(err) + } + for _, s := range sources { + if s.Name == "git" && !s.LastOKAt.Equal(at) { + t.Fatalf("freshness: %+v", s) + } + } + candidates, err := cleanup.List(context.Background(), cleanup.Env{Store: st, Plat: plat, Sigs: d.Sigs, Home: os.Getenv("HOME"), DataDir: t.TempDir(), Now: at, Cfg: d.Cfg.Cleanup, Why: true, TempRoots: []string{t.TempDir()}}) + if err != nil { + t.Fatal(err) + } + for _, c := range candidates { + if kind != "corrupt" && c.Path == gitscan.NormalizePath(linked) { + t.Fatalf("retired cleanup candidate: %+v", c) + } + } + } + count := strings.Count(logs.String(), "git_worktree_failed") + want := 0 + if kind == "corrupt" { + want = 1 + } + if count != want { + t.Fatalf("failure logs=%d want=%d: %s", count, want, logs.String()) + } + if kind == "corrupt" { + if err := os.WriteFile(marker, original, 0600); err != nil { + t.Fatal(err) + } + pass(t, d, st, Plan{Git: true, GitFull: true}) + ws, err := st.Worktrees(0) + if err != nil { + t.Fatal(err) + } + for _, w := range ws { + if w.ScanError != "" { + t.Fatalf("recovery retained error: %+v", w) + } + } + // A new diagnostic logs once; recurrence of a previously seen one + // stays quiet even after recovery. + for _, contents := range []string{"invalid gitfile\n", "invalid gitfile\n", "gitdir: missing-gitdir\n"} { + if err := os.WriteFile(marker, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + pass(t, d, st, Plan{Git: true, GitFull: true}) + } + if count := strings.Count(logs.String(), "git_worktree_failed"); count != 2 { + t.Fatalf("distinct failure logs=%d: %s", count, logs.String()) + } + } + }) + } +} diff --git a/internal/collector/sources.go b/internal/collector/sources.go index 335201dc..e93ada3e 100644 --- a/internal/collector/sources.go +++ b/internal/collector/sources.go @@ -2,8 +2,10 @@ package collector import ( "context" + "crypto/sha256" "errors" "fmt" + "log/slog" "path/filepath" "sort" "strings" @@ -17,6 +19,7 @@ import ( "github.com/labstack/driga/internal/gitscan" "github.com/labstack/driga/internal/model" "github.com/labstack/driga/internal/platform" + "github.com/labstack/driga/internal/scrub" "github.com/labstack/driga/internal/store" ) @@ -477,14 +480,38 @@ func writeGit(tx *store.Tx, repos []repoResult, scanErr error, source func(strin return err } report.Repos++ + previous, err := tx.Worktrees(id) + if err != nil { + return err + } + byPath := make(map[string]model.Worktree, len(previous)) + for _, w := range previous { + byPath[w.Path] = w + } for _, w := range repo.worktrees { + if w.ScanError != "" { + message := scrub.Scrub(w.ScanError) + if old, ok := byPath[w.Path]; ok { + w = old + } + w.ScanError = message + key := fmt.Sprintf("git_worktree_error:%x", sha256.Sum256([]byte(w.Path+"\x00"+message))) + if _, err := tx.Meta(key); errors.Is(err, store.ErrNotFound) { + if err := tx.SetMeta(key, "1"); err != nil { + return err + } + slog.Warn("git worktree scan failed", "event", "git_worktree_failed", "path", w.Path, "err", message) + } else if err != nil { + return err + } + } w.RepoID = id if _, err := tx.UpsertWorktree(w); err != nil { return err } } - // Only a complete scan can prove absence. A failed status/log read - // returns a partial list and must retain the last known inventory. + // Checkout failures remain in inventory with their diagnostics. Only + // a repository-level inventory failure prevents reconciliation. if repo.err == nil { previous, err := tx.Worktrees(id) if err != nil { diff --git a/internal/gitscan/scan.go b/internal/gitscan/scan.go index 2feec683..742517af 100644 --- a/internal/gitscan/scan.go +++ b/internal/gitscan/scan.go @@ -284,6 +284,10 @@ func (s *Scanner) git(ctx context.Context, dir string, args ...string) ([]byte, return nil, ctx.Err() } if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) && len(exit.Stderr) != 0 { + return nil, fmt.Errorf("git %s in %s: %w: %s", strings.Join(args, " "), dir, err, strings.TrimSpace(string(exit.Stderr))) + } return nil, fmt.Errorf("git %s in %s: %w", strings.Join(args, " "), dir, err) } return out, nil @@ -328,7 +332,9 @@ const ( ) // Scan reads the repository's non-bare worktrees. A full scan bypasses metadata -// caching; its caller schedules one at least every five minutes. +// caching; its caller schedules one at least every five minutes. Missing checkouts +// are omitted; status/log failures remain in inventory with ScanError populated. +// Repository inventory failures and cancellation are returned as errors. func (s *Scanner) Scan(ctx context.Context, repoPath string, mode ScanMode) ([]model.Worktree, error) { if err := ctx.Err(); err != nil { return nil, err @@ -391,12 +397,13 @@ func (s *Scanner) Scan(ctx context.Context, repoPath string, mode ScanMode) ([]m if entry.Bare { continue } - // A linked worktree whose directory was deleted stays listed until - // `git worktree prune`; it has nothing to scan. - if _, err := os.Stat(entry.Path); errors.Is(err, fs.ErrNotExist) { + // Git retains registrations after directories or their markers disappear. + // Lstat distinguishes an absent marker from a damaged gitdir/symlink. + if goneWorktree(entry.Path) { + s.mu.Lock() + delete(s.cache, entry.Path) + s.mu.Unlock() continue - } else if err != nil { - return nil, err } entries = append(entries, entry) } @@ -431,14 +438,40 @@ func (s *Scanner) Scan(ctx context.Context, repoPath string, mode ScanMode) ([]m } close(jobs) wg.Wait() + if err := ctx.Err(); err != nil { + return nil, err + } var worktrees []model.Worktree for i, w := range results { - if errs[i] == nil { - worktrees = append(worktrees, w) + if errs[i] != nil { + // A checkout may disappear between inventory and status. + if goneWorktree(entries[i].Path) { + continue + } + s.mu.Lock() + w = s.cache[entries[i].Path].Worktree + // Retry failures even when metadata timestamps have not changed. + delete(s.cache, entries[i].Path) + s.mu.Unlock() + w.Path = entries[i].Path + w.ScanError = errs[i].Error() } + worktrees = append(worktrees, w) } sort.Slice(worktrees, func(i, j int) bool { return worktrees[i].Path < worktrees[j].Path }) - return worktrees, errors.Join(errs...) + return worktrees, nil +} + +func goneWorktree(path string) bool { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.IsDir()) { + return true + } + if err != nil { + return false + } + _, err = os.Lstat(filepath.Join(path, ".git")) + return errors.Is(err, fs.ErrNotExist) } func (s *Scanner) scanWorktree(ctx context.Context, entry WorktreeEntry, full bool) (model.Worktree, error) { diff --git a/internal/gitscan/scanner_pruning_test.go b/internal/gitscan/scanner_pruning_test.go index 5a8df055..b51a688a 100644 --- a/internal/gitscan/scanner_pruning_test.go +++ b/internal/gitscan/scanner_pruning_test.go @@ -24,7 +24,10 @@ func TestScannerPrunesRemovedRepositoriesAndWorktrees(t *testing.T) { var paths []string for i := 0; i < 32; i++ { p := filepath.Join(base, fmt.Sprint(i)) - if err := os.MkdirAll(filepath.Join(p, "linked"), 0700); err != nil { + if err := os.MkdirAll(filepath.Join(p, "linked", ".git"), 0700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(p, ".git"), 0700); err != nil { t.Fatal(err) } paths = append(paths, p) diff --git a/internal/gitscan/worktree_failure_test.go b/internal/gitscan/worktree_failure_test.go new file mode 100644 index 00000000..dd92ea5c --- /dev/null +++ b/internal/gitscan/worktree_failure_test.go @@ -0,0 +1,78 @@ +package gitscan + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" +) + +func TestScanRetiresMissingMarkersAndRetriesCorruption(t *testing.T) { + gitEnv(t) + repo := initRepo(t, filepath.Join(t.TempDir(), "repo")) + linked := filepath.Join(t.TempDir(), "linked") + gitTest(t, repo, "worktree", "add", "-b", "topic", linked) + linked = NormalizePath(linked) + scanner := NewScanner(2) + if _, err := scanner.Scan(context.Background(), repo, Full); err != nil { + t.Fatal(err) + } + marker := filepath.Join(linked, ".git") + original, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(marker); err != nil { + t.Fatal(err) + } + var polls atomic.Int32 + run := scanner.runGit + scanner.runGit = func(ctx context.Context, dir string, args ...string) ([]byte, error) { + if dir == linked { + polls.Add(1) + } + return run(ctx, dir, args...) + } + for _, mode := range []ScanMode{Cached, Full, Identity} { + ws, err := scanner.Scan(context.Background(), repo, mode) + if err != nil || len(ws) != 1 || ws[0].Path != repo { + t.Fatalf("retired inventory: %+v %v", ws, err) + } + } + if polls.Load() != 0 { + t.Fatalf("retired checkout polled %d times", polls.Load()) + } + if err := os.WriteFile(marker, []byte("gitdir: missing\n"), 0600); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + ws, err := scanner.Scan(context.Background(), repo, Cached) + if err != nil || len(ws) != 2 { + t.Fatalf("partial scan: %+v %v", ws, err) + } + for _, w := range ws { + if w.Path == linked && w.ScanError == "" { + t.Fatalf("missing diagnostic: %+v", w) + } + if w.Path == repo && (w.ScanError != "" || w.Head == "") { + t.Fatalf("healthy checkout: %+v", w) + } + } + } + if polls.Load() != 2 { + t.Fatalf("failed checkout was not retried: %d", polls.Load()) + } + if err := os.WriteFile(marker, original, 0600); err != nil { + t.Fatal(err) + } + ws, err := scanner.Scan(context.Background(), repo, Cached) + if err != nil || len(ws) != 2 { + t.Fatalf("recovery: %+v %v", ws, err) + } + for _, w := range ws { + if w.ScanError != "" || w.LastCommitSubject == "" { + t.Fatalf("failed recovery: %+v", w) + } + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 5f1765ab..b4af02ea 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -104,6 +104,7 @@ type Repo struct { // Worktree is one checkout of a repo. type Worktree struct { + ScanError string `json:"scan_error,omitempty"` MachineID string `json:"machine_id"` ID int64 `json:"id"` RepoID int64 `json:"repo_id"` diff --git a/internal/store/active_index_test.go b/internal/store/active_index_test.go index 51114206..17bba905 100644 --- a/internal/store/active_index_test.go +++ b/internal/store/active_index_test.go @@ -81,7 +81,7 @@ func BenchmarkActiveSessions(b *testing.B) { func TestForwardMigrationAddsActivityIndex(t *testing.T) { st := openTest(t, "", Memory) // Rewind to the baseline, then run goose forward. - for _, sql := range []string{"DROP TABLE chat_command_output", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DROP INDEX sessions_active_activity", "ALTER TABLE messages DROP COLUMN decided_by", "ALTER TABLE messages DROP COLUMN tool", "ALTER TABLE procs DROP COLUMN argv", "DROP TABLE panel_events", "DROP TABLE panel_polls", "DROP TABLE prompt_intervals", "ALTER TABLE machine_samples DROP COLUMN disk_total", "ALTER TABLE machine_samples DROP COLUMN cpu_percent", "DELETE FROM goose_db_version WHERE version_id>1"} { + for _, sql := range []string{"ALTER TABLE worktrees DROP COLUMN scan_error", "DELETE FROM goose_db_version WHERE version_id=8", "DROP TABLE chat_command_output", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DROP INDEX sessions_active_activity", "ALTER TABLE messages DROP COLUMN decided_by", "ALTER TABLE messages DROP COLUMN tool", "ALTER TABLE procs DROP COLUMN argv", "DROP TABLE panel_events", "DROP TABLE panel_polls", "DROP TABLE prompt_intervals", "ALTER TABLE machine_samples DROP COLUMN disk_total", "ALTER TABLE machine_samples DROP COLUMN cpu_percent", "DELETE FROM goose_db_version WHERE version_id>1"} { if _, err := st.db.Exec(sql); err != nil { t.Fatal(err) } diff --git a/internal/store/command_output_test.go b/internal/store/command_output_test.go index 8203f625..6da1991b 100644 --- a/internal/store/command_output_test.go +++ b/internal/store/command_output_test.go @@ -90,7 +90,7 @@ func TestCommandOutputMigrationFromSix(t *testing.T) { if err != nil { t.Fatal(err) } - for _, statement := range []string{"DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7"} { + for _, statement := range []string{"ALTER TABLE worktrees DROP COLUMN scan_error", "DELETE FROM goose_db_version WHERE version_id=8", "DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7"} { if _, err = db.Exec(statement); err != nil { t.Fatal(err) } diff --git a/internal/store/conversation_claimed_test.go b/internal/store/conversation_claimed_test.go index 007af220..63053994 100644 --- a/internal/store/conversation_claimed_test.go +++ b/internal/store/conversation_claimed_test.go @@ -57,7 +57,7 @@ func TestConversationTurnHookClaimBackfill(t *testing.T) { if err != nil { t.Fatal(err) } - for _, query := range []string{"DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DELETE FROM goose_db_version WHERE version_id=6"} { + for _, query := range []string{"ALTER TABLE worktrees DROP COLUMN scan_error", "DELETE FROM goose_db_version WHERE version_id=8", "DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DELETE FROM goose_db_version WHERE version_id=6"} { if _, err := db.Exec(query); err != nil { db.Close() t.Fatal(err) @@ -110,7 +110,7 @@ func TestConversationClaimEvidenceMigratesAndPersists(t *testing.T) { if err != nil { t.Fatal(err) } - for _, query := range []string{"DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DELETE FROM goose_db_version WHERE version_id=6"} { + for _, query := range []string{"ALTER TABLE worktrees DROP COLUMN scan_error", "DELETE FROM goose_db_version WHERE version_id=8", "DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DELETE FROM goose_db_version WHERE version_id=6"} { if _, err := db.Exec(query); err != nil { db.Close() t.Fatal(err) diff --git a/internal/store/conversation_panel_claimed_test.go b/internal/store/conversation_panel_claimed_test.go index 371ccbb5..5f2944fe 100644 --- a/internal/store/conversation_panel_claimed_test.go +++ b/internal/store/conversation_panel_claimed_test.go @@ -95,7 +95,7 @@ func TestConversationPanelTurnHookClaimBackfill(t *testing.T) { if err != nil { t.Fatal(err) } - for _, query := range []string{"DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DELETE FROM goose_db_version WHERE version_id=6"} { + for _, query := range []string{"ALTER TABLE worktrees DROP COLUMN scan_error", "DELETE FROM goose_db_version WHERE version_id=8", "DROP TABLE chat_command_output", "DELETE FROM goose_db_version WHERE version_id=7", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DELETE FROM goose_db_version WHERE version_id=6"} { if _, err := db.Exec(query); err != nil { db.Close() t.Fatal(err) diff --git a/internal/store/migrations/0008_worktree_scan_error.sql b/internal/store/migrations/0008_worktree_scan_error.sql new file mode 100644 index 00000000..1ee7decd --- /dev/null +++ b/internal/store/migrations/0008_worktree_scan_error.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE worktrees ADD COLUMN scan_error TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE worktrees DROP COLUMN scan_error; diff --git a/internal/store/queries.go b/internal/store/queries.go index b35894c0..950909aa 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -256,11 +256,11 @@ func (q *querier) ReposScoped(scope Scope) ([]model.Repo, error) { }) } -const worktreeColumns = "repo_id,path,branch,head,ahead,behind,dirty,untracked,oldest_dirty_mtime,last_commit_at,last_commit_subject,updated_at" +const worktreeColumns = "repo_id,path,branch,head,ahead,behind,dirty,untracked,oldest_dirty_mtime,last_commit_at,last_commit_subject,updated_at,scan_error" // UpsertWorktree creates or updates a checkout identified by path. func (tx *Tx) UpsertWorktree(w model.Worktree) (int64, error) { - return tx.upsertID("worktrees", "machine_id,"+worktreeColumns, "machine_id,path", tx.machineID, w.RepoID, w.Path, w.Branch, w.Head, w.Ahead, w.Behind, w.Dirty, w.Untracked, unix(w.OldestDirtyMtime), unix(w.LastCommitAt), w.LastCommitSubject, unix(w.UpdatedAt)) + return tx.upsertID("worktrees", "machine_id,"+worktreeColumns, "machine_id,path", tx.machineID, w.RepoID, w.Path, w.Branch, w.Head, w.Ahead, w.Behind, w.Dirty, w.Untracked, unix(w.OldestDirtyMtime), unix(w.LastCommitAt), w.LastCommitSubject, unix(w.UpdatedAt), w.ScanError) } // DeleteWorktree removes a checkout while retaining its sessions as history. @@ -277,7 +277,7 @@ func (q *querier) Worktrees(repoID int64) ([]model.Worktree, error) { return readMany(q, "SELECT machine_id,id,"+worktreeColumns+" FROM worktrees WHERE machine_id=? AND (?=0 OR repo_id=?) ORDER BY path", []any{q.machineID, repoID, repoID}, func(s scanner) (model.Worktree, error) { var w model.Worktree var dirty, commit, updated int64 - err := s.Scan(&w.MachineID, &w.ID, &w.RepoID, &w.Path, &w.Branch, &w.Head, &w.Ahead, &w.Behind, &w.Dirty, &w.Untracked, &dirty, &commit, &w.LastCommitSubject, &updated) + err := s.Scan(&w.MachineID, &w.ID, &w.RepoID, &w.Path, &w.Branch, &w.Head, &w.Ahead, &w.Behind, &w.Dirty, &w.Untracked, &dirty, &commit, &w.LastCommitSubject, &updated, &w.ScanError) w.OldestDirtyMtime, w.LastCommitAt, w.UpdatedAt = fromUnix(dirty), fromUnix(commit), fromUnix(updated) return w, err }) diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 2abe7208..3fd99931 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -32,7 +32,7 @@ func TestFreshBaseline(t *testing.T) { t.Fatal(err) } rows.Close() - if !reflect.DeepEqual(versions, []int{0, 1, 2, 3, 4, 5, 6, 7}) { + if !reflect.DeepEqual(versions, []int{0, 1, 2, 3, 4, 5, 6, 7, 8}) { t.Fatal(versions) } for pragma, want := range map[string]int{"user_version": 1, "auto_vacuum": 2, "foreign_keys": 1} { @@ -214,7 +214,7 @@ func TestForwardMigrationFromBaseline(t *testing.T) { t.Fatal(err) } // Rewind to a baseline-only database, as created by the previous release. - for _, stmt := range []string{"DROP TABLE chat_command_output", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DROP INDEX sessions_active_activity", "ALTER TABLE messages DROP COLUMN decided_by", "ALTER TABLE messages DROP COLUMN tool", "ALTER TABLE procs DROP COLUMN argv", "DROP TABLE panel_events", "DROP TABLE panel_polls", "DROP TABLE prompt_intervals", "ALTER TABLE machine_samples DROP COLUMN disk_total", "ALTER TABLE machine_samples DROP COLUMN cpu_percent", "DELETE FROM goose_db_version WHERE version_id > 1"} { + for _, stmt := range []string{"ALTER TABLE worktrees DROP COLUMN scan_error", "DELETE FROM goose_db_version WHERE version_id=8", "DROP TABLE chat_command_output", "ALTER TABLE sessions DROP COLUMN conversation_claimed", "DROP INDEX sessions_active_activity", "ALTER TABLE messages DROP COLUMN decided_by", "ALTER TABLE messages DROP COLUMN tool", "ALTER TABLE procs DROP COLUMN argv", "DROP TABLE panel_events", "DROP TABLE panel_polls", "DROP TABLE prompt_intervals", "ALTER TABLE machine_samples DROP COLUMN disk_total", "ALTER TABLE machine_samples DROP COLUMN cpu_percent", "DELETE FROM goose_db_version WHERE version_id > 1"} { if _, err := db.Exec(stmt); err != nil { t.Fatal(err) }