931 lines
21 KiB
Go
931 lines
21 KiB
Go
package core
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
git "github.com/go-git/go-git/v5"
|
|
)
|
|
|
|
type App struct {
|
|
HomeDir string
|
|
ReposDir string
|
|
Stdin io.Reader
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
Verbose bool
|
|
Force bool
|
|
Quiet bool
|
|
Pretend bool
|
|
}
|
|
|
|
func New(stdout io.Writer, stderr io.Writer) (*App, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
|
}
|
|
|
|
return &App{
|
|
HomeDir: home,
|
|
ReposDir: filepath.Join(home, ".homesick", "repos"),
|
|
Stdin: os.Stdin,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) Version(version string) error {
|
|
_, err := fmt.Fprintln(a.Stdout, version)
|
|
return err
|
|
}
|
|
|
|
func (a *App) ShowPath(castle string) error {
|
|
_, err := fmt.Fprintln(a.Stdout, filepath.Join(a.ReposDir, castle))
|
|
return err
|
|
}
|
|
|
|
func (a *App) Clone(uri string, destination string) error {
|
|
if uri == "" {
|
|
return errors.New("clone requires URI")
|
|
}
|
|
|
|
if destination == "" {
|
|
destination = deriveDestination(uri)
|
|
}
|
|
if destination == "" {
|
|
return fmt.Errorf("unable to derive destination from uri %q", uri)
|
|
}
|
|
|
|
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
|
|
return fmt.Errorf("create repos directory: %w", err)
|
|
}
|
|
|
|
destinationPath := filepath.Join(a.ReposDir, destination)
|
|
|
|
if fi, err := os.Stat(uri); err == nil && fi.IsDir() {
|
|
if err := os.Symlink(uri, destinationPath); err != nil {
|
|
return fmt.Errorf("symlink local castle: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
_, err := git.PlainClone(destinationPath, false, &git.CloneOptions{
|
|
URL: uri,
|
|
RecurseSubmodules: git.DefaultSubmoduleRecursionDepth,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("clone %q into %q failed: %w", uri, destinationPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) List() error {
|
|
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
var castles []string
|
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if !d.IsDir() || d.Name() != ".git" {
|
|
return nil
|
|
}
|
|
|
|
castleRoot := filepath.Dir(path)
|
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
castles = append(castles, rel)
|
|
return filepath.SkipDir
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sort.Strings(castles)
|
|
for _, castle := range castles {
|
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
|
remote, remoteErr := gitOutput(castleRoot, "config", "remote.origin.url")
|
|
if remoteErr != nil {
|
|
remote = ""
|
|
}
|
|
_, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote))
|
|
if writeErr != nil {
|
|
return writeErr
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Status(castle string) error {
|
|
return a.runGit(filepath.Join(a.ReposDir, castle), "status")
|
|
}
|
|
|
|
func (a *App) Diff(castle string) error {
|
|
return a.runGit(filepath.Join(a.ReposDir, castle), "diff")
|
|
}
|
|
|
|
func (a *App) Pull(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
return a.runGit(filepath.Join(a.ReposDir, castle), "pull")
|
|
}
|
|
|
|
func (a *App) PullAll() error {
|
|
if _, err := os.Stat(a.ReposDir); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
var castles []string
|
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if !d.IsDir() || d.Name() != ".git" {
|
|
return nil
|
|
}
|
|
|
|
castleRoot := filepath.Dir(path)
|
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
castles = append(castles, rel)
|
|
return filepath.SkipDir
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sort.Strings(castles)
|
|
for _, castle := range castles {
|
|
if !a.Quiet {
|
|
if _, err := fmt.Fprintf(a.Stdout, "%s:\n", castle); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := a.runGit(filepath.Join(a.ReposDir, castle), "pull"); err != nil {
|
|
return fmt.Errorf("pull --all failed for %q: %w", castle, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Push(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
return a.runGit(filepath.Join(a.ReposDir, castle), "push")
|
|
}
|
|
|
|
func (a *App) Commit(castle string, message string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
|
|
trimmedMessage := strings.TrimSpace(message)
|
|
if trimmedMessage == "" {
|
|
return errors.New("commit requires message")
|
|
}
|
|
|
|
castledir := filepath.Join(a.ReposDir, castle)
|
|
if err := a.runGit(castledir, "add", "--all"); err != nil {
|
|
return err
|
|
}
|
|
|
|
return a.runGit(castledir, "commit", "-m", trimmedMessage)
|
|
}
|
|
|
|
func (a *App) Destroy(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
|
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
|
castleInfo, err := os.Lstat(castleRoot)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("castle %q not found", castle)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if !a.Force {
|
|
confirmed, confirmErr := a.confirmDestroy(castle)
|
|
if confirmErr != nil {
|
|
return confirmErr
|
|
}
|
|
if !confirmed {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Only attempt unlinking managed home files for regular castle directories.
|
|
if castleInfo.Mode()&os.ModeSymlink == 0 {
|
|
castleHome := filepath.Join(castleRoot, "home")
|
|
if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() {
|
|
if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil {
|
|
return unlinkErr
|
|
}
|
|
}
|
|
}
|
|
|
|
return os.RemoveAll(castleRoot)
|
|
}
|
|
|
|
func (a *App) confirmDestroy(castle string) (bool, error) {
|
|
reader := a.Stdin
|
|
if reader == nil {
|
|
reader = os.Stdin
|
|
}
|
|
|
|
if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
line, err := bufio.NewReader(reader).ReadString('\n')
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return false, err
|
|
}
|
|
|
|
response := strings.ToLower(strings.TrimSpace(line))
|
|
return response == "y" || response == "yes", nil
|
|
}
|
|
|
|
func (a *App) Open(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
|
|
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
|
if editor == "" {
|
|
return errors.New("the $EDITOR environment variable must be set to use this command")
|
|
}
|
|
|
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
|
if info, err := os.Stat(castleHome); err != nil || !info.IsDir() {
|
|
return fmt.Errorf("could not open %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
|
}
|
|
|
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
|
cmd := exec.Command("sh", "-c", editor+" .")
|
|
cmd.Dir = castleRoot
|
|
cmd.Stdout = a.Stdout
|
|
cmd.Stderr = a.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("open failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Exec(castle string, command []string) error {
|
|
commandString := strings.TrimSpace(strings.Join(command, " "))
|
|
if commandString == "" {
|
|
return errors.New("exec requires COMMAND")
|
|
}
|
|
|
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
|
if _, err := os.Stat(castleRoot); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("castle %q not found", castle)
|
|
}
|
|
return err
|
|
}
|
|
|
|
a.sayStatus("exec", fmt.Sprintf("%s command %q in castle %q", a.actionVerb(), commandString, castle))
|
|
if a.Pretend {
|
|
return nil
|
|
}
|
|
|
|
cmd := exec.Command("sh", "-c", commandString)
|
|
cmd.Dir = castleRoot
|
|
cmd.Stdout = a.Stdout
|
|
cmd.Stderr = a.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("exec failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) ExecAll(command []string) error {
|
|
commandString := strings.TrimSpace(strings.Join(command, " "))
|
|
if commandString == "" {
|
|
return errors.New("exec_all requires COMMAND")
|
|
}
|
|
|
|
if _, err := os.Stat(a.ReposDir); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
var castles []string
|
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if !d.IsDir() || d.Name() != ".git" {
|
|
return nil
|
|
}
|
|
|
|
castleRoot := filepath.Dir(path)
|
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
castles = append(castles, rel)
|
|
return filepath.SkipDir
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sort.Strings(castles)
|
|
for _, castle := range castles {
|
|
if err := a.Exec(castle, []string{commandString}); err != nil {
|
|
return fmt.Errorf("exec_all failed for %q: %w", castle, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Generate(castlePath string) error {
|
|
trimmed := strings.TrimSpace(castlePath)
|
|
if trimmed == "" {
|
|
return errors.New("generate requires PATH")
|
|
}
|
|
|
|
absCastle, err := filepath.Abs(trimmed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(absCastle, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.runGit(absCastle, "init"); err != nil {
|
|
return err
|
|
}
|
|
|
|
githubUser := ""
|
|
if out, cfgErr := gitOutput(absCastle, "config", "github.user"); cfgErr == nil {
|
|
githubUser = strings.TrimSpace(out)
|
|
}
|
|
|
|
if githubUser != "" {
|
|
repoName := filepath.Base(absCastle)
|
|
url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName)
|
|
if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return os.MkdirAll(filepath.Join(absCastle, "home"), 0o755)
|
|
}
|
|
|
|
func (a *App) Link(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
return a.LinkCastle(castle)
|
|
}
|
|
|
|
func (a *App) LinkCastle(castle string) error {
|
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
|
info, err := os.Stat(castleHome)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
|
}
|
|
|
|
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, subdir := range subdirs {
|
|
base := filepath.Join(castleHome, subdir)
|
|
if _, err := os.Stat(base); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := a.linkEach(castleHome, base, subdirs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Unlink(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
return a.UnlinkCastle(castle)
|
|
}
|
|
|
|
func (a *App) UnlinkCastle(castle string) error {
|
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
|
info, err := os.Stat(castleHome)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
|
}
|
|
|
|
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, subdir := range subdirs {
|
|
base := filepath.Join(castleHome, subdir)
|
|
if _, err := os.Stat(base); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Track(filePath string, castle string) error {
|
|
return a.TrackPath(filePath, castle)
|
|
}
|
|
|
|
func (a *App) TrackPath(filePath string, castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
|
|
trimmedFile := strings.TrimSpace(filePath)
|
|
if trimmedFile == "" {
|
|
return errors.New("track requires FILE")
|
|
}
|
|
|
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
|
castleHome := filepath.Join(castleRoot, "home")
|
|
info, err := os.Stat(castleHome)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("could not track %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
|
}
|
|
|
|
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Lstat(absolutePath); err != nil {
|
|
return err
|
|
}
|
|
|
|
relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
|
|
return fmt.Errorf("track requires file under %s", a.HomeDir)
|
|
}
|
|
|
|
castleTargetDir := filepath.Join(castleHome, relativeDir)
|
|
if relativeDir == "." {
|
|
castleTargetDir = castleHome
|
|
}
|
|
if err := os.MkdirAll(castleTargetDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath))
|
|
if _, err := os.Lstat(trackedPath); err == nil {
|
|
return fmt.Errorf("%s already exists", trackedPath)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
if err := os.Rename(absolutePath, trackedPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
subdirChanged := false
|
|
if relativeDir != "." {
|
|
subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
|
|
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := a.linkPath(trackedPath, absolutePath); err != nil {
|
|
return err
|
|
}
|
|
|
|
repo, err := git.PlainOpen(castleRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
|
|
if relativeDir == "." {
|
|
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
|
|
}
|
|
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil {
|
|
return err
|
|
}
|
|
|
|
if subdirChanged {
|
|
if _, err := worktree.Add(".homesick_subdir"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func appendUniqueSubdir(path string, subdir string) (bool, error) {
|
|
existing, err := readSubdirs(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
cleanSubdir := filepath.Clean(subdir)
|
|
for _, line := range existing {
|
|
if filepath.Clean(line) == cleanSubdir {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer file.Close()
|
|
|
|
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
|
|
entries, err := os.ReadDir(baseDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if name == "." || name == ".." {
|
|
continue
|
|
}
|
|
|
|
source := filepath.Join(baseDir, name)
|
|
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ignore {
|
|
continue
|
|
}
|
|
|
|
relDir, err := filepath.Rel(castleHome, baseDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
destination := filepath.Join(a.HomeDir, relDir, name)
|
|
if relDir == "." {
|
|
destination = filepath.Join(a.HomeDir, name)
|
|
}
|
|
|
|
if err := a.linkPath(source, destination); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
|
|
entries, err := os.ReadDir(baseDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if name == "." || name == ".." {
|
|
continue
|
|
}
|
|
|
|
source := filepath.Join(baseDir, name)
|
|
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ignore {
|
|
continue
|
|
}
|
|
|
|
relDir, err := filepath.Rel(castleHome, baseDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
destination := filepath.Join(a.HomeDir, relDir, name)
|
|
if relDir == "." {
|
|
destination = filepath.Join(a.HomeDir, name)
|
|
}
|
|
|
|
if err := unlinkPath(destination); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func unlinkPath(destination string) error {
|
|
info, err := os.Lstat(destination)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
return nil
|
|
}
|
|
|
|
return os.Remove(destination)
|
|
}
|
|
|
|
func (a *App) linkPath(source string, destination string) error {
|
|
absSource, err := filepath.Abs(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := os.Lstat(destination)
|
|
if err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
target, readErr := os.Readlink(destination)
|
|
if readErr == nil && target == absSource {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if !a.Force {
|
|
return fmt.Errorf("%s exists", destination)
|
|
}
|
|
|
|
if rmErr := os.RemoveAll(destination); rmErr != nil {
|
|
return rmErr
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
if err := os.Symlink(absSource, destination); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func readSubdirs(path string) ([]string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []string{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
result := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
result = append(result, filepath.Clean(trimmed))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) {
|
|
absCandidate, err := filepath.Abs(candidate)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
ignoreSet := map[string]struct{}{}
|
|
for _, subdir := range subdirs {
|
|
clean := filepath.Clean(subdir)
|
|
for clean != "." && clean != string(filepath.Separator) {
|
|
ignoreSet[filepath.Join(castleHome, clean)] = struct{}{}
|
|
next := filepath.Dir(clean)
|
|
if next == clean {
|
|
break
|
|
}
|
|
clean = next
|
|
}
|
|
}
|
|
|
|
_, ok := ignoreSet[absCandidate]
|
|
return ok, nil
|
|
}
|
|
|
|
func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) runGit(dir string, args ...string) error {
|
|
if a.Pretend {
|
|
a.sayStatus("git", fmt.Sprintf("%s git %s in %s", a.actionVerb(), strings.Join(args, " "), dir))
|
|
return nil
|
|
}
|
|
return runGitWithIO(dir, a.Stdout, a.Stderr, args...)
|
|
}
|
|
|
|
func (a *App) actionVerb() string {
|
|
if a.Pretend {
|
|
return "Would execute"
|
|
}
|
|
return "Executing"
|
|
}
|
|
|
|
func (a *App) sayStatus(action string, message string) {
|
|
if a.Quiet {
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintf(a.Stdout, "%s: %s\n", action, message)
|
|
}
|
|
|
|
func gitOutput(dir string, args ...string) (string, error) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// Rc runs the rc hooks for the given castle. It looks for executable files
|
|
// inside <castle>/.homesick.d and runs them in sorted (lexicographic) order
|
|
// with the castle root as the working directory, forwarding stdout and stderr
|
|
// to the App writers.
|
|
//
|
|
// If a .homesickrc file exists in the castle root and no parity.rb wrapper
|
|
// already exists in .homesick.d, a Ruby wrapper script named parity.rb is
|
|
// written there before execution so that it sorts first.
|
|
func (a *App) Rc(castle string) error {
|
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
|
if _, err := os.Stat(castleRoot); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("castle %q not found", castle)
|
|
}
|
|
return err
|
|
}
|
|
|
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
|
|
|
if _, err := os.Stat(homesickRc); err == nil && !a.Force {
|
|
return errors.New("refusing to run legacy .homesickrc without --force")
|
|
}
|
|
|
|
// If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created
|
|
// (but do not overwrite an existing parity.rb).
|
|
if _, err := os.Stat(homesickRc); err == nil {
|
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
|
if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) {
|
|
if mkErr := os.MkdirAll(homesickD, 0o755); mkErr != nil {
|
|
return fmt.Errorf("create .homesick.d: %w", mkErr)
|
|
}
|
|
wrapperContent := "#!/usr/bin/env ruby\n" +
|
|
"# parity.rb — generated wrapper for legacy .homesickrc\n" +
|
|
"# Evaluates .homesickrc in the context of the castle root.\n" +
|
|
"rc_file = File.join(__dir__, '..', '.homesickrc')\n" +
|
|
"eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n"
|
|
if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o755); writeErr != nil {
|
|
return fmt.Errorf("write parity.rb: %w", writeErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, err := os.Stat(homesickD); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
entries, err := os.ReadDir(homesickD)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// ReadDir returns entries in sorted order already.
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
info, infoErr := entry.Info()
|
|
if infoErr != nil {
|
|
return infoErr
|
|
}
|
|
if info.Mode()&0o111 == 0 {
|
|
// Not executable — skip.
|
|
continue
|
|
}
|
|
scriptPath := filepath.Join(homesickD, entry.Name())
|
|
cmd := exec.Command(scriptPath)
|
|
cmd.Dir = castleRoot
|
|
cmd.Stdout = a.Stdout
|
|
cmd.Stderr = a.Stderr
|
|
if runErr := cmd.Run(); runErr != nil {
|
|
return fmt.Errorf("rc script %q failed: %w", entry.Name(), runErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deriveDestination(uri string) string {
|
|
candidate := strings.TrimSpace(uri)
|
|
candidate = strings.TrimPrefix(candidate, "https://github.com/")
|
|
candidate = strings.TrimPrefix(candidate, "http://github.com/")
|
|
candidate = strings.TrimPrefix(candidate, "git://github.com/")
|
|
|
|
candidate = strings.TrimPrefix(candidate, "file://")
|
|
|
|
candidate = strings.TrimSuffix(candidate, ".git")
|
|
candidate = strings.TrimSuffix(candidate, "/")
|
|
if candidate == "" {
|
|
return ""
|
|
}
|
|
|
|
parts := strings.Split(candidate, "/")
|
|
last := parts[len(parts)-1]
|
|
if strings.Contains(last, ":") {
|
|
a := strings.Split(last, ":")
|
|
last = a[len(a)-1]
|
|
}
|
|
return last
|
|
}
|