diff --git a/.github/pages/index.html b/.github/pages/index.html index 61ceca2..55ce567 100644 --- a/.github/pages/index.html +++ b/.github/pages/index.html @@ -533,7 +533,7 @@
# Scaffold a starter config
hidedot init
-# Adopt an existing file into your dotfiles dir
-hidedot adopt ~/.zshrc
+# Adopt existing files: moves them in, symlinks them back
+# and adds them to your config
+hidedot adopt ~/.zshrc ~/.config/nvim
# Create symlinks (default command)
hidedot
@@ -594,7 +595,7 @@ 3Link Your Dotfiles
Commands
- init: Create a starter
hidedot.conf.yaml (use --force to overwrite)
- - adopt <path>: Move an existing file into the dotfiles dir and symlink it back
+ - adopt <path>...: Move existing files into the dotfiles dir, symlink them back and add them to your config
- link: Create symlinks from config (default)
- status: Show status of all symlinks (OK, MISSING, BROKEN, MISMATCH)
- unlink: Remove symlinks (use --restore to restore backups)
diff --git a/README.md b/README.md
index 2751e5c..cd381b3 100644
--- a/README.md
+++ b/README.md
@@ -36,8 +36,9 @@ brew install hidedot
# Scaffold a starter config in the current directory
hidedot init
-# Adopt an existing file: move it into the dotfiles dir and symlink it back
-hidedot adopt ~/.zshrc
+# Adopt existing files: move them into the dotfiles dir, symlink them back
+# and add the entries to your config
+hidedot adopt ~/.zshrc ~/.config/nvim
# Create symlinks (default command)
hidedot
@@ -64,9 +65,10 @@ Create `hidedot.conf.yaml`:
```yaml
- defaults:
link:
- relink: true
- force: true
- backup: true # Enable automatic backups
+ relink: true # Replace symlinks that point somewhere else
+ force: true # Replace files/dirs that are not symlinks
+ backup: true # Automatic backups — on unless set to false
+ remove_duplicates: false # Delete other symlinks pointing at the same source
# Optional: profile for filtering configs
profile: personal
@@ -145,13 +147,66 @@ Available template variables:
| Command | Description |
|---------|-------------|
| `init` | Create a starter `hidedot.conf.yaml` (use `--force` to overwrite) |
-| `adopt ` | Move an existing file/dir into the dotfiles dir and replace it with a symlink |
+| `adopt ...` | Move existing files/dirs into the dotfiles dir, replace them with symlinks and add them to the config |
| `link` | Create symlinks from config (default) |
| `status` | Show status of all symlinks (OK, MISSING, BROKEN, MISMATCH) |
| `unlink` | Remove symlinks (use `--restore` to restore backups) |
| `backup create` | Manually create backups of all linked files |
| `backup list` | List available backups |
+### `adopt`
+
+`adopt` takes files you already have and brings them under hideDot's control in one step:
+it backs the file up, moves it into your dotfiles directory, replaces the original with a
+symlink, and writes the matching `link:` entry into your config — comments and formatting
+preserved.
+
+The destination mirrors the file's own location, minus the leading dots:
+
+| Original | Lands in the repo as |
+|----------|----------------------|
+| `~/.zshrc` | `./zshrc` |
+| `~/.config/nvim` | `./config/nvim` |
+| `~/.config/git/config` | `./config/git/config` |
+
+```bash
+hidedot adopt ~/.zshrc ~/.config/nvim # several paths at once
+hidedot adopt ~/.zshrc --to zsh/zshrc # choose the destination
+hidedot adopt ~/.zshrc --no-config # print the config entry instead of writing it
+hidedot adopt ~/.zshrc --dry-run # preview the move and the resulting config
+```
+
+With `--profile`, the entry is written to the section declaring that profile. If no section
+matches, hideDot leaves the file alone and prints the entry instead.
+
+| Flag | Description |
+|------|-------------|
+| `--to` | Destination inside the dotfiles dir (single path only) |
+| `--no-config` | Print the config entry instead of writing it |
+
+## Backups
+
+Before overwriting anything that isn't already a symlink, hideDot copies it to
+`~/.hidedot-backups`. Backups are keyed by the original path, so re-running keeps one
+current copy per file rather than piling up. If a backup can't be made, hideDot refuses to
+overwrite the file.
+
+```bash
+hidedot backup create # back up every linked target
+hidedot backup list # original paths and when they were saved
+hidedot unlink --restore
+```
+
+Turn it off per run with `--no-backup`, or per config section with `backup: false`.
+
+## Exit codes
+
+`hidedot` exits `1` when any operation fails, so it can be used in scripts and CI:
+
+```bash
+hidedot || echo "something did not apply"
+```
+
## Examples
```bash
diff --git a/src/adopt.go b/src/adopt.go
index dbd5524..ea17de9 100644
--- a/src/adopt.go
+++ b/src/adopt.go
@@ -5,17 +5,36 @@
package main
import (
+ "bytes"
"fmt"
"os"
"path/filepath"
"strings"
+
+ "gopkg.in/yaml.v3"
)
-// RunAdopt moves an existing file/dir into the dotfiles directory and replaces
-// it with a symlink pointing back to the moved copy.
-func (app *App) RunAdopt(target string) error {
- targetPath := expandPath(target, app.homeDir)
- targetPath, _ = filepath.Abs(targetPath)
+// RunAdopt moves existing files or directories into the dotfiles directory,
+// replaces them with symlinks pointing back, and records them in the config.
+func (app *App) RunAdopt(targets []string, to string, writeConfig bool) error {
+ if to != "" && len(targets) > 1 {
+ return fmt.Errorf("--to takes a single path, but %d were given", len(targets))
+ }
+
+ for _, target := range targets {
+ if err := app.adoptPath(target, to, writeConfig); err != nil {
+ app.logger.error("%v", err)
+ }
+ }
+
+ return app.failureError()
+}
+
+func (app *App) adoptPath(target, to string, writeConfig bool) error {
+ targetPath, err := filepath.Abs(expandPath(target, app.homeDir))
+ if err != nil {
+ return fmt.Errorf("cannot resolve %s: %w", target, err)
+ }
info, err := os.Lstat(targetPath)
if err != nil {
@@ -26,25 +45,23 @@ func (app *App) RunAdopt(target string) error {
}
isDir := info.IsDir()
- dest := filepath.Join(app.execDir, filepath.Base(targetPath))
- if destExists, _, _ := checkPathExists(dest); destExists {
- return fmt.Errorf("destination already exists: %s", dest)
+ dest := adoptDestPath(targetPath, app.homeDir, app.execDir)
+ if to != "" {
+ dest, _ = filepath.Abs(expandSourcePath(to, app.homeDir, app.execDir))
+ }
+ if exists, _, _ := checkPathExists(dest); exists {
+ return fmt.Errorf("destination already exists: %s (use --to to choose another)", dest)
}
if !app.noBackup {
- app.createBackup(targetPath, isDir)
+ if err := app.createBackup(targetPath, isDir); err != nil {
+ return fmt.Errorf("backup failed, not adopting %s: %w", targetPath, err)
+ }
}
app.logger.info("Moving %s → %s", targetPath, dest)
if err := app.logger.execute(func() error {
- if isDir {
- if err := copyDir(targetPath, dest); err != nil {
- return err
- }
- } else if err := copyFile(targetPath, dest); err != nil {
- return err
- }
- return os.RemoveAll(targetPath)
+ return movePath(targetPath, dest, isDir)
}); err != nil {
return fmt.Errorf("error moving path: %w", err)
}
@@ -60,16 +77,217 @@ func (app *App) RunAdopt(target string) error {
app.logger.success("Adopted: %s", targetPath)
}
- // Suggest the config entry, using ~ and a repo-relative source when possible.
+ linkTarget, linkSource := app.configEntry(targetPath, dest)
+ if !writeConfig {
+ app.printConfigEntry(linkTarget, linkSource)
+ return nil
+ }
+
+ return app.addLinkToConfig(linkTarget, linkSource)
+}
+
+// adoptDestPath mirrors the adopted path's own layout inside the dotfiles
+// directory, dropping the leading dot of each component:
+//
+// ~/.zshrc -> /zshrc
+// ~/.config/nvim -> /config/nvim
+// ~/.config/git/config -> /config/git/config
+// /etc/hosts -> /etc/hosts
+//
+// Mirroring rather than flattening to the basename keeps ~/.config/nvim and
+// ~/nvim from colliding in the repo.
+func adoptDestPath(targetPath, homeDir, execDir string) string {
+ rel, err := filepath.Rel(homeDir, targetPath)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ // Outside $HOME: keep the absolute layout minus volume and leading separator.
+ rel = strings.TrimPrefix(targetPath, filepath.VolumeName(targetPath))
+ rel = strings.TrimPrefix(rel, string(os.PathSeparator))
+ }
+
+ parts := strings.Split(rel, string(os.PathSeparator))
+ for i, part := range parts {
+ if part != "." && part != ".." {
+ parts[i] = strings.TrimPrefix(part, ".")
+ }
+ }
+
+ return filepath.Join(append([]string{execDir}, parts...)...)
+}
+
+// configEntry renders the two config keys for an adopted path, preferring ~ and
+// a repo-relative source so the config stays portable across machines.
+func (app *App) configEntry(targetPath, dest string) (string, string) {
linkTarget := targetPath
if rel, err := filepath.Rel(app.homeDir, targetPath); err == nil && !strings.HasPrefix(rel, "..") {
- linkTarget = "~/" + rel
+ linkTarget = "~/" + filepath.ToSlash(rel)
}
- linkSource := "./" + filepath.Base(dest)
+
+ linkSource := "./" + filepath.ToSlash(filepath.Base(dest))
if rel, err := filepath.Rel(app.execDir, dest); err == nil && !strings.HasPrefix(rel, "..") {
- linkSource = "./" + rel
+ linkSource = "./" + filepath.ToSlash(rel)
}
+
+ return linkTarget, linkSource
+}
+
+func (app *App) printConfigEntry(linkTarget, linkSource string) {
app.logger.heading("Add this to your config:")
fmt.Printf(" link:\n %s: %s\n", linkTarget, linkSource)
+}
+
+// addLinkToConfig inserts target: source into the config's link mapping while
+// leaving comments and formatting untouched. Anything that would risk mangling
+// the user's file falls back to printing the entry for them to paste.
+func (app *App) addLinkToConfig(linkTarget, linkSource string) error {
+ // Read the file as written, not the template-expanded copy LoadConfigs
+ // works with: writing that back would bake {{ .Hostname }} and friends into
+ // the config permanently.
+ raw, err := os.ReadFile(app.configPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ app.logger.warn("No config at %s — run 'hidedot init' to create one", app.configPath)
+ app.printConfigEntry(linkTarget, linkSource)
+ return nil
+ }
+ return fmt.Errorf("error reading config: %w", err)
+ }
+
+ var doc yaml.Node
+ if err := yaml.Unmarshal(raw, &doc); err != nil {
+ // Legitimate for template-heavy configs: `key: {{ .X }}` is valid Go
+ // template but invalid YAML.
+ app.logger.warn("Config is not plain YAML (%v), leaving %s untouched", err, app.configPath)
+ app.printConfigEntry(linkTarget, linkSource)
+ return nil
+ }
+
+ section := findConfigSection(&doc, app.profile)
+ if section == nil {
+ if app.profile != "" {
+ app.logger.warn("No config section with profile '%s' in %s, leaving it untouched", app.profile, app.configPath)
+ } else {
+ app.logger.warn("Could not find a config section to update in %s, leaving it untouched", app.configPath)
+ }
+ app.printConfigEntry(linkTarget, linkSource)
+ return nil
+ }
+
+ if err := setLinkEntry(section, linkTarget, linkSource); err != nil {
+ return err
+ }
+
+ var buf bytes.Buffer
+ encoder := yaml.NewEncoder(&buf)
+ encoder.SetIndent(2) // the default of 4 would reindent the whole file
+ if err := encoder.Encode(&doc); err != nil {
+ return fmt.Errorf("error encoding config: %w", err)
+ }
+ if err := encoder.Close(); err != nil {
+ return fmt.Errorf("error encoding config: %w", err)
+ }
+
+ app.logger.info("Adding to %s: %s: %s", app.configPath, linkTarget, linkSource)
+ if app.dryRun {
+ app.logger.heading("Config would become:")
+ fmt.Println(buf.String())
+ return nil
+ }
+
+ if err := writeFileAtomic(app.configPath, buf.Bytes()); err != nil {
+ return fmt.Errorf("error writing config: %w", err)
+ }
+ app.logger.success("Updated config: %s", app.configPath)
+
+ return nil
+}
+
+// findConfigSection picks the mapping to update: the section matching the active
+// profile, or the first section when no profile is set.
+func findConfigSection(doc *yaml.Node, profile string) *yaml.Node {
+ if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
+ return nil
+ }
+
+ root := doc.Content[0]
+ if root.Kind != yaml.SequenceNode {
+ return nil
+ }
+
+ var first *yaml.Node
+ for _, item := range root.Content {
+ if item.Kind != yaml.MappingNode {
+ continue
+ }
+ if first == nil {
+ first = item
+ }
+ if profile != "" && mappingValue(item, "profile") == profile {
+ return item
+ }
+ }
+
+ // With an explicit profile, writing into an unrelated section would be
+ // worse than not writing at all.
+ if profile != "" {
+ return nil
+ }
+
+ return first
+}
+
+// mappingValue returns the scalar value stored under key in a mapping node.
+func mappingValue(node *yaml.Node, key string) string {
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ if node.Content[i].Value == key {
+ return node.Content[i+1].Value
+ }
+ }
+ return ""
+}
+
+// setLinkEntry adds or updates target: source inside the section's link mapping,
+// creating that mapping when the section has none.
+func setLinkEntry(section *yaml.Node, target, source string) error {
+ var links *yaml.Node
+ for i := 0; i+1 < len(section.Content); i += 2 {
+ if section.Content[i].Value == "link" {
+ links = section.Content[i+1]
+ break
+ }
+ }
+
+ if links == nil {
+ section.Content = append(section.Content,
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "link"},
+ &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"},
+ )
+ links = section.Content[len(section.Content)-1]
+ }
+
+ // An empty `link:` key decodes as a null scalar; turn it into a mapping.
+ if links.Kind == yaml.ScalarNode && links.Tag == "!!null" {
+ links.Kind = yaml.MappingNode
+ links.Tag = "!!map"
+ links.Value = ""
+ }
+
+ if links.Kind != yaml.MappingNode {
+ return fmt.Errorf("'link' in the config is not a mapping, cannot add %s", target)
+ }
+
+ for i := 0; i+1 < len(links.Content); i += 2 {
+ if links.Content[i].Value == target {
+ links.Content[i+1].Value = source
+ links.Content[i+1].Tag = "!!str"
+ links.Content[i+1].Style = 0
+ return nil
+ }
+ }
+
+ links.Content = append(links.Content,
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: target},
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: source},
+ )
+
return nil
}
diff --git a/src/adopt_test.go b/src/adopt_test.go
new file mode 100644
index 0000000..168a5bd
--- /dev/null
+++ b/src/adopt_test.go
@@ -0,0 +1,279 @@
+// hideDot - A dotfiles manager
+// Copyright (C) 2024-2026 youhide
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestAdoptDestPath(t *testing.T) {
+ home := filepath.Join(string(os.PathSeparator), "home", "user")
+ repo := filepath.Join(string(os.PathSeparator), "repo", "dotfiles")
+
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {
+ name: "dotfile in home",
+ in: filepath.Join(home, ".zshrc"),
+ want: filepath.Join(repo, "zshrc"),
+ },
+ {
+ name: "nested config directory",
+ in: filepath.Join(home, ".config", "nvim"),
+ want: filepath.Join(repo, "config", "nvim"),
+ },
+ {
+ name: "deeply nested file",
+ in: filepath.Join(home, ".config", "git", "config"),
+ want: filepath.Join(repo, "config", "git", "config"),
+ },
+ {
+ name: "plain name in home",
+ in: filepath.Join(home, "notes"),
+ want: filepath.Join(repo, "notes"),
+ },
+ {
+ name: "outside home keeps its layout",
+ in: filepath.Join(string(os.PathSeparator), "etc", "hosts"),
+ want: filepath.Join(repo, "etc", "hosts"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := adoptDestPath(tt.in, home, repo); got != tt.want {
+ t.Errorf("adoptDestPath(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAdoptPath(t *testing.T) {
+ t.Run("moves the file, symlinks it back and records it", func(t *testing.T) {
+ app := newTestApp(t)
+ target := filepath.Join(app.homeDir, ".config", "nvim", "init.lua")
+ writeTestFile(t, target, "vim.opt.number = true")
+ writeTestFile(t, app.configPath, "- link: {}\n")
+
+ if err := app.adoptPath(target, "", true); err != nil {
+ t.Fatal(err)
+ }
+
+ dest := filepath.Join(app.execDir, "config", "nvim", "init.lua")
+ if got := readTestFile(t, dest); got != "vim.opt.number = true" {
+ t.Errorf("moved content = %q", got)
+ }
+
+ link, err := os.Readlink(target)
+ if err != nil {
+ t.Fatalf("expected a symlink at %s: %v", target, err)
+ }
+ if link != dest {
+ t.Errorf("symlink points at %q, want %q", link, dest)
+ }
+
+ config := readTestFile(t, app.configPath)
+ if !strings.Contains(config, "~/.config/nvim/init.lua: ./config/nvim/init.lua") {
+ t.Errorf("config entry missing:\n%s", config)
+ }
+ })
+
+ t.Run("honours --to", func(t *testing.T) {
+ app := newTestApp(t)
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, target, "export X=1")
+ writeTestFile(t, app.configPath, "- link: {}\n")
+
+ if err := app.adoptPath(target, "zsh/zshrc", true); err != nil {
+ t.Fatal(err)
+ }
+
+ if got := readTestFile(t, filepath.Join(app.execDir, "zsh", "zshrc")); got != "export X=1" {
+ t.Errorf("moved content = %q", got)
+ }
+ if config := readTestFile(t, app.configPath); !strings.Contains(config, "~/.zshrc: ./zsh/zshrc") {
+ t.Errorf("config entry missing:\n%s", config)
+ }
+ })
+
+ t.Run("refuses a path that is already a symlink", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+ if err := os.Symlink(source, target); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := app.adoptPath(target, "", true); err == nil {
+ t.Error("expected an error for an already-adopted path")
+ }
+ })
+
+ t.Run("refuses to overwrite an existing destination", func(t *testing.T) {
+ app := newTestApp(t)
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, target, "new")
+ writeTestFile(t, filepath.Join(app.execDir, "zshrc"), "existing")
+
+ if err := app.adoptPath(target, "", true); err == nil {
+ t.Error("expected an error when the destination exists")
+ }
+ if got := readTestFile(t, filepath.Join(app.execDir, "zshrc")); got != "existing" {
+ t.Errorf("destination was overwritten: %q", got)
+ }
+ })
+
+ t.Run("dry run changes nothing", func(t *testing.T) {
+ app := newTestApp(t)
+ app.dryRun = true
+ app.logger.dryRun = true
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, target, "export X=1")
+ original := "- link: {}\n"
+ writeTestFile(t, app.configPath, original)
+
+ if err := app.adoptPath(target, "", true); err != nil {
+ t.Fatal(err)
+ }
+
+ if got := readTestFile(t, target); got != "export X=1" {
+ t.Error("dry run moved the file")
+ }
+ if got := readTestFile(t, app.configPath); got != original {
+ t.Errorf("dry run wrote to the config:\n%s", got)
+ }
+ })
+}
+
+func TestAddLinkToConfig(t *testing.T) {
+ t.Run("preserves comments and existing entries", func(t *testing.T) {
+ app := newTestApp(t)
+ writeTestFile(t, app.configPath, `- defaults:
+ link:
+ relink: true # keep this comment
+ # create directories first
+ create:
+ - ~/.config
+ link:
+ ~/.zshrc: ./zsh/zshrc
+`)
+
+ if err := app.addLinkToConfig("~/.vimrc", "./vimrc"); err != nil {
+ t.Fatal(err)
+ }
+
+ got := readTestFile(t, app.configPath)
+ for _, want := range []string{
+ "# keep this comment",
+ "# create directories first",
+ "~/.zshrc: ./zsh/zshrc",
+ "~/.vimrc: ./vimrc",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("config lost %q:\n%s", want, got)
+ }
+ }
+ })
+
+ t.Run("creates the link mapping when absent", func(t *testing.T) {
+ app := newTestApp(t)
+ writeTestFile(t, app.configPath, "- create:\n - ~/.config\n")
+
+ if err := app.addLinkToConfig("~/.vimrc", "./vimrc"); err != nil {
+ t.Fatal(err)
+ }
+
+ configs := mustParseConfigs(t, readTestFile(t, app.configPath))
+ if configs[0].Link["~/.vimrc"] != "./vimrc" {
+ t.Errorf("entry not added: %+v", configs[0].Link)
+ }
+ })
+
+ t.Run("updates an existing target instead of duplicating it", func(t *testing.T) {
+ app := newTestApp(t)
+ writeTestFile(t, app.configPath, "- link:\n ~/.vimrc: ./old\n")
+
+ if err := app.addLinkToConfig("~/.vimrc", "./vimrc"); err != nil {
+ t.Fatal(err)
+ }
+
+ got := readTestFile(t, app.configPath)
+ if strings.Contains(got, "./old") {
+ t.Errorf("old source kept:\n%s", got)
+ }
+ configs := mustParseConfigs(t, got)
+ if configs[0].Link["~/.vimrc"] != "./vimrc" {
+ t.Errorf("entry not updated: %+v", configs[0].Link)
+ }
+ })
+
+ t.Run("writes into the matching profile section", func(t *testing.T) {
+ app := newTestApp(t)
+ app.profile = "work"
+ writeTestFile(t, app.configPath, `- link:
+ ~/.zshrc: ./zshrc
+- profile: work
+ link:
+ ~/.gitconfig: ./git/config-work
+`)
+
+ if err := app.addLinkToConfig("~/.vimrc", "./vimrc"); err != nil {
+ t.Fatal(err)
+ }
+
+ configs := mustParseConfigs(t, readTestFile(t, app.configPath))
+ if _, ok := configs[0].Link["~/.vimrc"]; ok {
+ t.Error("entry landed in the unprofiled section")
+ }
+ if configs[1].Link["~/.vimrc"] != "./vimrc" {
+ t.Errorf("entry missing from the work section: %+v", configs[1].Link)
+ }
+ })
+
+ t.Run("leaves a config it cannot parse untouched", func(t *testing.T) {
+ app := newTestApp(t)
+ // Valid Go template, invalid YAML: the value opens a flow mapping.
+ original := "- link:\n ~/.gitconfig: {{ .HomeDir }}/git\n"
+ writeTestFile(t, app.configPath, original)
+
+ if err := app.addLinkToConfig("~/.vimrc", "./vimrc"); err != nil {
+ t.Fatal(err)
+ }
+
+ if got := readTestFile(t, app.configPath); got != original {
+ t.Errorf("config was rewritten:\n%s", got)
+ }
+ })
+
+ t.Run("leaves the config alone when no profile matches", func(t *testing.T) {
+ app := newTestApp(t)
+ app.profile = "laptop"
+ original := "- link:\n ~/.zshrc: ./zshrc\n"
+ writeTestFile(t, app.configPath, original)
+
+ if err := app.addLinkToConfig("~/.vimrc", "./vimrc"); err != nil {
+ t.Fatal(err)
+ }
+
+ if got := readTestFile(t, app.configPath); got != original {
+ t.Errorf("entry was written to an unrelated section:\n%s", got)
+ }
+ })
+}
+
+func TestRunAdoptRejectsToWithMultiplePaths(t *testing.T) {
+ app := newTestApp(t)
+
+ if err := app.RunAdopt([]string{"a", "b"}, "dest", true); err == nil {
+ t.Error("expected an error when --to is used with several paths")
+ }
+}
diff --git a/src/app.go b/src/app.go
index 47170b6..20b83ad 100644
--- a/src/app.go
+++ b/src/app.go
@@ -48,7 +48,7 @@ func (app *App) Initialize() error {
return fmt.Errorf("error getting home directory: %w", err)
}
- app.execDir, err = getExecutableDir()
+ app.execDir, err = getWorkingDir()
if err != nil {
return fmt.Errorf("error getting executable directory: %w", err)
}
@@ -112,16 +112,23 @@ func (app *App) LoadConfigs() ([]Config, error) {
return filteredConfigs, nil
}
-// expandTemplates expands Go templates in the config
+// expandTemplates expands Go templates in the config.
+//
+// A parse failure is tolerated: the file most likely isn't a template at all and
+// just happens to contain a literal "{{" (a shell command for another tool, say).
+// An execution failure is not — at that point the file *is* a template and the
+// error is a real mistake, usually a misspelled variable. Swallowing it would
+// create links with "{{ .Hostnam }}" baked into their names.
func (app *App) expandTemplates(content string) (string, error) {
tmpl, err := template.New("config").Parse(content)
if err != nil {
- return content, nil // Return original if template parsing fails
+ app.logger.warn("Config is not a valid template, using it as-is: %v", err)
+ return content, nil
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, app.tmplData); err != nil {
- return content, nil // Return original if template execution fails
+ return "", err
}
return buf.String(), nil
@@ -160,9 +167,38 @@ func (app *App) validateConfig(cfg Config) error {
}
// getDefaultOptions resolves the effective link options for a config section.
-func (app *App) getDefaultOptions(config Config) (force, relink, backup bool) {
- if config.Defaults == nil {
- return false, false, true // backup enabled by default
+// An omitted key falls back to the default: backups on, everything else off.
+func (app *App) getDefaultOptions(config Config) linkOptions {
+ opts := linkOptions{backup: true}
+
+ if config.Defaults != nil {
+ l := config.Defaults.Link
+ opts.force = boolValue(l.Force, false)
+ opts.relink = boolValue(l.Relink, false)
+ opts.backup = boolValue(l.Backup, true)
+ opts.removeDuplicates = boolValue(l.RemoveDuplicates, false)
}
- return config.Defaults.Link.Force, config.Defaults.Link.Relink, config.Defaults.Link.Backup
+
+ if app.noBackup {
+ opts.backup = false
+ }
+
+ return opts
+}
+
+// boolValue dereferences an optional config flag, falling back to def.
+func boolValue(p *bool, def bool) bool {
+ if p == nil {
+ return def
+ }
+ return *p
+}
+
+// failureError turns per-item failures that were already logged into a non-zero
+// exit status, so scripts and CI can tell a partial run from a clean one.
+func (app *App) failureError() error {
+ if app.logger == nil || app.logger.errorCount == 0 {
+ return nil
+ }
+ return fmt.Errorf("%d operation(s) failed", app.logger.errorCount)
}
diff --git a/src/backup.go b/src/backup.go
index 5d7d10b..82ce2cf 100644
--- a/src/backup.go
+++ b/src/backup.go
@@ -7,11 +7,27 @@ package main
import (
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
"fmt"
+ "maps"
"os"
"path/filepath"
+ "slices"
+ "time"
)
+// manifestName is the index kept alongside the backups. Backup directories are
+// named after a hash of their original path, which makes them unreadable on
+// their own — the manifest is what lets `backup list` show real paths.
+const manifestName = "manifest.json"
+
+// backupEntry describes one backup in the manifest.
+type backupEntry struct {
+ OriginalPath string `json:"original_path"`
+ Timestamp string `json:"timestamp"`
+ IsDir bool `json:"is_dir"`
+}
+
// RunBackup creates backups of all linked files
func (app *App) RunBackup(configs []Config) error {
app.logger.heading("Creating backups...")
@@ -21,7 +37,7 @@ func (app *App) RunBackup(configs []Config) error {
}
for _, config := range configs {
- for target := range config.Link {
+ for _, target := range slices.Sorted(maps.Keys(config.Link)) {
targetPath := expandPath(target, app.homeDir)
exists, isDir, _ := checkPathExists(targetPath)
@@ -40,24 +56,16 @@ func (app *App) RunBackup(configs []Config) error {
continue
}
- backupPath := app.getBackupPath(targetPath)
- app.logger.info("Backing up: %s → %s", targetPath, backupPath)
-
- if err := app.logger.execute(func() error {
- if isDir {
- return copyDir(targetPath, backupPath)
- }
- return copyFile(targetPath, backupPath)
- }); err != nil {
+ if err := app.createBackup(targetPath, isDir); err != nil {
app.logger.error("Error creating backup: %v", err)
- } else {
- app.logger.success("Backed up: %s", targetPath)
+ continue
}
+ app.logger.success("Backed up: %s", targetPath)
}
}
app.logger.summary()
- return nil
+ return app.failureError()
}
// RunListBackups lists all available backups
@@ -73,38 +81,70 @@ func (app *App) RunListBackups() error {
return fmt.Errorf("error reading backup directory: %w", err)
}
- if len(entries) == 0 {
- fmt.Println("No backups found.")
- return nil
- }
+ manifest := app.readManifest()
+ var count int
for _, entry := range entries {
+ if entry.Name() == manifestName {
+ continue
+ }
+
info, err := entry.Info()
if err != nil {
continue
}
- fmt.Printf(" %s %s\n", info.ModTime().Format("2006-01-02 15:04:05"), entry.Name())
+ count++
+
+ when := info.ModTime().Format("2006-01-02 15:04:05")
+ name := entry.Name()
+
+ // Fall back to the raw directory name for backups made before the
+ // manifest existed, or if it was removed by hand.
+ if record, ok := manifest[entry.Name()]; ok {
+ name = record.OriginalPath
+ if t, err := time.Parse(time.RFC3339, record.Timestamp); err == nil {
+ when = t.Format("2006-01-02 15:04:05")
+ }
+ }
+
+ fmt.Printf(" %s %s\n", when, name)
+ }
+
+ if count == 0 {
+ fmt.Println("No backups found.")
}
return nil
}
-func (app *App) createBackup(targetPath string, isDir bool) {
+// createBackup copies targetPath into the backup directory, replacing any
+// previous backup of the same path. It returns an error instead of only logging
+// one so callers can refuse to destroy a file they failed to back up.
+func (app *App) createBackup(targetPath string, isDir bool) error {
backupPath := app.getBackupPath(targetPath)
app.logger.info("Creating backup: %s → %s", targetPath, backupPath)
- if err := app.logger.execute(func() error {
- // Ensure backup directory exists
+ return app.logger.execute(func() error {
if err := os.MkdirAll(filepath.Dir(backupPath), 0755); err != nil {
return err
}
+
+ // Drop the previous backup first: copyDir merges into an existing
+ // directory, which would leave files that were since deleted behind.
+ if err := os.RemoveAll(backupPath); err != nil {
+ return err
+ }
+
if isDir {
- return copyDir(targetPath, backupPath)
+ if err := copyDir(targetPath, backupPath); err != nil {
+ return err
+ }
+ } else if err := copyFile(targetPath, backupPath); err != nil {
+ return err
}
- return copyFile(targetPath, backupPath)
- }); err != nil {
- app.logger.error("Error creating backup: %v", err)
- }
+
+ return app.recordBackup(targetPath, isDir)
+ })
}
func (app *App) restoreBackup(targetPath string) {
@@ -112,7 +152,7 @@ func (app *App) restoreBackup(targetPath string) {
exists, isDir, _ := checkPathExists(backupPath)
if !exists {
- app.logger.debug("No backup found for: %s", targetPath)
+ app.logger.warn("No backup to restore for: %s", targetPath)
return
}
@@ -137,3 +177,42 @@ func (app *App) getBackupPath(targetPath string) string {
baseName := filepath.Base(targetPath)
return filepath.Join(app.backupDir, fmt.Sprintf("%s_%s", baseName, shortHash))
}
+
+func (app *App) manifestPath() string {
+ return filepath.Join(app.backupDir, manifestName)
+}
+
+// readManifest returns the backup index, or an empty map when it is missing or
+// unreadable — listing backups must never fail because of a broken index.
+func (app *App) readManifest() map[string]backupEntry {
+ manifest := make(map[string]backupEntry)
+
+ data, err := os.ReadFile(app.manifestPath())
+ if err != nil {
+ return manifest
+ }
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ app.logger.debug("Ignoring unreadable backup manifest: %v", err)
+ return make(map[string]backupEntry)
+ }
+
+ return manifest
+}
+
+// recordBackup adds targetPath to the manifest so it can be listed by its real
+// name later.
+func (app *App) recordBackup(targetPath string, isDir bool) error {
+ manifest := app.readManifest()
+ manifest[filepath.Base(app.getBackupPath(targetPath))] = backupEntry{
+ OriginalPath: targetPath,
+ Timestamp: time.Now().Format(time.RFC3339),
+ IsDir: isDir,
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return writeFileAtomic(app.manifestPath(), data)
+}
diff --git a/src/fsutil.go b/src/fsutil.go
index 0198702..d137d59 100644
--- a/src/fsutil.go
+++ b/src/fsutil.go
@@ -22,7 +22,9 @@ func buildShellCmd(command string) *exec.Cmd {
return exec.Command("bash", "-c", command)
}
-func getExecutableDir() (string, error) {
+// getWorkingDir returns the directory relative link sources are resolved
+// against: the directory hidedot was invoked from, not where the binary lives.
+func getWorkingDir() (string, error) {
// First, try to use the current working directory
if cwd, err := os.Getwd(); err == nil {
return cwd, nil
@@ -112,6 +114,56 @@ func copyFile(src, dst string) error {
return os.Chmod(dst, srcInfo.Mode())
}
+// movePath moves src to dst, falling back to copy-then-delete when the two live
+// on different filesystems (os.Rename fails with EXDEV there).
+func movePath(src, dst string, isDir bool) error {
+ if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
+ return err
+ }
+
+ if err := os.Rename(src, dst); err == nil {
+ return nil
+ }
+
+ if isDir {
+ if err := copyDir(src, dst); err != nil {
+ return err
+ }
+ } else if err := copyFile(src, dst); err != nil {
+ return err
+ }
+
+ return os.RemoveAll(src)
+}
+
+// writeFileAtomic writes through a temp file in the same directory, so an
+// interrupted write can never leave a truncated config behind.
+func writeFileAtomic(path string, data []byte) error {
+ tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp*")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+
+ // Preserve the original file's mode when replacing an existing file.
+ if info, err := os.Stat(path); err == nil {
+ if err := os.Chmod(tmpName, info.Mode()); err != nil {
+ return err
+ }
+ }
+
+ return os.Rename(tmpName, path)
+}
+
func copyDir(src, dst string) error {
srcInfo, err := os.Stat(src)
if err != nil {
diff --git a/src/init.go b/src/init.go
index 2b4cd9d..b7a2549 100644
--- a/src/init.go
+++ b/src/init.go
@@ -14,7 +14,8 @@ const defaultConfigTemplate = `- defaults:
link:
relink: true
force: true
- backup: true # Enable automatic backups
+ backup: true # Automatic backups (on unless set to false)
+ # remove_duplicates: true # Delete other symlinks pointing at the same source
# Create directories before linking
create:
diff --git a/src/link.go b/src/link.go
index 39a0d65..0550d05 100644
--- a/src/link.go
+++ b/src/link.go
@@ -7,26 +7,33 @@ package main
import (
"bytes"
"fmt"
+ "maps"
"os"
"os/exec"
"path/filepath"
+ "slices"
"strings"
)
// RunLink executes the link command
func (app *App) RunLink(configs []Config) error {
+ declared := app.declaredTargets(configs)
+
for _, config := range configs {
- force, relink, backup := app.getDefaultOptions(config)
+ opts := app.getDefaultOptions(config)
if config.Defaults != nil {
- app.logger.info("Settings: force=%v, relink=%v, backup=%v", force, relink, backup)
+ app.logger.info("Settings: force=%v, relink=%v, backup=%v", opts.force, opts.relink, opts.backup)
}
- // Run pre-link hooks
+ // Run pre-link hooks. A failing pre-hook means the section's
+ // preconditions aren't met, so skip the whole section rather than
+ // linking on top of a half-prepared system.
if config.Hooks != nil && len(config.Hooks.PreLink) > 0 {
app.logger.heading("Running pre-link hooks...")
if err := app.runHooks(config.Hooks.PreLink); err != nil {
- app.logger.error("Pre-link hook failed: %v", err)
+ app.logger.error("Pre-link hook failed, skipping this config section: %v", err)
+ continue
}
}
@@ -38,11 +45,12 @@ func (app *App) RunLink(configs []Config) error {
}
}
- // Process link creation
+ // Process link creation. Maps iterate in random order, so sort the
+ // keys to keep runs (and their output) reproducible.
if len(config.Link) > 0 {
app.logger.heading("Creating links...")
- for target, source := range config.Link {
- app.createLink(target, source, force, relink, backup && !app.noBackup)
+ for _, target := range slices.Sorted(maps.Keys(config.Link)) {
+ app.createLink(target, config.Link[target], opts, declared)
}
}
@@ -57,16 +65,18 @@ func (app *App) RunLink(configs []Config) error {
// Process git repositories
if len(config.Git) > 0 {
app.logger.heading("Setting up git repositories...")
- for path, repo := range config.Git {
- app.cloneRepo(path, repo)
+ for _, path := range slices.Sorted(maps.Keys(config.Git)) {
+ app.cloneRepo(path, config.Git[path])
}
}
- // Run pre-shell hooks
+ // Run pre-shell hooks. Shell commands are the destructive part of a
+ // section, so a failing pre-hook skips them (and the post-hooks).
if config.Hooks != nil && len(config.Hooks.PreShell) > 0 {
app.logger.heading("Running pre-shell hooks...")
if err := app.runHooks(config.Hooks.PreShell); err != nil {
- app.logger.error("Pre-shell hook failed: %v", err)
+ app.logger.error("Pre-shell hook failed, skipping shell commands: %v", err)
+ continue
}
}
@@ -88,7 +98,25 @@ func (app *App) RunLink(configs []Config) error {
}
app.logger.summary()
- return nil
+ return app.failureError()
+}
+
+// declaredTargets collects every link target across all configs, so duplicate
+// removal can never delete a path the user explicitly manages.
+func (app *App) declaredTargets(configs []Config) map[string]bool {
+ targets := make(map[string]bool)
+
+ for _, config := range configs {
+ for target := range config.Link {
+ path, err := filepath.Abs(expandPath(target, app.homeDir))
+ if err != nil {
+ continue
+ }
+ targets[path] = true
+ }
+ }
+
+ return targets
}
func (app *App) createDirectory(dir string) {
@@ -119,8 +147,9 @@ func (app *App) createDirectory(dir string) {
}
}
-func (app *App) createLink(target, source string, force, relink, backup bool) {
+func (app *App) createLink(target, source string, opts linkOptions, declared map[string]bool) {
targetPath := expandPath(target, app.homeDir)
+ targetPath, _ = filepath.Abs(targetPath)
sourcePath := expandSourcePath(source, app.homeDir, app.execDir)
sourcePath, _ = filepath.Abs(sourcePath)
@@ -150,8 +179,10 @@ func (app *App) createLink(target, source string, force, relink, backup bool) {
return
}
- // Check for duplicates
- app.checkForDuplicates(targetPath, sourcePath)
+ // Check for duplicates (opt-in: it deletes files)
+ if opts.removeDuplicates {
+ app.checkForDuplicates(targetPath, sourcePath, declared)
+ }
// Check target path
targetExists, isTargetDir, _ := checkPathExists(targetPath)
@@ -173,7 +204,7 @@ func (app *App) createLink(target, source string, force, relink, backup bool) {
return
}
- if relink {
+ if opts.relink {
app.logger.warn("Relinking: %s → %s (was: %s)", targetPath, sourcePath, currentTarget)
app.logger.execute(func() error {
return os.Remove(targetPath)
@@ -183,10 +214,15 @@ func (app *App) createLink(target, source string, force, relink, backup bool) {
return
}
}
- } else if force {
- // Not a symlink but force is true - backup first if enabled
- if backup {
- app.createBackup(targetPath, isTargetDir)
+ } else if opts.force {
+ // Not a symlink but force is true - back it up before destroying it.
+ // If that backup can't be made, leave the file alone: an
+ // unrecoverable overwrite is worse than a skipped link.
+ if opts.backup {
+ if err := app.createBackup(targetPath, isTargetDir); err != nil {
+ app.logger.error("Backup failed, refusing to overwrite %s: %v", targetPath, err)
+ return
+ }
}
app.logger.warn("Removing existing path (force=true): %s", targetPath)
app.logger.execute(func() error {
@@ -209,7 +245,16 @@ func (app *App) createLink(target, source string, force, relink, backup bool) {
}
}
-func (app *App) checkForDuplicates(targetPath, sourcePath string) {
+// checkForDuplicates removes other symlinks in the target's directory that point
+// at the same source. Targets declared anywhere in the config are never touched:
+// two config entries sharing one source are a legitimate setup (~/.bashrc and
+// ~/.bash_profile, say), and deleting one of them would make the two entries
+// destroy each other on every run.
+//
+// Removals are logged rather than backed up on purpose. A symlink holds no data
+// of its own, and backups are keyed by path — copying one here would follow the
+// link and overwrite an existing backup of the real file that used to live there.
+func (app *App) checkForDuplicates(targetPath, sourcePath string, declared map[string]bool) {
targetDir := filepath.Dir(targetPath)
entries, err := os.ReadDir(targetDir)
@@ -220,7 +265,7 @@ func (app *App) checkForDuplicates(targetPath, sourcePath string) {
for _, entry := range entries {
entryPath := filepath.Join(targetDir, entry.Name())
- if entryPath == targetPath {
+ if entryPath == targetPath || declared[entryPath] {
continue
}
@@ -236,7 +281,7 @@ func (app *App) checkForDuplicates(targetPath, sourcePath string) {
linkDest, _ = filepath.Abs(linkDest)
if linkDest == sourcePath {
- app.logger.warn("Found duplicate symlink: %s → %s", entryPath, sourcePath)
+ app.logger.warn("Removing duplicate symlink: %s → %s", entryPath, sourcePath)
app.logger.execute(func() error {
return os.Remove(entryPath)
})
diff --git a/src/link_test.go b/src/link_test.go
new file mode 100644
index 0000000..6ceb8a8
--- /dev/null
+++ b/src/link_test.go
@@ -0,0 +1,337 @@
+// hideDot - A dotfiles manager
+// Copyright (C) 2024-2026 youhide
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestGetDefaultOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ src string
+ want linkOptions
+ }{
+ {
+ name: "no defaults block",
+ src: "- link: {}\n",
+ want: linkOptions{backup: true},
+ },
+ {
+ // The regression that matters: declaring defaults without a
+ // backup key used to disable backups entirely.
+ name: "defaults without a backup key still backs up",
+ src: "- defaults:\n link:\n force: true\n relink: true\n",
+ want: linkOptions{force: true, relink: true, backup: true},
+ },
+ {
+ name: "backup explicitly disabled",
+ src: "- defaults:\n link:\n force: true\n backup: false\n",
+ want: linkOptions{force: true},
+ },
+ {
+ name: "duplicate removal is opt-in",
+ src: "- defaults:\n link:\n remove_duplicates: true\n",
+ want: linkOptions{backup: true, removeDuplicates: true},
+ },
+ }
+
+ app := &App{}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configs := mustParseConfigs(t, tt.src)
+ if got := app.getDefaultOptions(configs[0]); got != tt.want {
+ t.Errorf("getDefaultOptions() = %+v, want %+v", got, tt.want)
+ }
+ })
+ }
+
+ t.Run("--no-backup wins over the config", func(t *testing.T) {
+ noBackupApp := &App{noBackup: true}
+ configs := mustParseConfigs(t, "- defaults:\n link:\n backup: true\n")
+ if got := noBackupApp.getDefaultOptions(configs[0]); got.backup {
+ t.Error("--no-backup should disable backups")
+ }
+ })
+}
+
+func TestCreateLink(t *testing.T) {
+ t.Run("creates a missing symlink", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+
+ app.createLink(target, source, linkOptions{backup: true}, nil)
+
+ dest, err := os.Readlink(target)
+ if err != nil {
+ t.Fatalf("expected a symlink at %s: %v", target, err)
+ }
+ if dest != source {
+ t.Errorf("symlink points at %q, want %q", dest, source)
+ }
+ })
+
+ t.Run("leaves a regular file alone without force", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+ writeTestFile(t, target, "precious")
+
+ app.createLink(target, source, linkOptions{backup: true}, nil)
+
+ if got := readTestFile(t, target); got != "precious" {
+ t.Errorf("target was modified without force: %q", got)
+ }
+ })
+
+ t.Run("backs up before overwriting with force", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+ writeTestFile(t, target, "precious")
+
+ app.createLink(target, source, linkOptions{force: true, backup: true}, nil)
+
+ if _, err := os.Readlink(target); err != nil {
+ t.Fatalf("expected a symlink at %s: %v", target, err)
+ }
+ if got := readTestFile(t, app.getBackupPath(target)); got != "precious" {
+ t.Errorf("backup content = %q, want %q", got, "precious")
+ }
+ })
+
+ t.Run("relinks a symlink pointing elsewhere", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ other := filepath.Join(app.execDir, "other")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+ writeTestFile(t, other, "other")
+ if err := os.Symlink(other, target); err != nil {
+ t.Fatal(err)
+ }
+
+ app.createLink(target, source, linkOptions{relink: true, backup: true}, nil)
+
+ dest, err := os.Readlink(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dest != source {
+ t.Errorf("symlink points at %q, want %q", dest, source)
+ }
+ })
+
+ t.Run("keeps an existing symlink when relink is off", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ other := filepath.Join(app.execDir, "other")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+ writeTestFile(t, other, "other")
+ if err := os.Symlink(other, target); err != nil {
+ t.Fatal(err)
+ }
+
+ app.createLink(target, source, linkOptions{backup: true}, nil)
+
+ dest, err := os.Readlink(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dest != other {
+ t.Errorf("symlink was changed to %q", dest)
+ }
+ })
+
+ t.Run("missing source is reported as an error", func(t *testing.T) {
+ app := newTestApp(t)
+ target := filepath.Join(app.homeDir, ".zshrc")
+
+ app.createLink(target, filepath.Join(app.execDir, "nope"), linkOptions{backup: true}, nil)
+
+ if app.logger.errorCount == 0 {
+ t.Error("expected an error for a missing source")
+ }
+ if _, err := os.Lstat(target); !os.IsNotExist(err) {
+ t.Error("no link should have been created")
+ }
+ })
+}
+
+func TestCheckForDuplicates(t *testing.T) {
+ t.Run("removes an undeclared duplicate", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ stale := filepath.Join(app.homeDir, ".zshrc.old")
+ writeTestFile(t, source, "config")
+ if err := os.Symlink(source, stale); err != nil {
+ t.Fatal(err)
+ }
+
+ app.checkForDuplicates(target, source, nil)
+
+ if _, err := os.Lstat(stale); !os.IsNotExist(err) {
+ t.Error("stale duplicate should have been removed")
+ }
+ })
+
+ t.Run("never removes a declared target", func(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "shellrc")
+ target := filepath.Join(app.homeDir, ".bashrc")
+ sibling := filepath.Join(app.homeDir, ".bash_profile")
+ writeTestFile(t, source, "config")
+ if err := os.Symlink(source, sibling); err != nil {
+ t.Fatal(err)
+ }
+
+ app.checkForDuplicates(target, source, map[string]bool{sibling: true})
+
+ if _, err := os.Lstat(sibling); err != nil {
+ t.Errorf("declared target was removed: %v", err)
+ }
+ })
+}
+
+// Two config entries sharing one source used to delete each other on every run,
+// leaving whichever was processed last as the only survivor.
+func TestRunLinkKeepsTargetsSharingASource(t *testing.T) {
+ app := newTestApp(t)
+ writeTestFile(t, filepath.Join(app.execDir, "shellrc"), "config")
+
+ configs := mustParseConfigs(t, `- defaults:
+ link:
+ force: true
+ relink: true
+ remove_duplicates: true
+ link:
+ ~/.bashrc: ./shellrc
+ ~/.bash_profile: ./shellrc
+`)
+
+ for run := 1; run <= 2; run++ {
+ if err := app.RunLink(configs); err != nil {
+ t.Fatalf("run %d: %v", run, err)
+ }
+ }
+
+ for _, name := range []string{".bashrc", ".bash_profile"} {
+ if _, err := os.Lstat(filepath.Join(app.homeDir, name)); err != nil {
+ t.Errorf("%s is gone after two runs: %v", name, err)
+ }
+ }
+}
+
+func TestRunLinkReportsFailures(t *testing.T) {
+ app := newTestApp(t)
+ configs := mustParseConfigs(t, "- link:\n ~/.zshrc: ./missing\n")
+
+ if err := app.RunLink(configs); err == nil {
+ t.Error("expected a non-nil error so the process exits non-zero")
+ }
+}
+
+func TestRunLinkAbortsSectionOnPreHookFailure(t *testing.T) {
+ app := newTestApp(t)
+ writeTestFile(t, filepath.Join(app.execDir, "zshrc"), "config")
+
+ configs := mustParseConfigs(t, `- hooks:
+ pre_link:
+ - exit 1
+ link:
+ ~/.zshrc: ./zshrc
+`)
+
+ if err := app.RunLink(configs); err == nil {
+ t.Error("expected a failing pre-link hook to be reported")
+ }
+ if _, err := os.Lstat(filepath.Join(app.homeDir, ".zshrc")); !os.IsNotExist(err) {
+ t.Error("links should not be created after a failed pre-link hook")
+ }
+}
+
+func TestRunUnlinkRestoresBackup(t *testing.T) {
+ app := newTestApp(t)
+ source := filepath.Join(app.execDir, "zshrc")
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, source, "config")
+ writeTestFile(t, target, "precious")
+
+ configs := mustParseConfigs(t, `- defaults:
+ link:
+ force: true
+ link:
+ ~/.zshrc: ./zshrc
+`)
+
+ if err := app.RunLink(configs); err != nil {
+ t.Fatal(err)
+ }
+ if err := app.RunUnlink(configs, true); err != nil {
+ t.Fatal(err)
+ }
+
+ info, err := os.Lstat(target)
+ if err != nil {
+ t.Fatalf("target was not restored: %v", err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ t.Error("restored target is still a symlink")
+ }
+ if got := readTestFile(t, target); got != "precious" {
+ t.Errorf("restored content = %q, want %q", got, "precious")
+ }
+}
+
+func TestRunBackupRecordsManifest(t *testing.T) {
+ app := newTestApp(t)
+ target := filepath.Join(app.homeDir, ".zshrc")
+ writeTestFile(t, target, "precious")
+
+ configs := mustParseConfigs(t, "- link:\n ~/.zshrc: ./zshrc\n")
+ if err := app.RunBackup(configs); err != nil {
+ t.Fatal(err)
+ }
+
+ manifest := app.readManifest()
+ entry, ok := manifest[filepath.Base(app.getBackupPath(target))]
+ if !ok {
+ t.Fatalf("backup not recorded in manifest: %+v", manifest)
+ }
+ if entry.OriginalPath != target {
+ t.Errorf("manifest path = %q, want %q", entry.OriginalPath, target)
+ }
+}
+
+// copyDir merges into whatever is already there, so a stale backup must be
+// cleared before a directory is backed up again.
+func TestCreateBackupReplacesStaleDirectory(t *testing.T) {
+ app := newTestApp(t)
+ target := filepath.Join(app.homeDir, ".config")
+ writeTestFile(t, filepath.Join(target, "keep"), "keep")
+ writeTestFile(t, filepath.Join(target, "gone"), "gone")
+
+ if err := app.createBackup(target, true); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(target, "gone")); err != nil {
+ t.Fatal(err)
+ }
+ if err := app.createBackup(target, true); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := os.Stat(filepath.Join(app.getBackupPath(target), "gone")); !os.IsNotExist(err) {
+ t.Error("deleted file survived in the refreshed backup")
+ }
+}
diff --git a/src/logger.go b/src/logger.go
index a657545..9d76ba8 100644
--- a/src/logger.go
+++ b/src/logger.go
@@ -125,6 +125,9 @@ func (l *Logger) heading(format string, args ...interface{}) {
}
func (l *Logger) summary() {
+ if l.quiet {
+ return
+ }
if l.useColors {
fmt.Printf("\n"+BoldGreen+"%d successful"+Reset+", "+BoldYellow+"%d warnings"+Reset+", "+BoldRed+"%d errors"+Reset+"\n",
l.successCount, l.warnCount, l.errorCount)
diff --git a/src/main.go b/src/main.go
index 6c8054e..d9d6944 100644
--- a/src/main.go
+++ b/src/main.go
@@ -33,6 +33,9 @@ func main() {
Short: "A blazing fast dotFiles manager",
Long: "hideDot - Easily manage your dotfiles, symlinks, and system configuration with a simple YAML config.",
Version: Version,
+ // Runtime failures are already reported per item; don't bury them
+ // under the full usage text.
+ SilenceUsage: true,
}
// Global flags
@@ -128,18 +131,23 @@ func main() {
initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "Overwrite an existing config file")
// Adopt command
+ var adoptTo string
+ var adoptNoConfig bool
adoptCmd := &cobra.Command{
- Use: "adopt ",
- Short: "Move an existing file into the dotfiles dir and symlink it",
- Long: "Move an existing file or directory into the dotfiles directory and replace it with a symlink pointing back.",
- Args: cobra.ExactArgs(1),
+ Use: "adopt ...",
+ Short: "Move existing files into the dotfiles dir, symlink them and record them in the config",
+ Long: "Move existing files or directories into the dotfiles directory, replace them with symlinks pointing back, " +
+ "and add the matching entries to your config file.",
+ Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := app.Initialize(); err != nil {
return err
}
- return app.RunAdopt(args[0])
+ return app.RunAdopt(args, adoptTo, !adoptNoConfig)
},
}
+ adoptCmd.Flags().StringVar(&adoptTo, "to", "", "Destination inside the dotfiles dir (single path only)")
+ adoptCmd.Flags().BoolVar(&adoptNoConfig, "no-config", false, "Print the config entry instead of writing it")
// Add all commands
rootCmd.AddCommand(linkCmd, statusCmd, unlinkCmd, backupCmd, initCmd, adoptCmd)
diff --git a/src/main_test.go b/src/main_test.go
index d08d0e7..3a9abd0 100644
--- a/src/main_test.go
+++ b/src/main_test.go
@@ -10,6 +10,63 @@ import (
"gopkg.in/yaml.v3"
)
+// newTestApp returns an App rooted in temporary directories, quiet enough not
+// to spam test output.
+func newTestApp(t *testing.T) *App {
+ t.Helper()
+
+ dir := t.TempDir()
+ home := filepath.Join(dir, "home")
+ repo := filepath.Join(dir, "repo")
+ backups := filepath.Join(dir, "backups")
+ for _, d := range []string{home, repo, backups} {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ return &App{
+ logger: &Logger{quiet: true},
+ homeDir: home,
+ execDir: repo,
+ backupDir: backups,
+ configPath: filepath.Join(repo, "hidedot.conf.yaml"),
+ }
+}
+
+// mustParseConfigs decodes YAML the same way LoadConfigs does, so tests exercise
+// the real config-to-struct wiring rather than hand-built structs.
+func mustParseConfigs(t *testing.T, src string) []Config {
+ t.Helper()
+
+ var configs []Config
+ if err := yaml.Unmarshal([]byte(src), &configs); err != nil {
+ t.Fatalf("parsing test config: %v", err)
+ }
+ return configs
+}
+
+func writeTestFile(t *testing.T, path, content string) {
+ t.Helper()
+
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readTestFile(t *testing.T, path string) string {
+ t.Helper()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
+
func TestExpandPath(t *testing.T) {
home := "/home/user"
tests := []struct {
@@ -126,12 +183,15 @@ func TestShellCommandUnmarshalYAML(t *testing.T) {
}
func TestExpandTemplates(t *testing.T) {
- app := &App{tmplData: TemplateData{
- Hostname: "myhost",
- Username: "bob",
- OS: "darwin",
- Arch: "arm64",
- }}
+ app := &App{
+ logger: &Logger{quiet: true},
+ tmplData: TemplateData{
+ Hostname: "myhost",
+ Username: "bob",
+ OS: "darwin",
+ Arch: "arm64",
+ },
+ }
out, err := app.expandTemplates("host={{ .Hostname }} os={{ .OS }}/{{ .Arch }} user={{ .Username }}")
if err != nil {
t.Fatal(err)
@@ -141,10 +201,21 @@ func TestExpandTemplates(t *testing.T) {
t.Errorf("expandTemplates = %q, want %q", out, want)
}
- // Invalid template returns original content unchanged.
+ // A file that doesn't parse as a template is used as-is: it probably holds
+ // a literal "{{" meant for some other tool.
orig := "value={{ .Missing "
- if got, _ := app.expandTemplates(orig); got != orig {
- t.Errorf("invalid template should return original, got %q", got)
+ got, err := app.expandTemplates(orig)
+ if err != nil {
+ t.Errorf("unparseable template should not fail: %v", err)
+ }
+ if got != orig {
+ t.Errorf("unparseable template should return original, got %q", got)
+ }
+
+ // A file that *is* a template but references an unknown variable is a real
+ // mistake and must not be silently ignored.
+ if _, err := app.expandTemplates("host={{ .Hostnam }}"); err == nil {
+ t.Error("expected an error for an unknown template variable")
}
}
diff --git a/src/types.go b/src/types.go
index 35a64be..b94998e 100644
--- a/src/types.go
+++ b/src/types.go
@@ -34,14 +34,29 @@ func (s LinkStatus) String() string {
}
}
+// LinkDefaults holds the link behaviour for a config section. The fields are
+// pointers so an omitted key can be told apart from an explicit false — without
+// that, writing `defaults.link` without `backup:` would silently disable
+// backups while `force: true` overwrites real files.
+type LinkDefaults struct {
+ Relink *bool `yaml:"relink,omitempty"`
+ Force *bool `yaml:"force,omitempty"`
+ Backup *bool `yaml:"backup,omitempty"`
+ RemoveDuplicates *bool `yaml:"remove_duplicates,omitempty"`
+}
+
+// linkOptions is the resolved form of LinkDefaults for one config section.
+type linkOptions struct {
+ force bool
+ relink bool
+ backup bool
+ removeDuplicates bool
+}
+
// Config represents a single configuration section
type Config struct {
Defaults *struct {
- Link struct {
- Relink bool `yaml:"relink"`
- Force bool `yaml:"force"`
- Backup bool `yaml:"backup"`
- } `yaml:"link"`
+ Link LinkDefaults `yaml:"link"`
} `yaml:"defaults,omitempty"`
Profile string `yaml:"profile,omitempty"`
Link map[string]string `yaml:"link,omitempty"`
diff --git a/src/unlink.go b/src/unlink.go
index 54a023e..a63f02f 100644
--- a/src/unlink.go
+++ b/src/unlink.go
@@ -4,14 +4,18 @@
package main
-import "os"
+import (
+ "maps"
+ "os"
+ "slices"
+)
// RunUnlink removes symlinks and optionally restores backups
func (app *App) RunUnlink(configs []Config, restore bool) error {
for _, config := range configs {
if len(config.Link) > 0 {
app.logger.heading("Removing symlinks...")
- for target := range config.Link {
+ for _, target := range slices.Sorted(maps.Keys(config.Link)) {
targetPath := expandPath(target, app.homeDir)
// Check if target exists and is a symlink
@@ -48,5 +52,5 @@ func (app *App) RunUnlink(configs []Config, restore bool) error {
}
app.logger.summary()
- return nil
+ return app.failureError()
}