Compare commits

..

No commits in common. '2ed8af0f5ee2897c96887531b7fdf776f3df1cc2' and '084be9f565b42b99726290a0f4006b5a45c105f6' have entirely different histories.

@ -1,27 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.22.x'
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

@ -40,20 +40,7 @@ archives:
changelog: changelog:
sort: asc sort: asc
use: github
groups:
- title: 'New Features'
regexp: '^.*feat(\(.+\))??!?:.+$'
order: 0
- title: 'Bug Fixes'
regexp: '^.*fix(\(.+\))??!?:.+$'
order: 1
- title: 'Other Changes'
order: 999
filters: filters:
exclude: exclude:
- "^docs:" - "^docs:"
- "^test:" - "^test:"
- "^chore:"
- "Merge pull request"
- "Merge branch"

@ -1,44 +1,19 @@
# Dotctl # Dotctl
dotfile management dotfile management
## About ## About
Dotctl is a tool to help you easily manage your dotfiles and sync them across separate machines using 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 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. 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 ## Installation
### go install
```sh
go install github.com/Marcusk19/dotctl@latest
```
### Build From Source ### Build From Source
_Prerequisites_ _Prerequisites_
- [go](https://go.dev/doc/install) - [go](https://go.dev/doc/install)
Clone the repo and run the script to build the binary and copy it to your path: clone the repo and run script to build binary and copy it to your path
```sh ```sh
git clone https://github.com/Marcusk19/dotctl.git git clone https://github.com/Marcusk19/dotctl.git
@ -51,30 +26,23 @@ make install
```bash ```bash
# init sets up the config file and directory to hold all dotfiles # init sets up the config file and directory to hold all dotfiles
dotctl init dotctl init
# add a config directory for dotctl to track # add a config directory for dotctl to track
dotctl add ~/.config/nvim dotctl add ~/.config/nvim
# create symlinks
# create symlinks (idempotent, safe to re-run)
dotctl link 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_
### Commands dotctl comes with a `sync` command that performs the following operations for the dotfiles directory:
| Command | Description | 1. pulls changes from configured upstream git repo
|---------|-------------| 2. commits and pushes any changes detected in the dotfile repo
| `dotctl init` | Set up a new dotfiles repo and config |
| `dotctl add <path>` | Track a config file or directory |
| `dotctl link` | Create symlinks for all tracked configs |
| `dotctl apply <repo-url>` | Clone a dotfiles repo and link everything (bootstrap) |
### Flags set the upstream repo using the `-r` flag or manually edit the config at `$HOME/dotfiles/dotctl/config.yaml`
| Flag | Description | example usage:
|------|-------------| ```
| `--dry-run` | Show what would be done without making changes | dotctl sync -r https://github.com/example/dotfiles.git
| `--overwrite` | Overwrite existing files when linking | ```
| `--no-backup` | Skip creating backups of existing files |

@ -83,10 +83,7 @@ func runAddCommand(cmd *cobra.Command, args []string) {
if !DryRun { if !DryRun {
// symlink the copied dotfile destination back to the config src // symlink the copied dotfile destination back to the config src
fs.RemoveAll(configSrc) fs.RemoveAll(configSrc)
if err := os.Symlink(dotfileDest, configSrc); err != nil { linkPaths(dotfileDest, configSrc)
log.Fatalf("Cannot symlink %s → %s: %v\n", configSrc, dotfileDest, err)
}
fmt.Printf("%s linked to %s\n", configSrc, dotfileDest)
} else { } else {
fmt.Println("Files were not symlinked") fmt.Println("Files were not symlinked")
} }

@ -1,120 +0,0 @@
package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func init() {
RootCmd.AddCommand(bootstrapCommand)
}
var bootstrapCommand = &cobra.Command{
Use: "bootstrap <repo-url>",
Short: "Clone a dotfiles repo and auto-discover configs to link (no config.yml required)",
Long: `bootstrap clones a dotfiles repository (or pulls if it already exists),
auto-discovers all top-level directories and files (excluding .git and dotctl),
symlinks each one to ~/.config/<name>, and writes a config.yml so that
subsequent dotctl commands (status, rm, link) work normally.
Use this when your dotfiles repo mirrors ~/.config/ directly and does not
have a dotctl config.yml yet.
Example:
dotctl bootstrap https://github.com/user/dotfiles.git`,
Args: cobra.ExactArgs(1),
Run: runBootstrapCommand,
}
func runBootstrapCommand(cmd *cobra.Command, args []string) {
repoURL := args[0]
dotfilePath := filepath.Clean(viper.GetString("dotfile-path"))
configPath := filepath.Clean(viper.GetString("config-path"))
// Step 1: Clone or pull
if err := cloneOrPull(cmd, repoURL, dotfilePath, DryRun); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err)
os.Exit(1)
}
// Step 2: Discover links from repo structure
links, err := DiscoverLinks(dotfilePath, configPath)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot discover links: %v\n", err)
os.Exit(1)
}
if len(links) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No configs discovered in repo — nothing to link.")
return
}
fmt.Fprintf(cmd.OutOrStdout(), "Discovered %d config(s) to link:\n", len(links))
for name, target := range links {
fmt.Fprintf(cmd.OutOrStdout(), " %s → %s\n", name, target)
}
fmt.Fprintln(cmd.OutOrStdout())
// Step 3: Link dotfiles
fmt.Fprintln(cmd.OutOrStdout(), "Linking dotfiles...")
result := LinkDotfiles(cmd.OutOrStdout(), dotfilePath, links, Overwrite, NoBackup, DryRun)
// Step 4: Write config.yml so future dotctl commands work
if err := WriteBootstrapConfig(cmd.OutOrStdout(), dotfilePath, links, DryRun); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not write config.yml: %v\n", err)
}
// Step 5: Summary
fmt.Fprintf(cmd.OutOrStdout(), "\nDone! %d linked, %d skipped, %d backed up\n",
result.Linked, result.Skipped, result.Backed)
}
// DiscoverLinks scans dotfileRoot for top-level entries, skipping .git and dotctl,
// and returns a links map of name → filepath.Join(configRoot, name).
func DiscoverLinks(dotfileRoot, configRoot string) (map[string]string, error) {
entries, err := os.ReadDir(dotfileRoot)
if err != nil {
return nil, fmt.Errorf("cannot read directory %s: %w", dotfileRoot, err)
}
links := make(map[string]string)
for _, entry := range entries {
name := entry.Name()
if name == ".git" || name == "dotctl" {
continue
}
links[name] = filepath.Join(configRoot, name)
}
return links, nil
}
// WriteBootstrapConfig writes a config.yml containing the discovered links
// to dotfileRoot/dotctl/config.yml using a fresh viper instance.
// If dryRun is true, it only prints what would be written.
func WriteBootstrapConfig(out io.Writer, dotfileRoot string, links map[string]string, dryRun bool) error {
configDir := filepath.Join(dotfileRoot, "dotctl")
configFile := filepath.Join(configDir, "config.yml")
if dryRun {
fmt.Fprintf(out, "[dry-run] Would write config.yml with %d entries to %s\n", len(links), configFile)
return nil
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("cannot create dotctl dir: %w", err)
}
v := viper.New()
v.Set("links", links)
if err := v.WriteConfigAs(configFile); err != nil {
return fmt.Errorf("cannot write config: %w", err)
}
fmt.Fprintf(out, "\nWrote config.yml to %s\n", configFile)
return nil
}

