Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions cmd/worktree-manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ 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.
release Release a worktree back to the pool.
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 <name> Branch name (e.g. BenE/add-unit-menu).
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 <repo-root>/.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
Expand Down
14 changes: 14 additions & 0 deletions cmd/worktree-manager/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
18 changes: 18 additions & 0 deletions internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions internal/db/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
49 changes: 49 additions & 0 deletions internal/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions internal/manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading