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
64 changes: 64 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package checkpoint

import (
"os"
"path/filepath"
"testing"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)

func TestCheckpointType_Values(t *testing.T) {
Expand All @@ -16,3 +21,62 @@ func TestCheckpointType_Values(t *testing.T) {
t.Errorf("expected zero value of Type to be Temporary, got %d", defaultType)
}
}

func TestCopyMetadataDir_SkipsSymlinks(t *testing.T) {
// Create a temp directory for the test
tempDir := t.TempDir()

// Initialize a git repository
repo, err := git.PlainInit(tempDir, false)
if err != nil {
t.Fatalf("failed to init git repo: %v", err)
}

// Create metadata directory structure
metadataDir := filepath.Join(tempDir, "metadata")
if err := os.MkdirAll(metadataDir, 0755); err != nil {
t.Fatalf("failed to create metadata dir: %v", err)
}

// Create a regular file that should be included
regularFile := filepath.Join(metadataDir, "regular.txt")
if err := os.WriteFile(regularFile, []byte("regular content"), 0644); err != nil {
t.Fatalf("failed to create regular file: %v", err)
}

// Create a sensitive file outside the metadata directory
sensitiveFile := filepath.Join(tempDir, "sensitive.txt")
if err := os.WriteFile(sensitiveFile, []byte("SECRET DATA"), 0644); err != nil {
t.Fatalf("failed to create sensitive file: %v", err)
}

// Create a symlink inside metadata directory pointing to the sensitive file
symlinkPath := filepath.Join(metadataDir, "sneaky-link")
if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}

// Create GitStore and call copyMetadataDir
store := NewGitStore(repo)
entries := make(map[string]object.TreeEntry)

err = store.copyMetadataDir(metadataDir, "checkpoint/", entries)
if err != nil {
t.Fatalf("copyMetadataDir failed: %v", err)
}

// Verify regular file was included
if _, ok := entries["checkpoint/regular.txt"]; !ok {
t.Error("regular.txt should be included in entries")
}

// Verify symlink was NOT included (security fix)
if _, ok := entries["checkpoint/sneaky-link"]; ok {
t.Error("symlink should NOT be included in entries - this would allow reading files outside the metadata directory")
}

