check.go (7394B)
1 package cmd 2 3 import ( 4 "fmt" 5 "os" 6 "path/filepath" 7 "strings" 8 9 "github.com/charmbracelet/lipgloss" 10 "github.com/spf13/cobra" 11 12 "github.com/louloulibs/esync/internal/config" 13 ) 14 15 // --------------------------------------------------------------------------- 16 // Styles 17 // --------------------------------------------------------------------------- 18 19 var ( 20 greenHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")) 21 yellowHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) 22 redHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) 23 dimText = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 24 ) 25 26 // --------------------------------------------------------------------------- 27 // Command 28 // --------------------------------------------------------------------------- 29 30 var checkCmd = &cobra.Command{ 31 Use: "check", 32 Short: "Validate config and preview included/excluded files", 33 Long: "Load the esync configuration, walk the local directory, and show which files would be included or excluded by the ignore patterns.", 34 RunE: runCheck, 35 } 36 37 func init() { 38 rootCmd.AddCommand(checkCmd) 39 } 40 41 // --------------------------------------------------------------------------- 42 // Run 43 // --------------------------------------------------------------------------- 44 45 func runCheck(cmd *cobra.Command, args []string) error { 46 cfg, err := loadConfig() 47 if err != nil { 48 return err 49 } 50 return printPreview(cfg) 51 } 52 53 // --------------------------------------------------------------------------- 54 // Shared: loadConfig 55 // --------------------------------------------------------------------------- 56 57 // loadConfig loads configuration from the -c flag or auto-detects it. 58 func loadConfig() (*config.Config, error) { 59 path := cfgFile 60 if path == "" { 61 path = config.FindConfigFile() 62 } 63 if path == "" { 64 return nil, fmt.Errorf("no config file found; use -c to specify one, or run `esync init`") 65 } 66 cfg, err := config.Load(path) 67 if err != nil { 68 return nil, fmt.Errorf("loading config %s: %w", path, err) 69 } 70 return cfg, nil 71 } 72 73 // --------------------------------------------------------------------------- 74 // Shared: printPreview 75 // --------------------------------------------------------------------------- 76 77 // fileEntry records a file path and (for excluded files) the rule that matched. 78 type fileEntry struct { 79 path string 80 rule string 81 } 82 83 // printPreview walks the local directory and displays included/excluded files. 84 func printPreview(cfg *config.Config) error { 85 localDir := cfg.Sync.Local 86 patterns := cfg.AllIgnorePatterns() 87 includes := cfg.Settings.Include 88 89 var included []fileEntry 90 var excluded []fileEntry 91 var brokenLinks []fileEntry 92 var includedSize int64 93 94 err := filepath.Walk(localDir, func(path string, info os.FileInfo, err error) error { 95 if err != nil { 96 return nil // skip unreadable entries 97 } 98 99 rel, err := filepath.Rel(localDir, path) 100 if err != nil { 101 return nil 102 } 103 104 // Skip the root directory itself 105 if rel == "." { 106 return nil 107 } 108 109 // Detect broken symlinks (Walk uses Lstat, so symlinks show up with err==nil) 110 if info.Mode()&os.ModeSymlink != 0 { 111 if _, statErr := os.Stat(path); statErr != nil { 112 target, _ := os.Readlink(path) 113 brokenLinks = append(brokenLinks, fileEntry{path: rel, rule: target}) 114 } 115 return nil // skip all symlinks from included/excluded lists 116 } 117 118 // Check against ignore patterns 119 for _, pattern := range patterns { 120 if matchesIgnorePattern(rel, info, pattern) { 121 excluded = append(excluded, fileEntry{path: rel, rule: pattern}) 122 if info.IsDir() { 123 return filepath.SkipDir 124 } 125 return nil 126 } 127 } 128 129 // Check against include patterns (if any) 130 if len(includes) > 0 && !matchesInclude(rel, includes) { 131 if info.IsDir() { 132 return filepath.SkipDir 133 } 134 return nil 135 } 136 137 if !info.IsDir() { 138 included = append(included, fileEntry{path: rel}) 139 includedSize += info.Size() 140 } 141 return nil 142 }) 143 if err != nil { 144 return fmt.Errorf("walking %s: %w", localDir, err) 145 } 146 147 // --- Config summary --- 148 fmt.Println() 149 fmt.Printf(" Local: %s\n", cfg.Sync.Local) 150 fmt.Printf(" Remote: %s\n", cfg.Sync.Remote) 151 fmt.Println() 152 153 // --- Included files --- 154 fmt.Println(greenHeader.Render(" Included files:")) 155 limit := 10 156 for i, f := range included { 157 if i >= limit { 158 fmt.Printf(" ... %d more files\n", len(included)-limit) 159 break 160 } 161 fmt.Printf(" %s\n", f.path) 162 } 163 if len(included) == 0 { 164 fmt.Println(" (none)") 165 } 166 fmt.Println() 167 168 // --- Excluded files --- 169 fmt.Println(yellowHeader.Render(" Excluded files:")) 170 for i, f := range excluded { 171 if i >= limit { 172 fmt.Printf(" ... %d more excluded\n", len(excluded)-limit) 173 break 174 } 175 fmt.Printf(" %-40s %s\n", f.path, dimText.Render("← "+f.rule)) 176 } 177 if len(excluded) == 0 { 178 fmt.Println(" (none)") 179 } 180 fmt.Println() 181 182 // --- Broken symlinks --- 183 if len(brokenLinks) > 0 { 184 fmt.Println(redHeader.Render(" Broken symlinks:")) 185 for _, f := range brokenLinks { 186 fmt.Printf(" %-40s %s\n", f.path, dimText.Render("-> "+f.rule)) 187 } 188 fmt.Println() 189 } 190 191 // --- Totals --- 192 totals := fmt.Sprintf(" %d files included (%s) | %d excluded", 193 len(included), formatSize(includedSize), len(excluded)) 194 fmt.Println(dimText.Render(totals)) 195 fmt.Println() 196 197 return nil 198 } 199 200 // --------------------------------------------------------------------------- 201 // Pattern matching 202 // --------------------------------------------------------------------------- 203 204 // matchesInclude checks whether a relative path falls under any include prefix. 205 // A path is included if it equals a prefix, is inside a prefix, or is an 206 // ancestor directory needed to reach a prefix. 207 func matchesInclude(rel string, includes []string) bool { 208 for _, inc := range includes { 209 inc = filepath.Clean(inc) 210 // Exact match (file or dir) 211 if rel == inc { 212 return true 213 } 214 // Path is inside the included prefix 215 if strings.HasPrefix(rel, inc+string(filepath.Separator)) { 216 return true 217 } 218 // Path is an ancestor of the included prefix 219 if strings.HasPrefix(inc, rel+string(filepath.Separator)) { 220 return true 221 } 222 } 223 return false 224 } 225 226 // matchesIgnorePattern checks whether a file (given its relative path and 227 // file info) matches a single ignore pattern. It handles bracket/quote 228 // stripping, ** prefixes, and directory-specific patterns. 229 func matchesIgnorePattern(rel string, info os.FileInfo, pattern string) bool { 230 // Strip surrounding quotes and brackets 231 pattern = strings.Trim(pattern, `"'`) 232 pattern = strings.Trim(pattern, "[]") 233 pattern = strings.TrimSpace(pattern) 234 235 if pattern == "" { 236 return false 237 } 238 239 // Check if this is a directory-only pattern (ends with /) 240 dirOnly := strings.HasSuffix(pattern, "/") 241 cleanPattern := strings.TrimSuffix(pattern, "/") 242 243 // Strip **/ prefix for simpler matching 244 cleanPattern = strings.TrimPrefix(cleanPattern, "**/") 245 246 if dirOnly && !info.IsDir() { 247 return false 248 } 249 250 baseName := filepath.Base(rel) 251 252 // Match against base name 253 if matched, _ := filepath.Match(cleanPattern, baseName); matched { 254 return true 255 } 256 257 // Match against full relative path 258 if matched, _ := filepath.Match(cleanPattern, rel); matched { 259 return true 260 } 261 262 // For directory patterns, also try matching directory components 263 if info.IsDir() { 264 if matched, _ := filepath.Match(cleanPattern, baseName); matched { 265 return true 266 } 267 } 268 269 return false 270 }