Files
gosick/internal/homesick/core/core.go
Micheal Wilkinson 0d3c9b5214
Some checks failed
Push Validation / validate (push) Has been cancelled
chore(security): resolve gosec findings with permission fixes and #nosec suppressions
2026-03-21 13:05:08 +00:00

946 lines
22 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) {
if stdout == nil {
return nil, errors.New("stdout writer cannot be nil")
}
if stderr == nil {
return nil, errors.New("stderr writer cannot be nil")
}
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, 0o750); 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, 0o750); 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
}
return isAffirmativeResponse(line), nil
}
func isAffirmativeResponse(input string) bool {
response := strings.ToLower(strings.TrimSpace(input))
return response == "y" || response == "yes"
}
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(editor, ".") // #nosec G204 — EDITOR environment variable is user-set
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) // #nosec G204 — intentional shell command execution feature
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, 0o750); 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"), 0o750)
}
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, 0o750); 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, 0o600) // #nosec G304 — internal metadata file
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), 0o750); 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) // #nosec G304 — internal metadata file
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, 0o750); 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), 0o600); writeErr != nil {
return fmt.Errorf("write parity.rb: %w", writeErr)
}
// #nosec G302 -- script wrapper must be executable to run properly
if chmodErr := os.Chmod(wrapperPath, 0o700); chmodErr != nil {
return fmt.Errorf("chmod parity.rb: %w", chmodErr)
}
}
}
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) // #nosec G204 — path validated from app-controlled .homesick.d directory
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
}