// Verify the correct number of entries
if len(entries) != 1 {
t.Errorf("expected 1 entry, got %d", len(entries))
}
}
7 changes: 7 additions & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,13 @@ func (s *GitStore) copyMetadataDir(metadataDir, basePath string, entries map[str
return nil
}

// Skip symlinks to prevent reading files outside the metadata directory.
// A symlink could point to sensitive files (e.g., /etc/passwd) which would
// then be captured in the checkpoint and stored in git history.
if info.Mode()&os.ModeSymlink != 0 {
return nil
}

// Get relative path within metadata dir
relPath, err := filepath.Rel(metadataDir, path)
if err != nil {
Expand Down
10 changes: 7 additions & 3 deletions cmd/entire/cli/checkpoint/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const (
// ShadowBranchPrefix is the prefix for shadow branches.
ShadowBranchPrefix = "entire/"

// ShadowBranchHashLength is the number of hex characters used in shadow branch names.
// Shadow branches are named "entire/<hash>" using the first 7 characters of the commit hash.
ShadowBranchHashLength = 7

// gitDir and entireDir are excluded from tree operations.
gitDir = ".git"
entireDir = ".entire"
Expand Down Expand Up @@ -441,10 +445,10 @@ func (s *GitStore) DeleteShadowBranch(baseCommit string) error {
}

// ShadowBranchNameForCommit returns the shadow branch name for a base commit hash.
// Uses the first 7 characters of the commit hash.
// Uses the first ShadowBranchHashLength characters of the commit hash.
func ShadowBranchNameForCommit(baseCommit string) string {
if len(baseCommit) >= 7 {
return ShadowBranchPrefix + baseCommit[:7]
if len(baseCommit) >= ShadowBranchHashLength {
return ShadowBranchPrefix + baseCommit[:ShadowBranchHashLength]
}
return ShadowBranchPrefix + baseCommit
}
Expand Down
90 changes: 0 additions & 90 deletions cmd/entire/cli/clean.go

This file was deleted.

176 changes: 176 additions & 0 deletions cmd/entire/cli/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package cli

import (
"fmt"
"io"

"entire.io/cli/cmd/entire/cli/strategy"
"github.com/spf13/cobra"
)

func newCleanCmd() *cobra.Command {
var forceFlag bool

cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up orphaned session data",
Long: `Remove orphaned session data that wasn't cleaned up automatically.

This command finds and removes orphaned data from any strategy:

Shadow branches (entire/<commit-hash>)
Created by manual-commit strategy. Normally auto-cleaned when sessions
are condensed during commits.

Session state files (.git/entire-sessions/)
Track active sessions. Orphaned when no checkpoints or shadow branches
reference them.

Checkpoint metadata (entire/sessions branch)
For auto-commit checkpoints: orphaned when commits are rebased/squashed
and no commit references the checkpoint ID anymore.
Manual-commit checkpoints are permanent (condensed history) and are
never considered orphaned.

Without --force, shows a preview of items that would be deleted.
With --force, actually deletes the orphaned items.

The entire/sessions branch itself is never deleted.`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runClean(cmd.OutOrStdout(), forceFlag)
},
}

cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Actually delete items (otherwise just preview)")

return cmd
}

func runClean(w io.Writer, force bool) error {
// List all cleanup items
items, err := strategy.ListAllCleanupItems()
if err != nil {
return fmt.Errorf("failed to list orphaned items: %w", err)
}

return runCleanWithItems(w, force, items)
}

// runCleanWithItems is the core logic for cleaning orphaned items.
// Separated for testability.
func runCleanWithItems(w io.Writer, force bool, items []strategy.CleanupItem) error {
// Handle no items case
if len(items) == 0 {
fmt.Fprintln(w, "No orphaned items to clean up.")
return nil
}

// Group items by type for display
var branches, states, checkpoints []strategy.CleanupItem
for _, item := range items {
switch item.Type {
case strategy.CleanupTypeShadowBranch:
branches = append(branches, item)
case strategy.CleanupTypeSessionState:
states = append(states, item)
case strategy.CleanupTypeCheckpoint:
checkpoints = append(checkpoints, item)
}
}

// Preview mode (default)
if !force {
fmt.Fprintf(w, "Found %d orphaned items:\n\n", len(items))

if len(branches) > 0 {
fmt.Fprintf(w, "Shadow branches (%d):\n", len(branches))
for _, item := range branches {
fmt.Fprintf(w, " %s\n", item.ID)
}
fmt.Fprintln(w)
}

if len(states) > 0 {
fmt.Fprintf(w, "Session states (%d):\n", len(states))
for _, item := range states {
fmt.Fprintf(w, " %s\n", item.ID)
}
fmt.Fprintln(w)
}

if len(checkpoints) > 0 {
fmt.Fprintf(w, "Checkpoint metadata (%d):\n", len(checkpoints))
for _, item := range checkpoints {
fmt.Fprintf(w, " %s\n", item.ID)
}
fmt.Fprintln(w)
}

fmt.Fprintln(w, "Run with --force to delete these items.")
return nil
}

// Force mode - delete items
result, err := strategy.DeleteAllCleanupItems(items)
if err != nil {
return fmt.Errorf("failed to delete orphaned items: %w", err)
}

// Report results
totalDeleted := len(result.ShadowBranches) + len(result.SessionStates) + len(result.Checkpoints)
totalFailed := len(result.FailedBranches) + len(result.FailedStates) + len(result.FailedCheckpoints)

if totalDeleted > 0 {
fmt.Fprintf(w, "Deleted %d items:\n", totalDeleted)

if len(result.ShadowBranches) > 0 {
fmt.Fprintf(w, "\n Shadow branches (%d):\n", len(result.ShadowBranches))
for _, branch := range result.ShadowBranches {
fmt.Fprintf(w, " %s\n", branch)
}
}

if len(result.SessionStates) > 0 {
fmt.Fprintf(w, "\n Session states (%d):\n", len(result.SessionStates))
for _, state := range result.SessionStates {
fmt.Fprintf(w, " %s\n", state)
}
}

if len(result.Checkpoints) > 0 {
fmt.Fprintf(w, "\n Checkpoints (%d):\n", len(result.Checkpoints))
for _, cp := range result.Checkpoints {
fmt.Fprintf(w, " %s\n", cp)
}
}
}

if totalFailed > 0 {
fmt.Fprintf(w, "\nFailed to delete %d items:\n", totalFailed)

if len(result.FailedBranches) > 0 {
fmt.Fprintf(w, "\n Shadow branches:\n")
for _, branch := range result.FailedBranches {
fmt.Fprintf(w, " %s\n", branch)
}
}

if len(result.FailedStates) > 0 {
fmt.Fprintf(w, "\n Session states:\n")
for _, state := range result.FailedStates {
fmt.Fprintf(w, " %s\n", state)
}
}

if len(result.FailedCheckpoints) > 0 {
fmt.Fprintf(w, "\n Checkpoints:\n")
for _, cp := range result.FailedCheckpoints {
fmt.Fprintf(w, " %s\n", cp)
}
}

return fmt.Errorf("failed to delete %d items", totalFailed)
}

return nil
}
Loading