esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

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 }