mirror of https://github.com/Marcusk19/dotctl
Compare commits
No commits in common. '2ed8af0f5ee2897c96887531b7fdf776f3df1cc2' and '084be9f565b42b99726290a0f4006b5a45c105f6' have entirely different histories.
2ed8af0f5e
...
084be9f565
@ -1,27 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,85 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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