From 2ed8af0f5ee2897c96887531b7fdf776f3df1cc2 Mon Sep 17 00:00:00 2001 From: Marcus Date: Sat, 11 Apr 2026 19:27:45 -0400 Subject: [PATCH] replace init+apply with a single bootstrap workflow --- cmd/apply.go | 99 ------------------- cmd/bootstrap.go | 120 +++++++++++++++++++++++ cmd/git.go | 49 ++++++++++ cmd/init.go | 2 +- install.sh | 6 +- test/apply_test.go | 189 ------------------------------------ test/bootstrap_test.go | 211 +++++++++++++++++++++++++++++++++++++++++ test/helpers_test.go | 25 +++++ 8 files changed, 409 insertions(+), 292 deletions(-) delete mode 100644 cmd/apply.go create mode 100644 cmd/bootstrap.go create mode 100644 cmd/git.go delete mode 100644 test/apply_test.go create mode 100644 test/bootstrap_test.go create mode 100644 test/helpers_test.go diff --git a/cmd/apply.go b/cmd/apply.go deleted file mode 100644 index a65729b..0000000 --- a/cmd/apply.go +++ /dev/null @@ -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 ", - 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) -} diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go new file mode 100644 index 0000000..b888ce5 --- /dev/null +++ b/cmd/bootstrap.go @@ -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 ", + 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/, 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 +} diff --git a/cmd/git.go b/cmd/git.go new file mode 100644 index 0000000..147adba --- /dev/null +++ b/cmd/git.go @@ -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 +} diff --git a/cmd/init.go b/cmd/init.go index 5041943..8e93a72 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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 diff --git a/install.sh b/install.sh index d42350b..2abb3a6 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/test/apply_test.go b/test/apply_test.go deleted file mode 100644 index dacc0b4..0000000 --- a/test/apply_test.go +++ /dev/null @@ -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!") -} diff --git a/test/bootstrap_test.go b/test/bootstrap_test.go new file mode 100644 index 0000000..5c110e0 --- /dev/null +++ b/test/bootstrap_test.go @@ -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") +} diff --git a/test/helpers_test.go b/test/helpers_test.go new file mode 100644 index 0000000..efa8392 --- /dev/null +++ b/test/helpers_test.go @@ -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 +}