Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 |
5 changes: 4 additions & 1 deletion cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
99 changes: 99 additions & 0 deletions cmd/apply.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 7 additions & 9 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading