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
15 changes: 15 additions & 0 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ credentials for each repository individually.`,
# Clone repositories even if project sync field is set to false
mani sync --ignore-sync-state

# Remove orphaned project directories
mani sync --remove-orphaned

# Display sync status
mani sync --status`,
Run: func(cmd *cobra.Command, args []string) {
Expand All @@ -45,6 +48,7 @@ credentials for each repository individually.`,
setSyncFlags.Parallel = cmd.Flags().Changed("parallel")
setSyncFlags.SyncGitignore = cmd.Flags().Changed("sync-gitignore")
setSyncFlags.SyncRemotes = cmd.Flags().Changed("sync-remotes")
setSyncFlags.RemoveOrphaned = cmd.Flags().Changed("remove-orphaned")
setSyncFlags.Forks = cmd.Flags().Changed("forks")

if setSyncFlags.Forks {
Expand Down Expand Up @@ -74,6 +78,7 @@ credentials for each repository individually.`,
cmd.Flags().BoolVar(&syncFlags.IgnoreSyncState, "ignore-sync-state", false, "sync project even if the project's sync field is set to false")
cmd.Flags().BoolVarP(&syncFlags.Parallel, "parallel", "p", false, "clone projects in parallel")
cmd.Flags().BoolVarP(&syncFlags.Status, "status", "s", false, "display status only")
cmd.Flags().BoolVar(&syncFlags.RemoveOrphaned, "remove-orphaned", false, "remove project directories that are no longer in the mani configuration")
cmd.Flags().Uint32P("forks", "f", 4, "maximum number of concurrent processes")

// Targets
Expand Down Expand Up @@ -134,13 +139,23 @@ func runSync(
config.SyncGitignore = &syncFlags.SyncGitignore
}

if setSyncFlags.RemoveOrphaned {
config.RemoveOrphaned = &syncFlags.RemoveOrphaned
}

if *config.SyncGitignore {
err := exec.UpdateGitignoreIfExists(config)
core.CheckIfError(err)
}

err = exec.CloneRepos(config, projects, syncFlags)
core.CheckIfError(err)

// Handle orphaned project removal
if *config.RemoveOrphaned {
err = exec.RemoveOrphanedProjects(config)
core.CheckIfError(err)
}
}

err = exec.PrintProjectStatus(config, projects)
Expand Down
15 changes: 11 additions & 4 deletions core/dao/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ type Config struct {
ConfigPaths []string `yaml:"-"`
Color bool `yaml:"-"`

Shell string `yaml:"shell"`
SyncRemotes *bool `yaml:"sync_remotes"`
SyncGitignore *bool `yaml:"sync_gitignore"`
ReloadTUI *bool `yaml:"reload_tui_on_change"`
Shell string `yaml:"shell"`
SyncRemotes *bool `yaml:"sync_remotes"`
SyncGitignore *bool `yaml:"sync_gitignore"`
RemoveOrphaned *bool `yaml:"remove_orphaned"`
ReloadTUI *bool `yaml:"reload_tui_on_change"`

// Intermediate
Env yaml.Node `yaml:"env"`
Expand Down Expand Up @@ -223,6 +224,12 @@ func ReadConfig(configFilepath string, userConfigPath string, colorFlag bool) (C
config.SyncRemotes = &syncRemotes
}

// Set Remove Orphaned
if config.RemoveOrphaned == nil {
removeOrphaned := false
config.RemoveOrphaned = &removeOrphaned
}

configResources, err := config.importConfigs()
if err != nil {
return config, err
Expand Down
114 changes: 114 additions & 0 deletions core/exec/clone.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package exec

import (
"bufio"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -397,3 +398,116 @@ func PrintProjectInit(projects []dao.Project) {
fmt.Println()
print.PrintTable(data.Rows, options, data.Headers, []string{}, os.Stdout)
}

// RemoveOrphanedProjects removes project directories that are no longer defined in the mani configuration
func RemoveOrphanedProjects(config *dao.Config) error {
// Find all directories in the config directory
configDir := filepath.Dir(config.Path)

// Get all project names that should exist based on current configuration
activeProjectPaths := make(map[string]bool)
for _, project := range config.ProjectList {
projectPath, err := core.GetAbsolutePath(configDir, project.Path, project.Name)
if err != nil {
continue // Skip if we can't resolve the path
}
activeProjectPaths[projectPath] = true
}
var orphanedPaths []string

entries, err := os.ReadDir(configDir)
if err != nil {
return fmt.Errorf("failed to read config directory: %w", err)
}

for _, entry := range entries {
if !entry.IsDir() {
continue
}

fullPath := filepath.Join(configDir, entry.Name())

// Skip the config directory itself (in case someone sets path: ".")
configDirAbs, err := filepath.Abs(configDir)
if err == nil {
fullPathAbs, err2 := filepath.Abs(fullPath)
if err2 == nil && fullPathAbs == configDirAbs {
continue
}
}

// Only consider directories that have a .git folder (git repositories)
// This is the key safety check - we only remove git repos, not random directories
gitDir := filepath.Join(fullPath, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
continue // Not a git repository, skip completely
}

// Check if this git repository path is in our active projects
if !activeProjectPaths[fullPath] {
orphanedPaths = append(orphanedPaths, fullPath)
}
}

if len(orphanedPaths) == 0 {
fmt.Println("No orphaned project directories found.")
return nil
}

// Display what will be removed
fmt.Printf("\n%s Found %d orphaned project director%s:\n\n",
color.FgYellow.Sprint("⚠"),
len(orphanedPaths),
func() string { if len(orphanedPaths) == 1 { return "y" }; return "ies" }())

for _, path := range orphanedPaths {
relPath, err := filepath.Rel(configDir, path)
if err != nil {
relPath = path
}
fmt.Printf(" %s %s\n", color.FgRed.Sprint("✗"), relPath)
}

fmt.Printf("\n%s These directories contain git repositories that are no longer defined in your mani.yaml configuration.\n",
color.FgYellow.Sprint("⚠"))
fmt.Printf("%s This action will permanently delete these directories and all their contents.\n",
color.FgRed.Sprint("!"))

// Ask for confirmation
fmt.Print("\nAre you sure you want to delete these directories? [y/N]: ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read user input: %w", err)
}

response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Operation cancelled.")
return nil
}

// Remove the orphaned directories
fmt.Println("\nRemoving orphaned project directories...")
for _, path := range orphanedPaths {
relPath, err := filepath.Rel(configDir, path)
if err != nil {
relPath = path
}

fmt.Printf("Removing %s... ", relPath)
err = os.RemoveAll(path)
if err != nil {
fmt.Printf("%s (error: %s)\n", color.FgRed.Sprint("failed"), err)
return fmt.Errorf("failed to remove directory %s: %w", path, err)
}
fmt.Printf("%s\n", color.FgGreen.Sprint("done"))
}

fmt.Printf("\n%s Successfully removed %d orphaned project director%s.\n",
color.FgGreen.Sprint("✓"),
len(orphanedPaths),
func() string { if len(orphanedPaths) == 1 { return "y" }; return "ies" }())

return nil
}
Loading