@ -1,49 +0,0 @@
package cmd
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
// cloneOrPull clones repoURL into dotfilePath if it does not exist,
// or pulls the latest changes if it is already a git repo.
// Does nothing on disk when dryRun is true.
func cloneOrPull(cmd *cobra.Command, repoURL, dotfilePath string, dryRun bool) error {
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 {
return fmt.Errorf("clone failed: %w", err)
}
}
} else if err != nil {
return fmt.Errorf("cannot stat %s: %w", dotfilePath, err)
} else if stat.IsDir() {
repo, err := git.PlainOpen(dotfilePath)
if err != nil {
return fmt.Errorf("%s exists but is not a git repository\nRemove it or use a different --dotfile-path", dotfilePath)
}
fmt.Fprintf(cmd.OutOrStdout(), "Pulling latest changes in %s...\n", dotfilePath)
if !dryRun {
w, err := repo.Worktree()
if err != nil {
return fmt.Errorf("cannot get worktree: %w", err)
}
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.")
}
}
}
return nil
}

@ -82,7 +82,9 @@ func runInitCommand(cmd *cobra.Command, args []string) {
log.Fatal(err) log.Fatal(err)
} }
gitignoreContent := []byte(`# dotctl config (config.yml) should be committed so dotctl bootstrap works on fresh machines gitignoreContent := []byte(`
# ignore dotctl config for individual installations
dotctl/
.DS_Store .DS_Store
*.swp *.swp

@ -2,22 +2,14 @@ package cmd
import ( import (
"fmt" "fmt"
"io"
"log" "log"
"os"
"path/filepath" "path/filepath"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// LinkResult holds counts from a link operation
type LinkResult struct {
Linked int
Skipped int
Backed int
}
func init() { func init() {
RootCmd.AddCommand(linkCommand) RootCmd.AddCommand(linkCommand)
} }
@ -26,130 +18,53 @@ var linkCommand = &cobra.Command{
Use: "link", Use: "link",
Run: runLinkCommand, Run: runLinkCommand,
Short: "generate symlinks according to config", Short: "generate symlinks according to config",
Long: "runs through all configs in the dotctl config file and links them to configured symlinks", Long: "runs through all configs in the dotctl config file and links them to configured symlinks", // TODO add longer description here
} }
func runLinkCommand(cmd *cobra.Command, args []string) { func runLinkCommand(cmd *cobra.Command, args []string) {
fmt.Fprintln(cmd.OutOrStdout(), "Symlinking dotfiles...") fs := FileSystem
dotfileRoot := viper.GetString("dotfile-path") fmt.Println("Symlinking dotfiles...")
links := viper.GetStringMapString("links") dotfileRoot := viper.Get("dotfile-path").(string)
result := LinkDotfiles(cmd.OutOrStdout(), dotfileRoot, links, Overwrite, NoBackup, DryRun) links := viper.GetStringMapString("links")
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 { for configName, configPath := range links {
if configName == ".git" || configName == "dotctl" { if configName == ".git" || configName == "dotctl" {
continue continue
} }
dotPath := filepath.Join(dotfileRoot, configName) dotPath := filepath.Join(dotfileRoot, configName)
if targetPath == "" { if configPath == "" {
fmt.Fprintf(out, " Warning: no target path configured for %s, skipping\n", configName) fmt.Fprintf(cmd.OutOrStdout(), "Warning: could not find config for %s\n", configName)
result.Skipped++
continue
}
// Ensure parent directory exists
parentDir := filepath.Dir(targetPath)
if !dryRun {
if err := os.MkdirAll(parentDir, 0755); err != nil {
log.Printf("Warning: could not create parent dir %s: %v\n", parentDir, err)
}
} }
// Check what's at the target path using Lstat (doesn't follow symlinks) // destination needs to be removed before symlink
lstat, err := os.Lstat(targetPath) if DryRun {
log.Printf("Existing directory %s will be removed\n", configPath)
if os.IsNotExist(err) {
// Target missing - create symlink
if dryRun {
fmt.Fprintf(out, " %s → %s [would link]\n", targetPath, dotPath)
} else { } else {
if err := os.Symlink(dotPath, targetPath); err != nil { fs.RemoveAll(configPath)
log.Printf("Error: cannot symlink %s → %s: %v\n", targetPath, dotPath, err)
result.Skipped++
continue
}
fmt.Fprintf(out, " %s → %s [linked]\n", targetPath, dotPath)
}
result.Linked++
continue
} }
if err != nil { testing := viper.Get("testing")
log.Printf("Warning: cannot stat %s: %v\n", targetPath, err)
result.Skipped++
continue
}
// Check if it's a symlink if DryRun {
if lstat.Mode()&os.ModeSymlink != 0 { log.Printf("Will link %s -> %s\n", configPath, dotPath)
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 { } else {
os.Remove(targetPath) if testing == true {
if err := os.Symlink(dotPath, targetPath); err != nil { fmt.Fprintf(cmd.OutOrStdout(), "%s,%s", configPath, dotPath)
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 { } else {
fmt.Fprintf(out, " %s [symlink to wrong path, skipping (use --overwrite to force)]\n", targetPath) linkPaths(dotPath, configPath)
result.Skipped++ }
} }
continue
} }
// Real file or directory exists at target
if noBackup {
fmt.Fprintf(out, " %s [real file exists, skipping (use --overwrite or remove --no-backup)]\n", targetPath)
result.Skipped++
continue
} }
backupPath := targetPath + ".dotctl.bak" func linkPaths(dotPath, configPath string) {
if dryRun { err := afero.OsFs.SymlinkIfPossible(afero.OsFs{}, dotPath, configPath)
fmt.Fprintf(out, " %s [would back up to %s and link → %s]\n", targetPath, backupPath, dotPath) if err != nil {
log.Fatalf("Cannot symlink %s: %s\n", configPath, err.Error())
} else { } else {
if err := os.Rename(targetPath, backupPath); err != nil { fmt.Printf("%s linked to %s\n", configPath, dotPath)
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
} }

@ -33,8 +33,6 @@ func Execute() {
var DotfilePath string var DotfilePath string
var ConfigPath string var ConfigPath string
var DryRun bool var DryRun bool
var Overwrite bool
var NoBackup bool
var FileSystem afero.Fs var FileSystem afero.Fs
@ -58,8 +56,6 @@ func init() {
"Path pointing to config directory", "Path pointing to config directory",
) )
RootCmd.PersistentFlags().BoolVarP(&DryRun, "dry-run", "d", false, "Only output which symlinks will be created") 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("dotfile-path", RootCmd.PersistentFlags().Lookup("dotfile-path"))
viper.BindPFlag("config-path", RootCmd.PersistentFlags().Lookup("config-path")) viper.BindPFlag("config-path", RootCmd.PersistentFlags().Lookup("config-path"))

@ -0,0 +1,206 @@
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)
}
}

@ -1,186 +0,0 @@
# 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 `<path>.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 <repo-url>`
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 <repo-url> ~/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 <repo>`. 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 <path>`
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 (12 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 (23 days)
Phase 2.2 → install.sh script (1 day)
Phase 3.x → status, list, remove (23 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 <url>` 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

@ -1,6 +1,6 @@
module github.com/Marcusk19/dotctl module github.com/Marcusk19/dotctl
go 1.24.0 go 1.21.0
require ( require (
github.com/carlmjohnson/versioninfo v0.22.5 github.com/carlmjohnson/versioninfo v0.22.5
@ -45,14 +45,14 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

@ -131,14 +131,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -146,13 +146,13 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -167,15 +167,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -183,14 +183,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

@ -1,85 +0,0 @@
#!/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 bootstrap if a repo URL was provided
if [ -n "${1:-}" ]; then
echo ""
echo "Running: dotctl bootstrap $1"
"$INSTALL_DIR/$BINARY" bootstrap "$1"
fi

@ -1,211 +0,0 @@
package test
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/Marcusk19/dotctl/cmd"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- DiscoverLinks unit tests ---
func TestDiscoverLinks_Basic(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "nvim"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "zsh"), 0755))
configRoot := t.TempDir()
links, err := cmd.DiscoverLinks(dir, configRoot)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configRoot, "nvim"), links["nvim"])
assert.Equal(t, filepath.Join(configRoot, "zsh"), links["zsh"])
assert.Len(t, links, 2)
}
func TestDiscoverLinks_SkipsGitAndDotctl(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dotctl"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "nvim"), 0755))
configRoot := t.TempDir()
links, err := cmd.DiscoverLinks(dir, configRoot)
require.NoError(t, err)
assert.NotContains(t, links, ".git")
assert.NotContains(t, links, "dotctl")
assert.Contains(t, links, "nvim")
assert.Len(t, links, 1)
}
func TestDiscoverLinks_Empty(t *testing.T) {
dir := t.TempDir()
configRoot := t.TempDir()
links, err := cmd.DiscoverLinks(dir, configRoot)
require.NoError(t, err)
assert.Empty(t, links)
}
// --- WriteBootstrapConfig unit tests ---
func TestWriteBootstrapConfig_CreatesFile(t *testing.T) {
dir := initLocalGitRepo(t)
links := map[string]string{
"nvim": "/home/user/.config/nvim",
"zsh": "/home/user/.config/zsh",
}
var buf bytes.Buffer
err := cmd.WriteBootstrapConfig(&buf, dir, links, false)
require.NoError(t, err)
configFile := filepath.Join(dir, "dotctl", "config.yml")
_, statErr := os.Stat(configFile)
assert.NoError(t, statErr, "config.yml should exist")
// Parse it back and verify
v := viper.New()
v.SetConfigFile(configFile)
require.NoError(t, v.ReadInConfig())
written := v.GetStringMapString("links")
assert.Equal(t, "/home/user/.config/nvim", written["nvim"])
assert.Equal(t, "/home/user/.config/zsh", written["zsh"])
}
func TestWriteBootstrapConfig_DryRun(t *testing.T) {
dir := initLocalGitRepo(t)
links := map[string]string{"nvim": "/home/user/.config/nvim"}
var buf bytes.Buffer
err := cmd.WriteBootstrapConfig(&buf, dir, links, true)
require.NoError(t, err)
configFile := filepath.Join(dir, "dotctl", "config.yml")
_, statErr := os.Stat(configFile)
assert.True(t, os.IsNotExist(statErr), "config.yml should NOT be created in dry-run mode")
assert.Contains(t, buf.String(), "dry-run")
}
// --- bootstrap command integration tests ---
func TestBootstrapCommand_LinksCreated(t *testing.T) {
defer resetGlobalState()
repoDir := initLocalGitRepo(t)
configRoot := t.TempDir()
// Put nvim and zsh dirs in the repo
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)
viper.Set("config-path", configRoot)
defer viper.Set("dotfile-path", "")
defer viper.Set("config-path", "")
rootCmd := cmd.RootCmd
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"bootstrap", "https://fake.url/repo.git"})
rootCmd.Execute()
// Symlinks should exist at configRoot/nvim and configRoot/zsh
nvimLink, err := os.Readlink(filepath.Join(configRoot, "nvim"))
require.NoError(t, err)
assert.Equal(t, filepath.Join(repoDir, "nvim"), nvimLink)
zshLink, err := os.Readlink(filepath.Join(configRoot, "zsh"))
require.NoError(t, err)
assert.Equal(t, filepath.Join(repoDir, "zsh"), zshLink)
}
func TestBootstrapCommand_WritesConfigYml(t *testing.T) {
defer resetGlobalState()
repoDir := initLocalGitRepo(t)
configRoot := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755))
viper.Set("dotfile-path", repoDir)
viper.Set("config-path", configRoot)
defer viper.Set("dotfile-path", "")
defer viper.Set("config-path", "")
rootCmd := cmd.RootCmd
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"bootstrap", "https://fake.url/repo.git"})
rootCmd.Execute()
configFile := filepath.Join(repoDir, "dotctl", "config.yml")
_, err := os.Stat(configFile)
assert.NoError(t, err, "config.yml should be written by bootstrap")
v := viper.New()
v.SetConfigFile(configFile)
require.NoError(t, v.ReadInConfig())
links := v.GetStringMapString("links")
assert.Equal(t, filepath.Join(configRoot, "nvim"), links["nvim"])
}
func TestBootstrapCommand_DryRun(t *testing.T) {
defer resetGlobalState()
repoDir := initLocalGitRepo(t)
configRoot := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755))
viper.Set("dotfile-path", repoDir)
viper.Set("config-path", configRoot)
defer viper.Set("dotfile-path", "")
defer viper.Set("config-path", "")
cmd.DryRun = true
rootCmd := cmd.RootCmd
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"bootstrap", "https://fake.url/repo.git"})
rootCmd.Execute()
output := buf.String()
assert.Contains(t, output, "would link")
assert.Contains(t, output, "dry-run")
// No symlink should be created
_, err := os.Lstat(filepath.Join(configRoot, "nvim"))
assert.True(t, os.IsNotExist(err), "symlink should NOT be created in dry-run mode")
// No config.yml should be written
_, err = os.Stat(filepath.Join(repoDir, "dotctl", "config.yml"))
assert.True(t, os.IsNotExist(err), "config.yml should NOT be written in dry-run mode")
}
func TestBootstrapCommand_NoArgs(t *testing.T) {
defer resetGlobalState()
rootCmd := cmd.RootCmd
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"bootstrap"})
err := rootCmd.Execute()
assert.Error(t, err, "bootstrap with no args should return an error")
}

@ -1,25 +0,0 @@
package test
import (
"testing"
"github.com/Marcusk19/dotctl/cmd"
gogit "github.com/go-git/go-git/v5"
"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
}
// resetGlobalState resets the global cmd flags to defaults.
func resetGlobalState() {
cmd.DryRun = false
cmd.Overwrite = false
cmd.NoBackup = false
}

@ -13,7 +13,6 @@ import (
func TestInitCommand(t *testing.T) { func TestInitCommand(t *testing.T) {
viper.Set("testing", true) viper.Set("testing", true)
cmd.FileSystem = afero.NewMemMapFs()
fs := cmd.FileSystem fs := cmd.FileSystem

@ -2,281 +2,42 @@ package test
import ( import (
"bytes" "bytes"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/Marcusk19/dotctl/cmd" "github.com/Marcusk19/dotctl/cmd"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestLinkDotfiles_MissingTarget(t *testing.T) { func TestLinkCommand(t *testing.T) {
dotfileRoot := t.TempDir() viper.Set("testing", true)
targetRoot := t.TempDir() cmd.FileSystem = afero.NewMemMapFs()
fs := cmd.FileSystem
// Create source dir in dotfileRoot homedir := os.Getenv("HOME")
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{ links := map[string]string{
".git": filepath.Join(targetRoot, ".git"), "someconfig": filepath.Join(homedir, ".config/someconfig"),
"dotctl": filepath.Join(targetRoot, "dotctl"),
} }
var buf bytes.Buffer viper.Set("links", links)
result := cmd.LinkDotfiles(&buf, dotfileRoot, links, false, false, false)
assert.Equal(t, 0, result.Linked) dotctl := cmd.RootCmd
assert.Equal(t, 0, result.Skipped) actual := new(bytes.Buffer)
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))
}
func TestLinkDotfiles_EmptyTargetPath(t *testing.T) { dotctl.SetOut(actual)
dotfileRoot := t.TempDir() dotctl.SetErr(actual)
dotctl.SetArgs([]string{"link"})
links := map[string]string{"nvim": ""} dotctl.Execute()
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)
assert.Equal(t, 2, result.Linked, "should have linked 2 entries") someconfig := filepath.Join(homedir, ".config/someconfig/")
assert.Equal(t, 0, result.Skipped) somedot := filepath.Join(homedir, "dotfiles/someconfig/")
assert.Equal(t, 0, result.Backed)
// Verify both symlinks expected := fmt.Sprintf("%s,%s", someconfig, somedot)
linkTarget, err := os.Readlink(nvimTarget)
require.NoError(t, err)
assert.Equal(t, filepath.Join(dotfileRoot, "nvim"), linkTarget)
linkTarget, err = os.Readlink(zshTarget) assert.Equal(t, expected, actual.String(), "actual differs from expected")
require.NoError(t, err)
assert.Equal(t, filepath.Join(dotfileRoot, "zsh"), linkTarget)
} }

Loading…
Cancel
Save