diff --git a/README.md b/README.md index 97e7e88..124f528 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,20 @@ detached snapshot of the default branch as it existed at the successful fetch. The repository's primary worktree and its local default branch are not modified. +### `reset --force` + +Force-removes every worktree registered in the selected database, then wipes +and recreates that database. This permanently discards uncommitted work in +those worktrees. It refuses database records outside the manager's worktree +pool to prevent accidental deletion of unrelated paths. + +The command may remove the worktree from which it is run, so invoke it from a +primary checkout or another directory: + +```sh +worktree-manager reset --force +``` + Before acquisition and listing, Git worktrees under the manager pool are reconciled with the selected database. This prevents a worktree created with one repository-local database from being invisible to another database and diff --git a/SKILL.md b/SKILL.md index e555fa8..de574fc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -81,6 +81,9 @@ in the worktree will be discarded on release. - `worktree-manager list` - show all managed worktrees and their status. - `worktree-manager verify` - check registered worktrees match git state. - `worktree-manager doctor` - repair state created by older versions. +- `worktree-manager reset --force` - permanently remove all managed worktrees + in the selected database and recreate it. Run this from a primary checkout + or another directory because it can remove the current worktree. If SQLite reports a read-only database, pass `--database /path/to/repo/.worktree-manager/state.db` and add diff --git a/cmd/worktree-manager/main.go b/cmd/worktree-manager/main.go index 218404f..3ad634c 100644 --- a/cmd/worktree-manager/main.go +++ b/cmd/worktree-manager/main.go @@ -21,6 +21,7 @@ Usage: worktree-manager list worktree-manager verify worktree-manager doctor + worktree-manager reset --force Commands: acquire Acquire a ready-to-use worktree. Prints the absolute path to stdout. @@ -28,6 +29,7 @@ Commands: list List all managed worktrees. verify Verify consistency of registered worktrees with git state. doctor Repair legacy branch and ownership records. + reset Force-remove all managed worktrees and recreate the database. Acquire options: -b, --branch Branch name (e.g. BenE/add-unit-menu). @@ -217,6 +219,39 @@ func main() { os.Exit(1) } + case "reset": + if err := requireForce(rest); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + fmt.Fprintln(os.Stderr, "usage: worktree-manager reset --force") + os.Exit(2) + } + database, err := openDatabase(databasePath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: open db: %v\n", err) + os.Exit(1) + } + m, err := newManager(database, baseDir) + if err != nil { + _ = database.Close() + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + removed, err := m.RemoveAllWorktrees() + if err != nil { + _ = database.Close() + printCommandError(err, databasePath) + os.Exit(1) + } + if err := database.Close(); err != nil { + fmt.Fprintf(os.Stderr, "error: close db: %v\n", err) + os.Exit(1) + } + if err := db.Recreate(databasePath); err != nil { + fmt.Fprintf(os.Stderr, "error: recreate db: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "reset: removed %d worktrees and recreated database\n", removed) + case "-h", "--help", "help": fmt.Fprint(os.Stderr, usage) @@ -251,6 +286,13 @@ func databaseError(err error, path string) error { return fmt.Errorf("%w\n\nadvice: SQLite cannot write to %s in this environment. Retry with a database inside the repository worktree-manager folder, for example:\n worktree-manager --database /.worktree-manager/state.db acquire ...\nUse the same --database path for subsequent commands, and add .worktree-manager/ to the repository's .gitignore", err, path) } +func requireForce(args []string) error { + if len(args) != 1 || args[0] != "--force" { + return fmt.Errorf("reset permanently removes managed worktrees and requires --force") + } + return nil +} + // parseAcquireArgs parses the arguments for the acquire command. It supports // both flags (-b/--branch, -r/--repo) and positional arguments, interpreted as // branch name first, then repo-path. It is an error to specify the same value via diff --git a/cmd/worktree-manager/main_test.go b/cmd/worktree-manager/main_test.go new file mode 100644 index 0000000..c96c607 --- /dev/null +++ b/cmd/worktree-manager/main_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestRequireForce(t *testing.T) { + if err := requireForce([]string{"--force"}); err != nil { + t.Fatalf("requireForce(--force): %v", err) + } + for _, args := range [][]string{nil, {}, {"--force", "extra"}, {"--yes"}} { + if err := requireForce(args); err == nil { + t.Fatalf("requireForce(%q) succeeded", args) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 4c4ed25..a37787f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -52,6 +52,24 @@ func DefaultDBPath() string { return filepath.Join(DefaultStateDir(), "state.db") } +// Recreate removes the database and its SQLite sidecar files, then creates an +// empty database with the current schema. +func Recreate(path string) error { + for _, candidate := range []string{path, path + "-shm", path + "-wal"} { + if err := os.Remove(candidate); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove database file %s: %w", candidate, err) + } + } + database, err := Open(path) + if err != nil { + return err + } + if err := database.Close(); err != nil { + return fmt.Errorf("close recreated database: %w", err) + } + return nil +} + // IsReadonlyError reports whether err is SQLite's read-only database error. func IsReadonlyError(err error) bool { if err == nil { diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 6c934c4..771f373 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -24,6 +24,44 @@ func TestDefaultDBPath(t *testing.T) { } } +func TestRecreate(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + d, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + tx, err := d.BeginTx() + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if _, err := d.GetOrCreateRepository(tx, "/repo/reset", "main"); err != nil { + t.Fatalf("GetOrCreateRepository: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if err := Recreate(path); err != nil { + t.Fatalf("Recreate: %v", err) + } + + recreated, err := Open(path) + if err != nil { + t.Fatalf("Open recreated database: %v", err) + } + t.Cleanup(func() { recreated.Close() }) + repos, err := recreated.ListAllRepositories() + if err != nil { + t.Fatalf("ListAllRepositories: %v", err) + } + if len(repos) != 0 { + t.Fatalf("expected empty recreated database, got %+v", repos) + } +} + func newTestDB(t *testing.T) *DB { t.Helper() path := filepath.Join(t.TempDir(), "state.db") diff --git a/internal/manager/manager.go b/internal/manager/manager.go index f4bd5b2..796d3ad 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -360,6 +360,55 @@ func (m *Manager) List() ([]ListResult, error) { return out, nil } +// RemoveAllWorktrees force-removes every worktree registered in this +// manager's database. It only acts on worktrees below the manager's pool +// directory, so malformed database records cannot remove arbitrary paths. +func (m *Manager) RemoveAllWorktrees() (int, error) { + repositories, err := m.db.ListAllRepositories() + if err != nil { + return 0, err + } + repositoriesByID := make(map[int64]*db.Repository, len(repositories)) + for _, repository := range repositories { + repositoriesByID[repository.ID] = repository + } + + worktrees, err := m.db.ListAllWorktrees() + if err != nil { + return 0, err + } + for _, worktree := range worktrees { + repository := repositoriesByID[worktree.RepositoryID] + if repository == nil { + return 0, fmt.Errorf("worktree %s references an unregistered repository", worktree.Path) + } + if !m.isManagerPoolPath(repository.RootPath, worktree.Path) { + return 0, fmt.Errorf("refusing to remove worktree outside the manager pool: %s", worktree.Path) + } + } + + for _, worktree := range worktrees { + repository := repositoriesByID[worktree.RepositoryID] + gitRepository := &gitops.Repo{Root: repository.RootPath} + registered, err := gitRepository.WorktreeExists(worktree.Path) + if err != nil { + return 0, fmt.Errorf("check worktree %s: %w", worktree.Path, err) + } + if !registered { + continue + } + if err := gitRepository.RemoveWorktree(worktree.Path, true); err != nil { + return 0, fmt.Errorf("remove worktree %s: %w", worktree.Path, err) + } + } + for _, repository := range repositories { + if err := (&gitops.Repo{Root: repository.RootPath}).PruneWorktrees(); err != nil { + return 0, fmt.Errorf("prune worktrees for %s: %w", repository.RootPath, err) + } + } + return len(worktrees), nil +} + // reconcileRepository adopts manager-pool worktrees known to Git but missing // from this database. They are marked allocated until an explicit release // proves that they are safe to return to the pool. diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go index 652a8cc..3ea3df7 100644 --- a/internal/manager/manager_test.go +++ b/internal/manager/manager_test.go @@ -459,6 +459,86 @@ func TestList(t *testing.T) { } } +func TestRemoveAllWorktrees(t *testing.T) { + firstRepository := setupRepo(t) + secondRepository := setupRepo(t) + d := newManagerDB(t) + m := newTestManager(t, d) + + first, err := m.Acquire(firstRepository, "first-task") + if err != nil { + t.Fatalf("Acquire first worktree: %v", err) + } + second, err := m.Acquire(secondRepository, "second-task") + if err != nil { + t.Fatalf("Acquire second worktree: %v", err) + } + + removed, err := m.RemoveAllWorktrees() + if err != nil { + t.Fatalf("RemoveAllWorktrees: %v", err) + } + if removed != 2 { + t.Fatalf("removed %d worktrees, want 2", removed) + } + for _, path := range []string{first.WorktreePath, second.WorktreePath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected removed worktree path %s, got %v", path, err) + } + } +} + +func TestRemoveAllWorktreesRejectsPathOutsidePool(t *testing.T) { + repository := setupRepo(t) + d := newManagerDB(t) + m := newTestManager(t, d) + + tx, err := d.BeginTx() + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + registeredRepository, err := d.GetOrCreateRepository(tx, repository, "main") + if err != nil { + t.Fatalf("GetOrCreateRepository: %v", err) + } + unsafePath := t.TempDir() + if _, err := d.InsertWorktree(tx, registeredRepository.ID, unsafePath, "unsafe", db.StatusAllocated); err != nil { + t.Fatalf("InsertWorktree: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + if _, err := m.RemoveAllWorktrees(); err == nil || !strings.Contains(err.Error(), "outside the manager pool") { + t.Fatalf("expected pool-path validation error, got %v", err) + } + if _, err := os.Stat(unsafePath); err != nil { + t.Fatalf("expected unsafe path to remain, got %v", err) + } +} + +func TestRemoveAllWorktreesPrunesMissingWorktree(t *testing.T) { + repository := setupRepo(t) + d := newManagerDB(t) + m := newTestManager(t, d) + + worktree, err := m.Acquire(repository, "missing-task") + if err != nil { + t.Fatalf("Acquire: %v", err) + } + if err := os.RemoveAll(worktree.WorktreePath); err != nil { + t.Fatalf("RemoveAll test worktree: %v", err) + } + + removed, err := m.RemoveAllWorktrees() + if err != nil { + t.Fatalf("RemoveAllWorktrees: %v", err) + } + if removed != 1 { + t.Fatalf("removed %d worktrees, want 1", removed) + } +} + func TestVerifyClean(t *testing.T) { repo := setupRepo(t) d := newManagerDB(t)