344 lines
7.3 KiB
Go
344 lines
7.3 KiB
Go
package core
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
if err := runGit(a.ReposDir, "clone", "-q", "--config", "push.default=upstream", "--recursive", uri, destination); err != nil {
|
|
return 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 runGit(filepath.Join(a.ReposDir, castle), "status")
|
|
}
|
|
|
|
func (a *App) Diff(castle string) error {
|
|
return runGit(filepath.Join(a.ReposDir, castle), "diff")
|
|
}
|
|
|
|
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(string) error {
|
|
return errors.New("unlink is not implemented in Go yet")
|
|
}
|
|
|
|
func (a *App) Track(string, string) error {
|
|
return errors.New("track is not implemented in Go yet")
|
|
}
|
|
|
|
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) 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 runGit(dir string, args ...string) error {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.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
|
|
}
|
|
|
|
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/")
|
|
|
|
if strings.HasPrefix(candidate, "file://") {
|
|
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
|
|
}
|