Compare commits

...

4 Commits

Author SHA1 Message Date
Marcus 2ed8af0f5e replace init+apply with a single bootstrap workflow 4 weeks ago
Marcus Kok 5945819253
release workflow (#46) 4 weeks ago
dependabot[bot] c34b262202
Bump golang.org/x/crypto from 0.31.0 to 0.45.0 (#45)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4 weeks ago
Marcus Kok ce0d506e21
implement Phase 1 + 2: idempotent link, apply command, and install script (#43)
- 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>
4 weeks ago

@ -0,0 +1,27 @@
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,7 +40,20 @@ archives:
changelog:
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:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "Merge pull request"
- "Merge branch"

@ -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,120 @@
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
}

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

@ -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)
} else {
if testing == true {
fmt.Fprintf(cmd.OutOrStdout(), "%s,%s", configPath, dotPath)
// Check if it's a symlink
if lstat.Mode()&os.ModeSymlink != 0 {
existing, err := os.Readlink(targetPath)
if err == nil && existing == dotPath {
// Already pointing to the right place
fmt.Fprintf(out, " %s → %s [already linked]\n", targetPath, dotPath)
result.Skipped++
continue
}
// Symlink to wrong place
if overwrite {
if dryRun {
fmt.Fprintf(out, " %s [would remove wrong symlink and relink → %s]\n", targetPath, dotPath)
} else {
linkPaths(dotPath, configPath)
os.Remove(targetPath)
if err := os.Symlink(dotPath, targetPath); err != nil {
log.Printf("Error: cannot relink %s: %v\n", targetPath, err)
result.Skipped++
continue
}
fmt.Fprintf(out, " %s → %s [relinked]\n", targetPath, dotPath)
}
result.Linked++
} else {
fmt.Fprintf(out, " %s [symlink to wrong path, skipping (use --overwrite to force)]\n", targetPath)
result.Skipped++
}
continue
}
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())
// Real file or directory exists at target
if noBackup {
fmt.Fprintf(out, " %s [real file exists, skipping (use --overwrite or remove --no-backup)]\n", targetPath)
result.Skipped++
continue
}
backupPath := targetPath + ".dotctl.bak"
if dryRun {
fmt.Fprintf(out, " %s [would back up to %s and link → %s]\n", targetPath, backupPath, dotPath)
} else {
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

@ -1,6 +1,6 @@
module github.com/Marcusk19/dotctl
go 1.21.0
go 1.24.0
require (
github.com/carlmjohnson/versioninfo v0.22.5
@ -45,14 +45,14 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // 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.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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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/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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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-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.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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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.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.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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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

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

@ -0,0 +1,25 @@
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,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