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) 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 := runGitWithIO(castledir, a.Stdout, a.Stderr, "add", "--all"); err != nil { return err } return runGitWithIO(castledir, a.Stdout, a.Stderr, "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 } // 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) 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 } 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 := runGitWithIO(absCastle, a.Stdout, a.Stderr, "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 := runGitWithIO(absCastle, a.Stdout, a.Stderr, "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 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 /.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 }