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 }