606 lines
12 KiB
Go
606 lines
12 KiB
Go
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
|
|
}
|