diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index f5a38cb..f7c2452 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -88,7 +88,23 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { return 1 } return 0 - case "track", "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate": + case "track": + if len(args) < 2 || strings.TrimSpace(args[1]) == "" { + _, _ = fmt.Fprintln(stderr, "error: track requires FILE") + return 1 + } + + castle := "dotfiles" + if len(args) > 2 && strings.TrimSpace(args[2]) != "" { + castle = args[2] + } + + if err := app.Track(args[1], castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate": _, _ = fmt.Fprintf(stderr, "error: %s is not implemented in Go yet\n", command) return 2 default: @@ -116,10 +132,11 @@ func printHelp(w io.Writer) { _, _ = fmt.Fprintln(w, " diff [CASTLE]") _, _ = fmt.Fprintln(w, " link [CASTLE]") _, _ = fmt.Fprintln(w, " unlink [CASTLE]") + _, _ = fmt.Fprintln(w, " track FILE [CASTLE]") _, _ = fmt.Fprintln(w, " version | -v | --version") _, _ = fmt.Fprintln(w, "") _, _ = fmt.Fprintln(w, "Not implemented yet:") - _, _ = fmt.Fprintln(w, " track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") + _, _ = fmt.Fprintln(w, " pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") _, _ = fmt.Fprintln(w, "") _, _ = fmt.Fprintln(w, "Build: go build -o dist/homesick-go ./cmd/homesick") } diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 734f477..ab1d99f 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -213,8 +213,125 @@ func (a *App) UnlinkCastle(castle string) error { return nil } -func (a *App) Track(string, string) error { - return errors.New("track is not implemented in Go yet") +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 { @@ -420,9 +537,7 @@ func deriveDestination(uri string) string { 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.TrimPrefix(candidate, "file://") candidate = strings.TrimSuffix(candidate, ".git") candidate = strings.TrimSuffix(candidate, "/")