You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dotctl/cmd/link.go

156 lines
4.4 KiB
Go

package cmd
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// LinkResult holds counts from a link operation
type LinkResult struct {
Linked int
Skipped int
Backed int
}
func init() {
RootCmd.AddCommand(linkCommand)
}
var linkCommand = &cobra.Command{
Use: "link",
Run: runLinkCommand,
Short: "generate symlinks according to config",
Long: "runs through all configs in the dotctl config file and links them to configured symlinks",
}
func runLinkCommand(cmd *cobra.Command, args []string) {
fmt.Fprintln(cmd.OutOrStdout(), "Symlinking dotfiles...")
dotfileRoot := viper.GetString("dotfile-path")
links := viper.GetStringMapString("links")
result := LinkDotfiles(cmd.OutOrStdout(), dotfileRoot, links, Overwrite, NoBackup, DryRun)
fmt.Fprintf(cmd.OutOrStdout(), "\nSummary: %d linked, %d skipped, %d backed up\n",
result.Linked, result.Skipped, result.Backed)
}
// LinkDotfiles performs idempotent symlinking of dotfiles.
// dotfileRoot: path to the dotfiles repo (e.g. ~/dotfiles)
// links: map of config name -> target path (from config.yml)
// overwrite: if true, replaces existing wrong symlinks
// noBackup: if true, skips conflicts instead of backing up real files
// dryRun: if true, prints actions without making filesystem changes
func LinkDotfiles(out io.Writer, dotfileRoot string, links map[string]string, overwrite, noBackup, dryRun bool) LinkResult {
result := LinkResult{}
for configName, targetPath := range links {
if configName == ".git" || configName == "dotctl" {
continue
}
dotPath := filepath.Join(dotfileRoot, configName)
if targetPath == "" {
fmt.Fprintf(out, " Warning: no target path configured for %s, skipping\n", configName)
result.Skipped++
continue
}
// Ensure parent directory exists
parentDir := filepath.Dir(targetPath)
if !dryRun {
if err := os.MkdirAll(parentDir, 0755); err != nil {
log.Printf("Warning: could not create parent dir %s: %v\n", parentDir, err)
}
}
// Check what's at the target path using Lstat (doesn't follow symlinks)
lstat, err := os.Lstat(targetPath)
if os.IsNotExist(err) {
// Target missing - create symlink
if dryRun {
fmt.Fprintf(out, " %s → %s [would link]\n", targetPath, dotPath)
} else {
if err := os.Symlink(dotPath, targetPath); err != nil {
log.Printf("Error: cannot symlink %s → %s: %v\n", targetPath, dotPath, err)
result.Skipped++
continue
}
fmt.Fprintf(out, " %s → %s [linked]\n", targetPath, dotPath)
}
result.Linked++
continue
}
if err != nil {
log.Printf("Warning: cannot stat %s: %v\n", targetPath, err)
result.Skipped++
continue
}
// Check if it's a symlink
if lstat.Mode()&os.ModeSymlink != 0 {
existing, err := os.Readlink(targetPath)
if err == nil && existing == dotPath {
// Already pointing to the right place
fmt.Fprintf(out, " %s → %s [already linked]\n", targetPath, dotPath)
result.Skipped++
continue
}
// Symlink to wrong place
if overwrite {
if dryRun {
fmt.Fprintf(out, " %s [would remove wrong symlink and relink → %s]\n", targetPath, dotPath)
} else {
os.Remove(targetPath)
if err := os.Symlink(dotPath, targetPath); err != nil {
log.Printf("Error: cannot relink %s: %v\n", targetPath, err)
result.Skipped++
continue
}
fmt.Fprintf(out, " %s → %s [relinked]\n", targetPath, dotPath)
}
result.Linked++
} else {
fmt.Fprintf(out, " %s [symlink to wrong path, skipping (use --overwrite to force)]\n", targetPath)
result.Skipped++
}
continue
}
// Real file or directory exists at target
if noBackup {
fmt.Fprintf(out, " %s [real file exists, skipping (use --overwrite or remove --no-backup)]\n", targetPath)
result.Skipped++
continue
}
backupPath := targetPath + ".dotctl.bak"
if dryRun {
fmt.Fprintf(out, " %s [would back up to %s and link → %s]\n", targetPath, backupPath, dotPath)
} else {
if err := os.Rename(targetPath, backupPath); err != nil {
log.Printf("Error: cannot back up %s: %v\n", targetPath, err)
result.Skipped++
continue
}
if err := os.Symlink(dotPath, targetPath); err != nil {
log.Printf("Error: cannot symlink after backup %s: %v\n", targetPath, err)
result.Skipped++
continue
}
fmt.Fprintf(out, " %s → %s [backed up to .dotctl.bak, linked]\n", targetPath, dotPath)
}
result.Backed++
}
return result
}