From 81f719ac2ad9d9134c96d585fda721f4172cd330 Mon Sep 17 00:00:00 2001 From: Marcus Date: Sat, 11 Apr 2026 18:07:00 -0400 Subject: [PATCH] implement Phase 1 + 2: idempotent link, apply command, and install script - Rewrite cmd/link.go with safe idempotent logic: skips correct symlinks, backs up real files to .dotctl.bak, respects --overwrite and --no-backup - Add cmd/apply.go: new bootstrap command that clones or pulls a dotfiles repo then runs the idempotent link logic with a summary - Add --overwrite and --no-backup persistent flags to root command - Remove unstable cmd/sync.go - Fix cmd/init.go gitignore to not exclude dotctl/config.yml (required for apply to work on fresh machines) - Add install.sh: detects OS/arch, downloads binary from GitHub releases, optionally runs dotctl apply - Update README with Quick Start section covering both bootstrap methods - Rewrite test/link_test.go with 10 real-filesystem idempotency tests - Add test/apply_test.go with 5 tests covering dry-run, linking, and idempotency - Fix pre-existing TestInitCommand failure (missing MemMapFs setup) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 58 +++++++--- cmd/add.go | 5 +- cmd/apply.go | 99 ++++++++++++++++ cmd/init.go | 16 ++- cmd/link.go | 143 ++++++++++++++++++----- cmd/root.go | 4 + cmd/sync.go | 206 --------------------------------- dotctl-roadmap.md | 186 ++++++++++++++++++++++++++++++ install.sh | 85 ++++++++++++++ test/apply_test.go | 189 ++++++++++++++++++++++++++++++ test/init_test.go | 1 + test/link_test.go | 281 +++++++++++++++++++++++++++++++++++++++++---- 12 files changed, 994 insertions(+), 279 deletions(-) create mode 100644 cmd/apply.go delete mode 100644 cmd/sync.go create mode 100644 dotctl-roadmap.md create mode 100755 install.sh create mode 100644 test/apply_test.go diff --git a/README.md b/README.md index 0c09e2f..3cc659e 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,44 @@ # Dotctl dotfile management + ## About + Dotctl is a tool to help you easily manage your dotfiles and sync them across separate machines using git. It creates a `dotfiles` subdirectory in the user's `$HOME` and provides simple commands to add and symlink config files/directories to the central `dotfiles` directory. +## Quick Start (Bootstrap a fresh machine) + +**Option 1 — Shell script (no Go required):** + +```bash +curl -fsSL https://raw.githubusercontent.com/Marcusk19/dotctl/main/install.sh | bash -s -- https://github.com/your-user/dotfiles.git +``` + +**Option 2 — go install:** + +```bash +go install github.com/Marcusk19/dotctl@latest +dotctl apply https://github.com/your-user/dotfiles.git +``` + +Both methods clone your dotfiles repo to `~/dotfiles` and link all tracked configs automatically. ## Installation +### go install + +```sh +go install github.com/Marcusk19/dotctl@latest +``` + ### Build From Source + _Prerequisites_ - [go](https://go.dev/doc/install) -clone the repo and run script to build binary and copy it to your path +Clone the repo and run the script to build the binary and copy it to your path: ```sh git clone https://github.com/Marcusk19/dotctl.git @@ -26,23 +51,30 @@ make install ```bash # init sets up the config file and directory to hold all dotfiles dotctl init + # add a config directory for dotctl to track dotctl add ~/.config/nvim -# create symlinks + +# create symlinks (idempotent, safe to re-run) dotctl link + +# bootstrap dotfiles on a fresh machine +dotctl apply https://github.com/your-user/dotfiles.git ``` -### Syncing to git -_Warning: using the sync command can have some unexpected behavior, currently the recommendation -is to manually track the dotfiles with git_ -dotctl comes with a `sync` command that performs the following operations for the dotfiles directory: +### Commands -1. pulls changes from configured upstream git repo -2. commits and pushes any changes detected in the dotfile repo +| Command | Description | +|---------|-------------| +| `dotctl init` | Set up a new dotfiles repo and config | +| `dotctl add ` | Track a config file or directory | +| `dotctl link` | Create symlinks for all tracked configs | +| `dotctl apply ` | Clone a dotfiles repo and link everything (bootstrap) | -set the upstream repo using the `-r` flag or manually edit the config at `$HOME/dotfiles/dotctl/config.yaml` +### Flags -example usage: -``` -dotctl sync -r https://github.com/example/dotfiles.git -``` +| Flag | Description | +|------|-------------| +| `--dry-run` | Show what would be done without making changes | +| `--overwrite` | Overwrite existing files when linking | +| `--no-backup` | Skip creating backups of existing files | diff --git a/cmd/add.go b/cmd/add.go index aa0f642..47c646a 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -83,7 +83,10 @@ func runAddCommand(cmd *cobra.Command, args []string) { if !DryRun { // symlink the copied dotfile destination back to the config src fs.RemoveAll(configSrc) - linkPaths(dotfileDest, configSrc) + if err := os.Symlink(dotfileDest, configSrc); err != nil { + log.Fatalf("Cannot symlink %s → %s: %v\n", configSrc, dotfileDest, err) + } + fmt.Printf("%s linked to %s\n", configSrc, dotfileDest) } else { fmt.Println("Files were not symlinked") } diff --git a/cmd/apply.go b/cmd/apply.go new file mode 100644 index 0000000..a65729b --- /dev/null +++ b/cmd/apply.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + RootCmd.AddCommand(applyCommand) +} + +var applyCommand = &cobra.Command{ + Use: "apply ", + Short: "Clone a dotfiles repo and link all tracked configs", + Long: `apply clones a dotfiles repository (or pulls if it already exists), +reads the dotctl config, and creates symlinks for all tracked configs. + +Example: + dotctl apply https://github.com/user/dotfiles.git`, + Args: cobra.ExactArgs(1), + Run: runApplyCommand, +} + +func runApplyCommand(cmd *cobra.Command, args []string) { + repoURL := args[0] + dotfilePath := viper.GetString("dotfile-path") + // strip trailing slash for consistency + dotfilePath = filepath.Clean(dotfilePath) + + // Step 1: Clone or pull + stat, err := os.Stat(dotfilePath) + if os.IsNotExist(err) { + fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s into %s...\n", repoURL, dotfilePath) + if !DryRun { + _, err = git.PlainClone(dotfilePath, false, &git.CloneOptions{ + URL: repoURL, + Progress: cmd.OutOrStdout(), + }) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: clone failed: %v\n", err) + os.Exit(1) + } + } + } else if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot stat %s: %v\n", dotfilePath, err) + os.Exit(1) + } else if stat.IsDir() { + // Check if it's a git repo + repo, err := git.PlainOpen(dotfilePath) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s exists but is not a git repository\n", dotfilePath) + fmt.Fprintf(cmd.ErrOrStderr(), "Remove it or use a different --dotfile-path\n") + os.Exit(1) + } + fmt.Fprintf(cmd.OutOrStdout(), "Pulling latest changes in %s...\n", dotfilePath) + if !DryRun { + w, err := repo.Worktree() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot get worktree: %v\n", err) + os.Exit(1) + } + err = w.Pull(&git.PullOptions{RemoteName: "origin"}) + if err != nil && err != git.NoErrAlreadyUpToDate { + fmt.Fprintf(cmd.OutOrStdout(), "Warning: git pull failed (%v), continuing with local state\n", err) + } else if err == git.NoErrAlreadyUpToDate { + fmt.Fprintln(cmd.OutOrStdout(), "Already up to date.") + } + } + } + + // Step 2: Read config + configPath := filepath.Join(dotfilePath, "dotctl", "config.yml") + v := viper.New() + v.SetConfigFile(configPath) + if err := v.ReadInConfig(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot read config at %s: %v\n", configPath, err) + fmt.Fprintln(cmd.ErrOrStderr(), "Is this repo set up with dotctl? Run 'dotctl init' first.") + os.Exit(1) + } + links := v.GetStringMapString("links") + + if len(links) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No links configured in config.yml — nothing to link.") + return + } + + // Step 3: Run idempotent link + fmt.Fprintln(cmd.OutOrStdout(), "Linking dotfiles...") + result := LinkDotfiles(cmd.OutOrStdout(), dotfilePath, links, Overwrite, NoBackup, DryRun) + + // Step 4: Print summary + fmt.Fprintf(cmd.OutOrStdout(), "\nDone! %d linked, %d skipped, %d backed up\n", + result.Linked, result.Skipped, result.Backed) +} diff --git a/cmd/init.go b/cmd/init.go index 7b64b79..5041943 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -82,15 +82,13 @@ func runInitCommand(cmd *cobra.Command, args []string) { log.Fatal(err) } - gitignoreContent := []byte(` - # ignore dotctl config for individual installations - dotctl/ - - .DS_Store - *.swp - *.bak - *.tmp - `) + gitignoreContent := []byte(`# dotctl config (config.yml) should be committed so dotctl apply works on fresh machines + +.DS_Store +*.swp +*.bak +*.tmp +`) err := afero.WriteFile(fs, filepath.Join(DotfilePath, ".gitignore"), gitignoreContent, 0644) diff --git a/cmd/link.go b/cmd/link.go index 25ed8b6..6e9a3c2 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -2,14 +2,22 @@ package cmd import ( "fmt" + "io" "log" + "os" "path/filepath" - "github.com/spf13/afero" "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) } @@ -18,53 +26,130 @@ 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", // TODO add longer description here + Long: "runs through all configs in the dotctl config file and links them to configured symlinks", } func runLinkCommand(cmd *cobra.Command, args []string) { - fs := FileSystem - fmt.Println("Symlinking dotfiles...") - dotfileRoot := viper.Get("dotfile-path").(string) - + fmt.Fprintln(cmd.OutOrStdout(), "Symlinking dotfiles...") + dotfileRoot := viper.GetString("dotfile-path") links := viper.GetStringMapString("links") - for configName, configPath := range 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 configPath == "" { - fmt.Fprintf(cmd.OutOrStdout(), "Warning: could not find config for %s\n", configName) + if targetPath == "" { + fmt.Fprintf(out, " Warning: no target path configured for %s, skipping\n", configName) + result.Skipped++ + continue } - // destination needs to be removed before symlink - if DryRun { - log.Printf("Existing directory %s will be removed\n", configPath) + // 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) + } + } - } else { - fs.RemoveAll(configPath) + // 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 } - testing := viper.Get("testing") + if err != nil { + log.Printf("Warning: cannot stat %s: %v\n", targetPath, err) + result.Skipped++ + continue + } - if DryRun { - log.Printf("Will link %s -> %s\n", configPath, dotPath) - } else { - if testing == true { - fmt.Fprintf(cmd.OutOrStdout(), "%s,%s", configPath, dotPath) + // 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 { - linkPaths(dotPath, configPath) + fmt.Fprintf(out, " %s [symlink to wrong path, skipping (use --overwrite to force)]\n", targetPath) + result.Skipped++ } + continue } - } -} -func linkPaths(dotPath, configPath string) { - err := afero.OsFs.SymlinkIfPossible(afero.OsFs{}, dotPath, configPath) - if err != nil { - log.Fatalf("Cannot symlink %s: %s\n", configPath, err.Error()) - } else { - fmt.Printf("%s linked to %s\n", configPath, dotPath) + // 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 } diff --git a/cmd/root.go b/cmd/root.go index 70a1f18..6a49e57 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,6 +33,8 @@ func Execute() { var DotfilePath string var ConfigPath string var DryRun bool +var Overwrite bool +var NoBackup bool var FileSystem afero.Fs @@ -56,6 +58,8 @@ func init() { "Path pointing to config directory", ) RootCmd.PersistentFlags().BoolVarP(&DryRun, "dry-run", "d", false, "Only output which symlinks will be created") + RootCmd.PersistentFlags().BoolVar(&Overwrite, "overwrite", false, "Overwrite existing files instead of backing up") + RootCmd.PersistentFlags().BoolVar(&NoBackup, "no-backup", false, "Skip backups, skip conflicts instead") viper.BindPFlag("dotfile-path", RootCmd.PersistentFlags().Lookup("dotfile-path")) viper.BindPFlag("config-path", RootCmd.PersistentFlags().Lookup("config-path")) diff --git a/cmd/sync.go b/cmd/sync.go deleted file mode 100644 index 7cfefc0..0000000 --- a/cmd/sync.go +++ /dev/null @@ -1,206 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "log" - "path" - "time" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/manifoldco/promptui" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var remoteRepository string - -func init() { - RootCmd.AddCommand(syncCommand) - syncCommand.Flags().StringVarP( - &remoteRepository, - "remote", - "r", - "", - "URL of remote repository", - ) - - viper.BindPFlag("dotctl-origin", syncCommand.Flags().Lookup("remote")) -} - -var syncCommand = &cobra.Command{ - Use: "sync", - Short: "Sync dotfiles with git", - Long: "TODO: add longer description", - Run: runSyncCommand, -} - -func validateInput(input string) error { - if input == "" { - return errors.New("Missing input") - } - - return nil -} - -func gitAddFiles(worktree *git.Worktree, fs afero.Fs) error { - dotfilepath := viper.GetString("dotfile-path") - entries, err := afero.ReadDir(fs, dotfilepath) - if err != nil { - return err - } - for _, entry := range entries { - if entry.Name() == "dotctl" { - continue - } - _, err = worktree.Add(entry.Name()) - if err != nil { - return err - } - } - return nil -} - -func runSyncCommand(cmd *cobra.Command, args []string) { - origin := viper.GetString("dotctl-origin") - if origin == "" { - fmt.Fprintln(cmd.OutOrStdout(), "No remote repository found") - return - } - - dotfilepath := viper.GetString("dotfile-path") - r, err := git.PlainOpen(dotfilepath) - CheckIfError(err) - - // check remotes and if origin does not exist - // we need to create it - list, err := r.Remotes() - CheckIfError(err) - - if len(list) == 0 { - r.CreateRemote(&config.RemoteConfig{ - Name: "origin", - URLs: []string{origin}, - }) - } - - w, err := r.Worktree() - CheckIfError(err) - - username := promptui.Prompt{ - Label: "username", - Validate: validateInput, - } - - password := promptui.Prompt{ - Label: "password", - Validate: validateInput, - HideEntered: true, - Mask: '*', - } - - usernameVal, err := username.Run() - CheckIfError(err) - - passwordVal, err := password.Run() - CheckIfError(err) - - fmt.Println("Pulling from remote") - - err = w.Pull(&git.PullOptions{ - RemoteName: "origin", - Auth: &http.BasicAuth{ - Username: usernameVal, - Password: passwordVal, - }, - }) - - if err != nil { - fmt.Println(err) - } else { - fmt.Fprintf(cmd.OutOrStdout(), "successfully pulled from %s", origin) - } - - status, err := w.Status() - if err != nil { - log.Fatalln("Error getting status", err) - } - - if !status.IsClean() { - fmt.Println("Changes detected, do you want to push them?") - confirm := promptui.Prompt{ - Label: "commit and push changes", - IsConfirm: true, - } - - _, err := confirm.Run() - if err != nil { - fmt.Println("Will not push changes") - return - } - - fmt.Println("Pushing changes...") - - err = gitAddFiles(w, FileSystem) - if err != nil { - log.Fatalf("Could not add files: %s\n", err) - return - } - - commitMessage := "backup " + time.Now().String() - - commit, err := w.Commit(commitMessage, &git.CommitOptions{ - Author: &object.Signature{ - Name: "dotctl CLI", - Email: "example@example.com", - When: time.Now(), - }, - }) - - if err != nil { - log.Fatal(err.Error()) - } - - obj, err := r.CommitObject(commit) - - if err != nil { - log.Fatalf("Cannot commit: %s", err) - } - - fmt.Println(obj) - - err = r.Push(&git.PushOptions{ - RemoteName: "origin", - Auth: &http.BasicAuth{ - Username: usernameVal, - Password: passwordVal, - }, - }) - CheckIfError(err) - } - - // a pull deletes the dotctl config from the filesystem, need to recreate it - rewriteConfig() -} - -func rewriteConfig() { - fs := UseFilesystem() - err := fs.MkdirAll(path.Join(DotfilePath, "dotctl"), 0755) - if err != nil { - log.Fatalf("Unable to create dotfile structure: %s", error.Error(err)) - } - - _, err = fs.Create(path.Join(DotfilePath, "dotctl/config")) - if err != nil { - panic(fmt.Errorf("Unable to create config file %w", err)) - } - - err = viper.WriteConfig() - if err != nil { - fmt.Println("Error: could not write config: ", err) - } -} diff --git a/dotctl-roadmap.md b/dotctl-roadmap.md new file mode 100644 index 0000000..6ebb44a --- /dev/null +++ b/dotctl-roadmap.md @@ -0,0 +1,186 @@ +# dotctl — Fresh Machine Bootstrap Roadmap + +## Goal + +A single command on a fresh machine should clone a dotfiles repo and fully apply it: + +```bash +dotctl apply https://github.com/user/dotfiles.git +``` + +No manual git clone. No rebuilding from source. No multi-step dance. + +--- + +## Current State Summary + +| Capability | Status | +|---|---| +| `init` / `add` / `link` core loop | ✅ Working | +| Symlink creation | ✅ Working | +| Config stored at `~/dotfiles/dotctl/config.yaml` | ✅ Working | +| `sync` command (pull + push) | ⚠️ Unstable — README warns against it | +| Bootstrap from existing repo | ❌ Missing | +| Conflict detection on `link` | ❌ Missing | +| Idempotent `link` (safe to re-run) | ❌ Missing | +| One-liner install script | ❌ Missing | +| Templating / per-machine config | ❌ Not in scope (yet) | +| Secret management | ❌ Not in scope (yet) | + +--- + +## Phase 1 — Fix the Foundation (prerequisite for everything) + +These are bugs/gaps that block safe automation. + +### 1.1 Make `link` idempotent + +**Problem:** Running `link` a second time likely fails or clobbers without warning if a target path already exists. + +**Fix:** Before creating each symlink, check the target: +- If missing → create symlink (happy path) +- If already a symlink pointing to the correct source → skip, log "already linked" +- If already a symlink pointing somewhere else → warn, skip unless `--overwrite` flag passed +- If a real file/directory exists → back it up to `.dotctl.bak`, then link (or skip with `--no-backup`) + +**Files to touch:** `cmd/link.go` + +**Acceptance criteria:** Running `dotctl link` twice on a clean setup produces no errors and no duplicate symlinks. + +--- + +### 1.2 Fix or remove `sync` + +**Problem:** The README actively warns against `sync`. It should either be fixed or removed so it doesn't cause data loss for new users. + +**Decision to make (pick one):** +- **Option A — Fix it:** Proper git pull → detect conflicts → commit changed files → push. Use `go-git` for in-process git operations instead of shelling out. +- **Option B — Remove it:** Drop `sync`, document that users should manage the `~/dotfiles` directory as a normal git repo. Add a note in README pointing to how to do `git pull` and then `dotctl link`. + +Recommendation: **Option B** now, **Option A** later in Phase 3. It's safer to remove a broken command than ship a half-working one. + +--- + +### 1.3 Validate that config round-trips cleanly + +**Problem:** The tracked file list lives in `~/dotfiles/dotctl/config.yaml`. On a fresh machine, this file needs to already exist in the cloned repo for bootstrap to work. Need to confirm this file is actually committed and not gitignored. + +**Fix:** Add a note in `.gitignore` explicitly *not* ignoring `dotctl/config.yaml`. Add a check in `init` that warns if the config file would be gitignored. + +--- + +## Phase 2 — The Bootstrap Command (core deliverable) + +### 2.1 `dotctl apply ` + +This is the main feature. It should: + +1. Check if `~/dotfiles` already exists + - If yes and it's a git repo → `git pull` (or prompt user) + - If yes but not a git repo → error with clear message + - If no → `git clone ~/dotfiles` +2. Read `~/dotfiles/dotctl/config.yaml` (fail clearly if not found — tells user their repo isn't set up for dotctl) +3. Run the equivalent of `dotctl link` with idempotent behavior from Phase 1.1 +4. Print a summary: N linked, M skipped, K backed up + +**Flags:** +- `--overwrite` — overwrite existing files instead of backing up +- `--dry-run` — print what would happen without touching the filesystem +- `--no-backup` — skip backups, just skip conflicts + +**Files to touch:** New `cmd/apply.go` + +**Acceptance criteria:** On a machine with nothing but Go installed and git in PATH, running `dotctl apply https://github.com/user/dotfiles.git` produces a fully linked dotfiles setup. + +--- + +### 2.2 One-liner install + apply script + +The bootstrap UX needs to work before `dotctl` itself is installed. Options: + +**Option A — `go install` (simplest):** +```bash +go install github.com/Marcusk19/dotctl@latest && dotctl apply https://github.com/user/dotfiles.git +``` +Requires Go on the machine. Works great for developer setups. + +**Option B — Shell script + prebuilt binary:** +```bash +curl -fsSL https://raw.githubusercontent.com/Marcusk19/dotctl/main/install.sh | bash -s -- https://github.com/user/dotfiles.git +``` +The script: detects OS/arch → downloads the correct binary from GitHub releases → runs `dotctl apply `. No Go required. + +Recommendation: **ship both**. `go install` for devs, the curl script for fresh machines where Go may not be present. goreleaser is already set up, so binary releases exist — just need the install script. + +**Files to add:** `install.sh` in repo root + +--- + +## Phase 3 — Quality of Life (after core bootstrap works) + +### 3.1 Fix `sync` properly + +With `go-git` as a dependency (or shelling to git with proper error handling): +- `dotctl sync` → pull remote changes, re-run link, push any local changes +- Detect and surface merge conflicts clearly instead of silently failing + +--- + +### 3.2 `dotctl status` + +Shows current state of all tracked files: +``` +nvim → ~/.config/nvim [linked ✓] +zshrc → ~/.zshrc [linked ✓] +kitty → ~/.config/kitty [CONFLICT — real file exists] +tmux.conf → ~/.tmux.conf [broken symlink] +``` + +--- + +### 3.3 `dotctl remove ` + +Removes a tracked file from the manifest and optionally replaces the symlink with the real file. + +--- + +### 3.4 `dotctl list` + +Prints all tracked entries from config — useful for auditing before running `apply` on a new machine. + +--- + +## Phase 4 — Advanced (optional, post-stabilization) + +| Feature | Notes | +|---|---| +| Per-machine profiles | Tag entries in config with `profiles: [work, home]`; pass `--profile` to `apply` | +| Templating | Go `text/template` over files with a `.tmpl` extension — render before linking | +| Secret redaction | Warn when a tracked file contains patterns that look like secrets (API keys, tokens) | +| XDG-aware paths | Auto-resolve `$XDG_CONFIG_HOME` instead of hardcoding `~/.config` | + +--- + +## Implementation Order + +``` +Phase 1.1 → idempotent link (1–2 days) +Phase 1.2 → remove/fix sync (0.5 days) +Phase 1.3 → config gitignore audit (0.5 days) +Phase 2.1 → dotctl apply command (2–3 days) +Phase 2.2 → install.sh script (1 day) +Phase 3.x → status, list, remove (2–3 days) +Phase 4.x → profiles, templating (future) +``` + +--- + +## Definition of Done (for "point at a repo and go") + +- [ ] `install.sh` downloads the correct binary for current OS/arch +- [ ] `dotctl apply ` clones repo if not present, reads config, links all tracked files +- [ ] Running `apply` twice produces no errors +- [ ] Existing files are backed up, not silently overwritten +- [ ] `--dry-run` works and shows exactly what would happen +- [ ] README updated with the new one-liner bootstrap flow +- [ ] At least one end-to-end test that: clones a test dotfiles repo → runs apply → asserts symlinks exist diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d42350b --- /dev/null +++ b/install.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="Marcusk19/dotctl" +BINARY="dotctl" + +# Detect OS +OS=$(uname -s) +case "$OS" in + Linux) OS="Linux" ;; + Darwin) OS="Darwin" ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="x86_64" ;; + aarch64 | arm64) ARCH="arm64" ;; + i386 | i686) ARCH="i386" ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +# Determine archive name and format +if [ "$OS" = "Windows" ]; then + ARCHIVE="${BINARY}_${OS}_${ARCH}.zip" +else + ARCHIVE="${BINARY}_${OS}_${ARCH}.tar.gz" +fi + +DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ARCHIVE}" + +echo "Downloading dotctl for ${OS}/${ARCH}..." +echo "URL: ${DOWNLOAD_URL}" + +# Download to a temp dir +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ARCHIVE" + +# Extract +cd "$TMP_DIR" +if [[ "$ARCHIVE" == *.tar.gz ]]; then + tar xzf "$ARCHIVE" +else + unzip -q "$ARCHIVE" +fi + +# Determine install location +if [ -w "/usr/local/bin" ]; then + INSTALL_DIR="/usr/local/bin" +else + INSTALL_DIR="$HOME/.local/bin" + mkdir -p "$INSTALL_DIR" +fi + +# Install binary +mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY" +chmod +x "$INSTALL_DIR/$BINARY" + +echo "" +echo "dotctl installed to $INSTALL_DIR/$BINARY" + +# PATH hint if using ~/.local/bin +if [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then + if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then + echo "" + echo "NOTE: Add ~/.local/bin to your PATH if not already done:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + fi +fi + +# Run apply if a repo URL was provided +if [ -n "${1:-}" ]; then + echo "" + echo "Running: dotctl apply $1" + "$INSTALL_DIR/$BINARY" apply "$1" +fi diff --git a/test/apply_test.go b/test/apply_test.go new file mode 100644 index 0000000..dacc0b4 --- /dev/null +++ b/test/apply_test.go @@ -0,0 +1,189 @@ +package test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Marcusk19/dotctl/cmd" + gogit "github.com/go-git/go-git/v5" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initLocalGitRepo creates a temp directory and initializes it as a git repo. +func initLocalGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + _, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + return dir +} + +// writeApplyConfig writes a dotctl/config.yml with the given links map. +func writeApplyConfig(t *testing.T, dir string, links map[string]string) { + t.Helper() + configDir := filepath.Join(dir, "dotctl") + require.NoError(t, os.MkdirAll(configDir, 0755)) + + content := "links:\n" + for name, path := range links { + content += fmt.Sprintf(" %s: %s\n", name, path) + } + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yml"), []byte(content), 0644)) +} + +// resetGlobalState resets the global cmd flags to defaults. +func resetGlobalState() { + cmd.DryRun = false + cmd.Overwrite = false + cmd.NoBackup = false +} + +func TestApplyCommand_ExistingRepo_EmptyLinks(t *testing.T) { + defer resetGlobalState() + + repoDir := initLocalGitRepo(t) + writeApplyConfig(t, repoDir, map[string]string{}) + + viper.Set("dotfile-path", repoDir) + defer viper.Set("dotfile-path", "") + + rootCmd := cmd.RootCmd + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"}) + + rootCmd.Execute() + + output := buf.String() + assert.Contains(t, output, "Pulling latest changes") + assert.Contains(t, output, "No links configured") +} + +func TestApplyCommand_ExistingRepo_WithLinks_DryRun(t *testing.T) { + defer resetGlobalState() + + repoDir := initLocalGitRepo(t) + targetRoot := t.TempDir() + + nvimTarget := filepath.Join(targetRoot, ".config", "nvim") + + writeApplyConfig(t, repoDir, map[string]string{ + "nvim": nvimTarget, + }) + + // Create source directory in the repo + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755)) + + viper.Set("dotfile-path", repoDir) + defer viper.Set("dotfile-path", "") + + cmd.DryRun = true + + rootCmd := cmd.RootCmd + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"}) + + rootCmd.Execute() + + output := buf.String() + assert.Contains(t, output, "Linking dotfiles...") + assert.Contains(t, output, "would link") + + // Verify no actual symlink was created + _, err := os.Lstat(nvimTarget) + assert.True(t, os.IsNotExist(err), "symlink should NOT be created in dry-run mode") +} + +func TestApplyCommand_ExistingRepo_WithLinks(t *testing.T) { + defer resetGlobalState() + + repoDir := initLocalGitRepo(t) + targetRoot := t.TempDir() + + nvimTarget := filepath.Join(targetRoot, ".config", "nvim") + + writeApplyConfig(t, repoDir, map[string]string{ + "nvim": nvimTarget, + }) + + // Create source directory in the repo + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755)) + + viper.Set("dotfile-path", repoDir) + defer viper.Set("dotfile-path", "") + + rootCmd := cmd.RootCmd + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"}) + + rootCmd.Execute() + + output := buf.String() + assert.Contains(t, output, "Linking dotfiles...") + assert.Contains(t, output, "1 linked, 0 skipped, 0 backed up") + + // Verify symlink was actually created + linkTarget, err := os.Readlink(nvimTarget) + require.NoError(t, err, "symlink should exist at target") + assert.Equal(t, filepath.Join(repoDir, "nvim"), linkTarget) +} + +func TestApplyCommand_NoArgs(t *testing.T) { + defer resetGlobalState() + + rootCmd := cmd.RootCmd + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"apply"}) + + err := rootCmd.Execute() + + assert.Error(t, err, "apply with no args should return an error") +} + +func TestApplyCommand_ExistingRepo_MultipleLinks_DryRun(t *testing.T) { + defer resetGlobalState() + + repoDir := initLocalGitRepo(t) + targetRoot := t.TempDir() + + nvimTarget := filepath.Join(targetRoot, ".config", "nvim") + zshTarget := filepath.Join(targetRoot, ".zshrc") + + writeApplyConfig(t, repoDir, map[string]string{ + "nvim": nvimTarget, + "zsh": zshTarget, + }) + + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "zsh"), 0755)) + + viper.Set("dotfile-path", repoDir) + defer viper.Set("dotfile-path", "") + + cmd.DryRun = true + + rootCmd := cmd.RootCmd + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"}) + + rootCmd.Execute() + + output := buf.String() + assert.Contains(t, output, "Linking dotfiles...") + assert.Contains(t, output, "would link") + assert.Contains(t, output, "Done!") +} diff --git a/test/init_test.go b/test/init_test.go index 1e241b3..fc10914 100644 --- a/test/init_test.go +++ b/test/init_test.go @@ -13,6 +13,7 @@ import ( func TestInitCommand(t *testing.T) { viper.Set("testing", true) + cmd.FileSystem = afero.NewMemMapFs() fs := cmd.FileSystem diff --git a/test/link_test.go b/test/link_test.go index 5c28b6d..0214b24 100644 --- a/test/link_test.go +++ b/test/link_test.go @@ -2,42 +2,281 @@ package test import ( "bytes" - "fmt" "os" "path/filepath" "testing" "github.com/Marcusk19/dotctl/cmd" - "github.com/spf13/afero" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestLinkCommand(t *testing.T) { - viper.Set("testing", true) - cmd.FileSystem = afero.NewMemMapFs() - fs := cmd.FileSystem - homedir := os.Getenv("HOME") +func TestLinkDotfiles_MissingTarget(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + // Create source dir in dotfileRoot + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + // Target does not exist yet + targetPath := filepath.Join(targetRoot, ".config", "nvim") + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) + + assert.Equal(t, 1, result.Linked, "should have linked 1") + assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Backed) + + // Verify symlink was actually created and points to the right place + linkTarget, err := os.Readlink(targetPath) + require.NoError(t, err, "symlink should exist at targetPath") + assert.Equal(t, dotPath, linkTarget, "symlink should point to dotPath") +} + +func TestLinkDotfiles_AlreadyLinked(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + targetPath := filepath.Join(targetRoot, ".config", "nvim") + require.NoError(t, os.MkdirAll(filepath.Dir(targetPath), 0755)) + + // Pre-create correct symlink + require.NoError(t, os.Symlink(dotPath, targetPath)) + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) + + assert.Equal(t, 0, result.Linked) + assert.Equal(t, 1, result.Skipped, "should have skipped 1 (already linked)") + assert.Equal(t, 0, result.Backed) + + // Symlink should still point to dotPath + linkTarget, err := os.Readlink(targetPath) + require.NoError(t, err) + assert.Equal(t, dotPath, linkTarget) + + assert.Contains(t, buf.String(), "already linked") +} + +func TestLinkDotfiles_WrongSymlink_NoOverwrite(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + wrongPath := filepath.Join(t.TempDir(), "wrong") + require.NoError(t, os.MkdirAll(wrongPath, 0755)) + + targetPath := filepath.Join(targetRoot, ".config", "nvim") + require.NoError(t, os.MkdirAll(filepath.Dir(targetPath), 0755)) + + // Pre-create symlink pointing to the wrong place + require.NoError(t, os.Symlink(wrongPath, targetPath)) + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) + + assert.Equal(t, 0, result.Linked) + assert.Equal(t, 1, result.Skipped, "should have skipped 1 (wrong symlink, no overwrite)") + assert.Equal(t, 0, result.Backed) + + // Symlink should still point to the wrong path (unchanged) + linkTarget, err := os.Readlink(targetPath) + require.NoError(t, err) + assert.Equal(t, wrongPath, linkTarget) +} + +func TestLinkDotfiles_WrongSymlink_Overwrite(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + wrongPath := filepath.Join(t.TempDir(), "wrong") + require.NoError(t, os.MkdirAll(wrongPath, 0755)) + + targetPath := filepath.Join(targetRoot, ".config", "nvim") + require.NoError(t, os.MkdirAll(filepath.Dir(targetPath), 0755)) + + // Pre-create symlink pointing to the wrong place + require.NoError(t, os.Symlink(wrongPath, targetPath)) + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, true, false, false) + + assert.Equal(t, 1, result.Linked, "should have relinked 1") + assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Backed) + + // Symlink should now point to dotPath + linkTarget, err := os.Readlink(targetPath) + require.NoError(t, err) + assert.Equal(t, dotPath, linkTarget, "symlink should now point to dotPath") +} + +func TestLinkDotfiles_RealFile_WithBackup(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + targetPath := filepath.Join(targetRoot, ".config", "nvim") + require.NoError(t, os.MkdirAll(filepath.Dir(targetPath), 0755)) + + // Create a real file at the target path + require.NoError(t, os.WriteFile(targetPath, []byte("original content"), 0644)) + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) + + assert.Equal(t, 0, result.Linked) + assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 1, result.Backed, "should have backed up 1") + + // Backup file should exist with original content + backupPath := targetPath + ".dotctl.bak" + backupContent, err := os.ReadFile(backupPath) + require.NoError(t, err, "backup file should exist") + assert.Equal(t, "original content", string(backupContent)) + + // Target should now be a symlink to dotPath + linkTarget, err := os.Readlink(targetPath) + require.NoError(t, err, "target should be a symlink now") + assert.Equal(t, dotPath, linkTarget) +} + +func TestLinkDotfiles_RealFile_NoBackup(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + targetPath := filepath.Join(targetRoot, ".config", "nvim") + require.NoError(t, os.MkdirAll(filepath.Dir(targetPath), 0755)) + + // Create a real file at the target path + require.NoError(t, os.WriteFile(targetPath, []byte("original content"), 0644)) + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, true, false) + + assert.Equal(t, 0, result.Linked) + assert.Equal(t, 1, result.Skipped, "should have skipped 1 (noBackup)") + assert.Equal(t, 0, result.Backed) + + // Original file should be untouched + content, err := os.ReadFile(targetPath) + require.NoError(t, err) + assert.Equal(t, "original content", string(content)) + + // No backup file should exist + backupPath := targetPath + ".dotctl.bak" + _, err = os.Stat(backupPath) + assert.True(t, os.IsNotExist(err), "backup file should not exist") +} + +func TestLinkDotfiles_DryRun(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + dotPath := filepath.Join(dotfileRoot, "nvim") + require.NoError(t, os.MkdirAll(dotPath, 0755)) + + targetPath := filepath.Join(targetRoot, ".config", "nvim") + // Do NOT create parent dirs - dryRun should not create them either + + links := map[string]string{"nvim": targetPath} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, true) + + assert.Equal(t, 1, result.Linked, "should count 1 linked even in dry run") + assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Backed) + + // No symlink should actually be created + _, err := os.Lstat(targetPath) + assert.True(t, os.IsNotExist(err), "symlink should NOT be created in dry run") + + assert.Contains(t, buf.String(), "would link") +} + +func TestLinkDotfiles_SkipsGitAndDotctl(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() - fs.MkdirAll(filepath.Join(homedir, "dotfiles/dotctl"), 0755) links := map[string]string{ - "someconfig": filepath.Join(homedir, ".config/someconfig"), + ".git": filepath.Join(targetRoot, ".git"), + "dotctl": filepath.Join(targetRoot, "dotctl"), } - viper.Set("links", links) + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) - dotctl := cmd.RootCmd - actual := new(bytes.Buffer) + assert.Equal(t, 0, result.Linked) + assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Backed) + + // Nothing should be created + _, err := os.Lstat(filepath.Join(targetRoot, ".git")) + assert.True(t, os.IsNotExist(err)) + _, err = os.Lstat(filepath.Join(targetRoot, "dotctl")) + assert.True(t, os.IsNotExist(err)) +} - dotctl.SetOut(actual) - dotctl.SetErr(actual) - dotctl.SetArgs([]string{"link"}) +func TestLinkDotfiles_EmptyTargetPath(t *testing.T) { + dotfileRoot := t.TempDir() - dotctl.Execute() + links := map[string]string{"nvim": ""} + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) + + assert.Equal(t, 0, result.Linked) + assert.Equal(t, 1, result.Skipped, "empty target should be skipped") + assert.Equal(t, 0, result.Backed) +} + +func TestLinkDotfiles_MultipleEntries(t *testing.T) { + dotfileRoot := t.TempDir() + targetRoot := t.TempDir() + + // Create two source dirs + require.NoError(t, os.MkdirAll(filepath.Join(dotfileRoot, "nvim"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dotfileRoot, "zsh"), 0755)) + + nvimTarget := filepath.Join(targetRoot, ".config", "nvim") + zshTarget := filepath.Join(targetRoot, ".zshrc") + + links := map[string]string{ + "nvim": nvimTarget, + "zsh": zshTarget, + } + var buf bytes.Buffer + result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false) - someconfig := filepath.Join(homedir, ".config/someconfig/") - somedot := filepath.Join(homedir, "dotfiles/someconfig/") + assert.Equal(t, 2, result.Linked, "should have linked 2 entries") + assert.Equal(t, 0, result.Skipped) + assert.Equal(t, 0, result.Backed) - expected := fmt.Sprintf("%s,%s", someconfig, somedot) + // Verify both symlinks + linkTarget, err := os.Readlink(nvimTarget) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dotfileRoot, "nvim"), linkTarget) - assert.Equal(t, expected, actual.String(), "actual differs from expected") + linkTarget, err = os.Readlink(zshTarget) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dotfileRoot, "zsh"), linkTarget) }