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 <url>
- 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 <noreply@anthropic.com>
pull/43/head
Marcus 4 weeks ago
parent 084be9f565
commit 81f719ac2a

@ -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 <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) |
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 |

@ -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")
}

@ -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 <repo-url>",
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)
}

@ -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)

@ -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
}
// 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)
}
}
// destination needs to be removed before symlink
if DryRun {
log.Printf("Existing directory %s will be removed\n", 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 {
fs.RemoveAll(configPath)
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)
// 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 {
if testing == true {
fmt.Fprintf(cmd.OutOrStdout(), "%s,%s", configPath, dotPath)
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
}
// 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
}
}
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())
backupPath := targetPath + ".dotctl.bak"
if dryRun {
fmt.Fprintf(out, " %s [would back up to %s and link → %s]\n", targetPath, backupPath, dotPath)
} else {
fmt.Printf("%s linked to %s\n", configPath, dotPath)
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
}

@ -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"))

@ -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)
}
}

@ -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 `<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

@ -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

@ -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!")
}

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

@ -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)
}

Loading…
Cancel
Save