package core import ( "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" ) func ApplyPackage(filesDir, targetRoot string, cfg *packageConfig) error { return ApplyPackages([]string{filesDir}, targetRoot, cfg) } func ApplyPackages(filesDirs []string, targetRoot string, cfg *packageConfig) error { if err := EnsureDir(targetRoot); err != nil { return err } for _, filesDir := range filesDirs { if _, err := os.Stat(filesDir); err != nil { if errors.Is(err, os.ErrNotExist) { continue } return err } err := filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if path == filesDir { return nil } rel, err := filepath.Rel(filesDir, path) if err != nil { return err } if ShouldIgnorePath(rel, cfg) { if entry.IsDir() { return filepath.SkipDir } return nil } targetPath := filepath.Join(targetRoot, rel) if entry.IsDir() { return EnsureDir(targetPath) } srcAbs, err := filepath.Abs(path) if err != nil { return err } // For overlay mode: remove existing symlink if it points to a different source if info, err := os.Lstat(targetPath); err == nil && info.Mode()&os.ModeSymlink != 0 { current, err := os.Readlink(targetPath) if err != nil { return err } if current != srcAbs { // Remove the old symlink to allow overlay if err := os.Remove(targetPath); err != nil { return err } } } return LinkFile(srcAbs, targetPath) }) if err != nil { return err } } return nil } func LinkFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } if info, err := os.Lstat(dst); err == nil { if info.Mode()&os.ModeSymlink != 0 { current, err := os.Readlink(dst) if err != nil { return err } if current == src { return nil } return fmt.Errorf("conflict at %s (symlink points to %s, expected %s). Run 'sigil unlink %s' to restore and remove the conflicting link", dst, current, src, dst) } return fmt.Errorf("conflict at %s (file exists and is not a symlink). Back up or remove this file first", dst) } else if !errors.Is(err, os.ErrNotExist) { return err } return os.Symlink(src, dst) } func EnsureDir(path string) error { info, err := os.Lstat(path) if err == nil { if info.IsDir() { return nil } return fmt.Errorf("%s exists and is not a directory", path) } if !errors.Is(err, os.ErrNotExist) { return err } return os.MkdirAll(path, 0o755) } func FindStaleLinks(filesDir, targetRoot string) ([]string, error) { return FindStaleLinksMulti([]string{filesDir}, targetRoot) } func FindStaleLinksMulti(filesDirs []string, targetRoot string) ([]string, error) { targetAbs, err := filepath.Abs(targetRoot) if err != nil { return nil, err } // Collect all valid source directories (prefixes) for checking var sourcePrefixes []string for _, filesDir := range filesDirs { filesAbs, err := filepath.Abs(filesDir) if err != nil { return nil, err } if _, err := os.Stat(filesDir); err != nil { if errors.Is(err, os.ErrNotExist) { continue } return nil, err } sourcePrefixes = append(sourcePrefixes, filesAbs) } if len(sourcePrefixes) == 0 { return nil, nil } var stale []string seen := make(map[string]bool) // First pass: walk source directories to find files that exist for _, filesDir := range filesDirs { filesAbs, err := filepath.Abs(filesDir) if err != nil { return nil, err } err = filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } rel, err := filepath.Rel(filesAbs, path) if err != nil { return err } targetPath := filepath.Join(targetAbs, rel) seen[targetPath] = true info, err := os.Lstat(targetPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } if info.Mode()&os.ModeSymlink == 0 { return nil } src, err := os.Readlink(targetPath) if err != nil { return err } if !filepath.IsAbs(src) { src = filepath.Join(filepath.Dir(targetPath), src) } src = filepath.Clean(src) // Check if symlink points to any of our source directories pointsToRepo := false for _, prefix := range sourcePrefixes { if strings.HasPrefix(src, prefix+string(os.PathSeparator)) || src == prefix { pointsToRepo = true break } } if !pointsToRepo { return nil } // Check if the source file in repo still exists if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { stale = append(stale, targetPath) } return nil }) if err != nil { return nil, err } } // Second pass: walk target directory to find orphaned symlinks // (symlinks pointing to our repo but source file no longer exists) targetEntries, err := os.ReadDir(targetAbs) if err != nil { if errors.Is(err, os.ErrNotExist) { return stale, nil } return nil, err } for _, entry := range targetEntries { if entry.IsDir() { continue } targetPath := filepath.Join(targetAbs, entry.Name()) if seen[targetPath] { continue } info, err := os.Lstat(targetPath) if err != nil { continue } if info.Mode()&os.ModeSymlink == 0 { continue } src, err := os.Readlink(targetPath) if err != nil { continue } if !filepath.IsAbs(src) { src = filepath.Join(filepath.Dir(targetPath), src) } src = filepath.Clean(src) // Check if symlink points to any of our source directories pointsToRepo := false for _, prefix := range sourcePrefixes { if strings.HasPrefix(src, prefix+string(os.PathSeparator)) || src == prefix { pointsToRepo = true break } } if !pointsToRepo { continue } // Check if source still exists if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { stale = append(stale, targetPath) } } return stale, nil } func RemoveLinks(paths []string, dryRun bool) error { for _, path := range paths { if dryRun { fmt.Printf("dry-run: remove %s\n", path) continue } if err := os.Remove(path); err != nil { return err } fmt.Printf("removed %s\n", path) } return nil } func HandleStaleLinks(stales []string) error { if len(stales) == 0 { return nil } repo, err := RepoPath() if err != nil { return err } reader := newReader() for _, path := range stales { fmt.Printf("stale: %s\n", path) canUnlink, err := StaleHasRepoFile(path, repo) if err != nil { return err } prompt := "action [p=prune, u=unlink, i=ignore]: " if !canUnlink { prompt = "action [p=prune, i=ignore]: " } fmt.Print(prompt) choice, err := reader.ReadString('\n') if err != nil { return err } choice = strings.TrimSpace(strings.ToLower(choice)) if choice == "" || choice == "i" { continue } if choice == "p" { if err := os.Remove(path); err != nil { return err } fmt.Printf("removed %s\n", path) continue } if choice == "u" && canUnlink { if err := UnlinkStale(path, repo); err != nil { return err } continue } fmt.Println("invalid choice; skipping") } return nil } func StaleHasRepoFile(targetPath, repo string) (bool, error) { RepoPath, err := RepoPathForTarget(targetPath, repo) if err != nil { return false, err } if RepoPath == "" { return false, nil } if _, err := os.Stat(RepoPath); errors.Is(err, os.ErrNotExist) { return false, nil } else if err != nil { return false, err } return true, nil } func UnlinkStale(targetPath, repo string) error { RepoPath, err := RepoPathForTarget(targetPath, repo) if err != nil { return err } if RepoPath == "" { return nil } if err := os.Remove(targetPath); err != nil { return err } if err := CopyFile(RepoPath, targetPath); err != nil { return err } if err := os.Remove(RepoPath); err != nil { return err } fmt.Printf("unlinked %s (removed %s)\n", targetPath, RepoPath) return nil } func RestorePackage(filesDir, targetRoot string, dryRun bool) error { filesAbs, err := filepath.Abs(filesDir) if err != nil { return err } return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if path == filesDir { return nil } if entry.IsDir() { return nil } rel, err := filepath.Rel(filesDir, path) if err != nil { return err } targetPath := filepath.Join(targetRoot, rel) return RestoreOne(path, targetPath, filesAbs, dryRun) }) } func RestorePath(filesDir, targetRoot, relPath string, dryRun bool) error { filesAbs, err := filepath.Abs(filesDir) if err != nil { return err } relPath = filepath.Clean(relPath) if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { return fmt.Errorf("invalid relative path %q", relPath) } sourcePath := filepath.Join(filesDir, relPath) info, err := os.Lstat(sourcePath) if err != nil { return err } if info.IsDir() { return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } rel, err := filepath.Rel(filesDir, path) if err != nil { return err } targetPath := filepath.Join(targetRoot, rel) return RestoreOne(path, targetPath, filesAbs, dryRun) }) } rel, err := filepath.Rel(filesDir, sourcePath) if err != nil { return err } return RestoreOne(sourcePath, filepath.Join(targetRoot, rel), filesAbs, dryRun) } func RestoreOne(sourcePath, targetPath, filesAbs string, dryRun bool) error { info, err := os.Lstat(targetPath) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink == 0 { return nil } src, err := os.Readlink(targetPath) if err != nil { return err } if !filepath.IsAbs(src) { src = filepath.Join(filepath.Dir(targetPath), src) } src = filepath.Clean(src) if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs { return nil } if dryRun { fmt.Printf("dry-run: restore %s\n", targetPath) return nil } if err := os.Remove(targetPath); err != nil { return err } fmt.Printf("restored %s\n", targetPath) return CopyFile(sourcePath, targetPath) } func CopyFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } srcFile, err := os.Open(src) if err != nil { return err } defer srcFile.Close() info, err := srcFile.Stat() if err != nil { return err } dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) if err != nil { return err } defer dstFile.Close() if _, err := io.Copy(dstFile, srcFile); err != nil { return err } return nil } func MoveDirContents(srcDir, destDir string) error { entries, err := os.ReadDir(srcDir) if err != nil { return err } for _, entry := range entries { srcPath := filepath.Join(srcDir, entry.Name()) destPath := filepath.Join(destDir, entry.Name()) if _, err := os.Stat(destPath); err == nil { return fmt.Errorf("destination already exists: %s", destPath) } else if !errors.Is(err, os.ErrNotExist) { return err } if err := os.Rename(srcPath, destPath); err != nil { return err } } return nil } func RemoveOrUnlink(args []string, isRemove bool) error { _ = isRemove // commands are identical, flag kept for clarity flags, pkgSpec, err := ParsePackageFlags(args) if err != nil { return err } pkgName, relPath, err := ResolvePackageSpec(pkgSpec) if err != nil { return err } repo, err := RepoPath() if err != nil { return err } pkgDir := filepath.Join(repo, pkgName) configPath := filepath.Join(pkgDir, ConfigFileName) cfg, err := LoadConfig(configPath) if err != nil { return err } targetRoot, err := SelectTarget(cfg) if err != nil { return err } filesDir := filepath.Join(pkgDir, FilesDirName) if _, err := os.Stat(filesDir); err != nil { return err } if relPath == "" { if err := RestorePackage(filesDir, targetRoot, flags.dryRun); err != nil { return err } if flags.dryRun { return nil } if err := os.RemoveAll(pkgDir); err != nil { return err } fmt.Printf("removed package %s\n", pkgName) return nil } if err := RestorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil { return err } if flags.dryRun { return nil } if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil { return err } fmt.Printf("removed %s:%s\n", pkgName, relPath) return nil }