mirror of https://github.com/Marcusk19/dotctl
Compare commits
4 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
2ed8af0f5e | 4 weeks ago |
|
|
5945819253 | 4 weeks ago |
|
|
c34b262202 | 4 weeks ago |
|
|
ce0d506e21 | 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
|
||||
}
|
||||
@ -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…
Reference in New Issue