Compare commits

..

4 Commits
v0.1.1 ... main

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

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

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

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

@ -77,9 +77,9 @@ if [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then
fi
fi
# Run apply if a repo URL was provided
# Run bootstrap if a repo URL was provided
if [ -n "${1:-}" ]; then
echo ""
echo "Running: dotctl apply $1"
"$INSTALL_DIR/$BINARY" apply "$1"
echo "Running: dotctl bootstrap $1"
"$INSTALL_DIR/$BINARY" bootstrap "$1"
fi

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

@ -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
}
Loading…
Cancel
Save