646 lines
15 KiB
Go
646 lines
15 KiB
Go
package core
|
|
|
|
import (
|
|
"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
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
Verbose bool
|
|
Force 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"),
|
|
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 runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status")
|
|
}
|
|
|
|
func (a *App) Diff(castle string) error {
|
|
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff")
|
|
}
|
|
|
|
func (a *App) Pull(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull")
|
|
}
|
|
|
|
func (a *App) Push(castle string) error {
|
|
if strings.TrimSpace(castle) == "" {
|
|
castle = "dotfiles"
|
|
}
|
|
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "push")
|
|
}
|
|
|
|
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 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 .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
|
|
}
|