Skip to content
Open
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
50 changes: 17 additions & 33 deletions cmd/entire/cli/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,16 @@ func newCleanCmd() *cobra.Command {

cmd := &cobra.Command{
Use: "clean",
Short: "Clean up session data and orphaned Entire data",
Short: "Clean up Entire session data",
Long: `Clean up Entire session data for the current HEAD commit.

By default, cleans session state and shadow branches for the current HEAD:
- Session state files (.git/entire-sessions/<session-id>.json)
- Shadow branch (entire/<commit-hash>-<worktree-hash>)

Use --all to clean all orphaned Entire data across the repository:
- Orphaned shadow branches
- Orphaned session state files
- Orphaned checkpoint entries on entire/checkpoints/v1
Use --all to clean all Entire session data across the repository:
- All session state files (.git/entire-sessions/)
- All shadow branches
- Temporary files (.entire/tmp/)
The entire/checkpoints/v1 branch itself is preserved.

Expand Down Expand Up @@ -79,7 +78,7 @@ Use --dry-run to preview what would be deleted without prompting.`,
}

cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Skip confirmation prompt and override active session guard")
cmd.Flags().BoolVarP(&allFlag, "all", "a", false, "Clean all orphaned data across the repository")
cmd.Flags().BoolVarP(&allFlag, "all", "a", false, "Clean all session data across the repository")
cmd.Flags().BoolVarP(&dryRunFlag, "dry-run", "d", false, "Preview what would be deleted without deleting")
cmd.Flags().StringVar(&sessionFlag, "session", "", "Clean a specific session by ID")

Expand Down Expand Up @@ -257,16 +256,16 @@ func runCleanSession(ctx context.Context, cmd *cobra.Command, strat *strategy.Ma
return nil
}

// runCleanAll cleans all orphaned data across the repository (old `entire clean` behavior).
// runCleanAll cleans all session data across the repository.
func runCleanAll(ctx context.Context, cmd *cobra.Command, force, dryRun bool) error {
// List all cleanup items
items, err := strategy.ListAllCleanupItems(ctx)
// List all items (sessions, shadow branches) — not just orphaned ones
items, err := strategy.ListAllItems(ctx)
if err != nil {
return fmt.Errorf("failed to list orphaned items: %w", err)
return fmt.Errorf("failed to list items: %w", err)
}

// List temp files
tempFiles, err := listTempFiles(ctx)
// List temp files — skip active-session filter since --all deletes those sessions
tempFiles, err := listAllTempFiles(ctx)
if err != nil {
// Non-fatal: continue with other cleanup items
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to list temp files: %v\n", err)
Expand All @@ -275,14 +274,14 @@ func runCleanAll(ctx context.Context, cmd *cobra.Command, force, dryRun bool) er
return runCleanAllWithItems(ctx, cmd, force, dryRun, items, tempFiles)
}

// runCleanAllWithItems is the core logic for cleaning all orphaned items.
// runCleanAllWithItems is the core logic for cleaning all items.
// Separated for testability — tests pass a cmd without a TTY and use force or dryRun to avoid prompts.
func runCleanAllWithItems(ctx context.Context, cmd *cobra.Command, force, dryRun bool, items []strategy.CleanupItem, tempFiles []string) error {
w := cmd.OutOrStdout()
errW := cmd.ErrOrStderr()
// Handle no items case
if len(items) == 0 && len(tempFiles) == 0 {
fmt.Fprintln(w, "No orphaned items to clean up.")
fmt.Fprintln(w, "No items to clean up.")
return nil
}

Expand Down Expand Up @@ -443,10 +442,9 @@ func runCleanAllWithItems(ctx context.Context, cmd *cobra.Command, force, dryRun
return nil
}

// listTempFiles returns files in .entire/tmp/ that are safe to delete,
// excluding files belonging to active sessions.
// Uses os.DirFS + fs.WalkDir to confine listing to the temp directory.
func listTempFiles(ctx context.Context) ([]string, error) {
// listAllTempFiles returns all files in .entire/tmp/ without filtering.
// Used by --all since those sessions are being deleted anyway.
func listAllTempFiles(ctx context.Context) ([]string, error) {
absDir, err := paths.AbsPath(ctx, paths.EntireTmpDir)
if err != nil {
return nil, fmt.Errorf("failed to resolve temp dir: %w", err)
Expand All @@ -460,14 +458,6 @@ func listTempFiles(ctx context.Context) ([]string, error) {
}
defer root.Close()

// Build set of active session IDs to protect their temp files
activeSessionIDs := make(map[string]bool)
if states, listErr := strategy.ListSessionStates(ctx); listErr == nil {
for _, state := range states {
activeSessionIDs[state.SessionID] = true
}
}

var files []string
err = fs.WalkDir(root.FS(), ".", func(_ string, d fs.DirEntry, err error) error {
if err != nil {
Expand All @@ -476,13 +466,7 @@ func listTempFiles(ctx context.Context) ([]string, error) {
if d.IsDir() {
return nil
}
// Skip temp files belonging to active sessions (e.g., "session-id.json")
name := d.Name()
sessionID := strings.TrimSuffix(name, ".json")
if sessionID != name && activeSessionIDs[sessionID] {
return nil
}
files = append(files, name)
files = append(files, d.Name())
return nil
})
if os.IsNotExist(err) {
Expand Down
63 changes: 59 additions & 4 deletions cmd/entire/cli/clean_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,8 @@ func TestCleanCmd_All_NoOrphanedItems(t *testing.T) {
}

output := stdout.String()
if !strings.Contains(output, "No orphaned items") {
t.Errorf("Expected 'No orphaned items' message, got: %s", output)
if !strings.Contains(output, "No items to clean up") {
t.Errorf("Expected 'No items to clean up' message, got: %s", output)
}
}

Expand Down Expand Up @@ -583,6 +583,61 @@ func TestCleanCmd_All_Subdirectory(t *testing.T) {
}
}

// Regression test: --all should find sessions that have a shadow branch.
// Previously, --all only cleaned orphaned sessions (no shadow branch AND no checkpoints),
// so active sessions with a shadow branch were invisible to --all.
func TestCleanCmd_All_FindsSessionWithShadowBranch(t *testing.T) {
repo, commitHash := setupCleanTestRepo(t)

wt, err := repo.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}
worktreePath := wt.Filesystem.Root()
worktreeID, err := paths.GetWorktreeID(worktreePath)
if err != nil {
t.Fatalf("failed to get worktree ID: %v", err)
}

// Create shadow branch for the session's base commit
shadowBranch := checkpoint.ShadowBranchNameForCommit(commitHash.String(), worktreeID)
shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranch), commitHash)
if err := repo.Storer.SetReference(shadowRef); err != nil {
t.Fatalf("failed to create shadow branch: %v", err)
}

// Create session state file — this session HAS a shadow branch,
// so it was NOT considered orphaned by the old --all behavior
sessionFile := createSessionStateFile(t, worktreePath, "2026-02-02-active-session", commitHash)

cmd := newCleanCmd()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--all", "--force"})

err = cmd.Execute()
if err != nil {
t.Fatalf("clean --all --force error = %v", err)
}

output := stdout.String()

// Session should be cleaned
if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
t.Error("session state file should be deleted by --all")
}

// Shadow branch should be cleaned
refName := plumbing.NewBranchReferenceName(shadowBranch)
if _, err := repo.Reference(refName, true); err == nil {
t.Error("shadow branch should be deleted by --all")
}

if !strings.Contains(output, "Deleted") {
t.Errorf("Expected 'Deleted' in output, got: %s", output)
}
}

// --- runCleanAllWithItems unit tests ---

func TestRunCleanAllWithItems_PartialFailure(t *testing.T) {
Expand Down Expand Up @@ -663,8 +718,8 @@ func TestRunCleanAllWithItems_NoItems(t *testing.T) {
}

output := stdout.String()
if !strings.Contains(output, "No orphaned items") {
t.Errorf("Expected 'No orphaned items' message, got: %s", output)
if !strings.Contains(output, "No items to clean up") {
t.Errorf("Expected 'No items to clean up' message, got: %s", output)
}
}

Expand Down
47 changes: 32 additions & 15 deletions cmd/entire/cli/strategy/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,29 +335,46 @@ func DeleteOrphanedCheckpoints(ctx context.Context, checkpointIDs []string) (del
return checkpointIDs, []string{}, nil
}

// ListAllCleanupItems returns all orphaned items across all categories.
// It iterates over all registered strategies and calls ListOrphanedItems on those
// that implement OrphanedItemsLister.
// Returns an error if the repository cannot be opened.
func ListAllCleanupItems(ctx context.Context) ([]CleanupItem, error) {
// ListAllItems returns all Entire items for full cleanup.
// This includes all shadow branches and all session states regardless of
// whether they have checkpoints or active shadow branches.
func ListAllItems(ctx context.Context) ([]CleanupItem, error) {
var items []CleanupItem
var firstErr error

strat := NewManualCommitStrategy()
stratItems, err := strat.ListOrphanedItems(ctx)
// All shadow branches (using ListShadowBranches directly, not
// ListOrphanedItems, so this won't break if orphan filtering is added)
branches, err := ListShadowBranches(ctx)
if err != nil {
return nil, fmt.Errorf("listing orphaned items: %w", err)
return nil, fmt.Errorf("listing shadow branches: %w", err)
}
items = append(items, stratItems...)
// Orphaned session states (strategy-agnostic)
states, err := ListOrphanedSessionStates(ctx)
for _, branch := range branches {
items = append(items, CleanupItem{
Type: CleanupTypeShadowBranch,
ID: branch,
Reason: "clean all",
})
}

// All session states (not just orphaned)
store, err := session.NewStateStore(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create state store: %w", err)
}

states, err := store.List(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to list session states: %w", err)
}

items = append(items, states...)
for _, state := range states {
items = append(items, CleanupItem{
Type: CleanupTypeSessionState,
ID: state.SessionID,
Reason: "clean all",
})
}

return items, firstErr
return items, nil
}

// DeleteAllCleanupItems deletes all specified cleanup items.
Expand Down
Loading