Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ var destroyCmd = &cobra.Command{
return fmt.Errorf("not in a git repository: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot)
cfg, err := config.Load(repoRoot)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot, cfg.Root)
if err != nil {
return err
}
Expand Down
7 changes: 6 additions & 1 deletion cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -34,11 +35,15 @@ func getRunE(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot)
poolDir, err := config.ResolvePoolDir(repoRoot, cfg.Root)
if err != nil {
return fmt.Errorf("failed to resolve pool directory: %w", err)
}

if err := config.EnsureGitignore(filepath.Dir(poolDir)); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to update .gitignore: %v\n", err)
}

wtPath, err := pool.Acquire(repoRoot, poolDir, cfg.MaxTrees)
if err != nil {
return err
Expand Down
5 changes: 5 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ var initCmd = &cobra.Command{
return fmt.Errorf("failed to write config: %w", err)
}

// Append a comment showing the root option.
if _, err := f.WriteString("\n# Worktree root directory (relative to repo root or absolute path).\n# Worktrees are placed under {root}/.treehouse/. Default: $HOME\n# Example: root = \"./\"\n"); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}

fmt.Fprintf(os.Stderr, "Created %s\n", dest)
return nil
},
Expand Down
7 changes: 6 additions & 1 deletion cmd/return_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ var returnCmd = &cobra.Command{
return fmt.Errorf("not in a git repository: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot)
cfg, err := config.Load(repoRoot)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot, cfg.Root)
if err != nil {
return err
}
Expand Down
7 changes: 6 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ var statusCmd = &cobra.Command{
return fmt.Errorf("not in a git repository: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot)
cfg, err := config.Load(repoRoot)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

poolDir, err := config.ResolvePoolDir(repoRoot, cfg.Root)
if err != nil {
return err
}
Expand Down
25 changes: 17 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
)

type Config struct {
MaxTrees int `toml:"max_trees"`
MaxTrees int `toml:"max_trees"`
Root string `toml:"root"`
}

func DefaultConfig() Config {
Expand Down Expand Up @@ -41,19 +42,27 @@ func Load(repoRoot string) (Config, error) {
return cfg, nil
}

func ResolvePoolDir(repoRoot string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}

func ResolvePoolDir(repoRoot string, root string) (string, error) {
remoteURL, err := git.GetRemoteURL(repoRoot)
if err != nil {
return "", err
}

repoName := filepath.Base(repoRoot)
shortHash := git.ShortHash(remoteURL)
poolName := repoName + "-" + shortHash

return filepath.Join(home, ".treehouse", repoName+"-"+shortHash), nil
if root == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".treehouse", poolName), nil
}

expanded := os.ExpandEnv(root)
if !filepath.IsAbs(expanded) {
expanded = filepath.Join(repoRoot, expanded)
}
return filepath.Join(expanded, ".treehouse", poolName), nil
}
67 changes: 67 additions & 0 deletions internal/config/gitignore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package config

import (
"os"
"path/filepath"
"strings"

"github.com/kunchenguid/treehouse/internal/git"
)

// EnsureGitignore adds treehouseDir to the .gitignore of the enclosing git
// repo, if treehouseDir is inside a git repo. It is a no-op if the directory
// is not inside a repo or if the entry already exists.
func EnsureGitignore(treehouseDir string) error {
// Walk up from treehouseDir to find an existing ancestor for the git check,
// since the directory itself may not exist yet.
checkDir := treehouseDir
for {
if info, err := os.Stat(checkDir); err == nil && info.IsDir() {
break
}
parent := filepath.Dir(checkDir)
if parent == checkDir {
return nil
}
checkDir = parent
}

repoRoot, err := git.FindRepoRootFrom(checkDir)
if err != nil {
// Not inside a git repo — nothing to do.
return nil
}

rel, err := filepath.Rel(repoRoot, treehouseDir)
if err != nil {
return nil
}

// Use forward slashes for .gitignore and prefix with /
entry := "/" + filepath.ToSlash(rel)

gitignorePath := filepath.Join(repoRoot, ".gitignore")
existing, err := os.ReadFile(gitignorePath)
if err != nil && !os.IsNotExist(err) {
return err
}

for _, line := range strings.Split(string(existing), "\n") {
if strings.TrimSpace(line) == entry {
return nil
}
}

f, err := os.OpenFile(gitignorePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()

prefix := ""
if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") {
prefix = "\n"
}
_, err = f.WriteString(prefix + entry + "\n")
return err
}
131 changes: 131 additions & 0 deletions internal/config/gitignore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package config

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func setupGitRepo(t *testing.T) string {
t.Helper()
base := t.TempDir()
base, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}

bareDir := filepath.Join(base, "remote.git")
repoDir := filepath.Join(base, "myrepo")

run(t, "", "git", "init", "--bare", "--initial-branch=main", bareDir)
run(t, "", "git", "init", "--initial-branch=main", repoDir)
run(t, repoDir, "git", "config", "user.email", "test@test.com")
run(t, repoDir, "git", "config", "user.name", "Test")
run(t, repoDir, "git", "remote", "add", "origin", bareDir)

if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, repoDir, "git", "add", ".")
run(t, repoDir, "git", "commit", "-m", "initial")
run(t, repoDir, "git", "push", "-u", "origin", "main")

return repoDir
}

func run(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out)
}
}

func TestEnsureGitignore_AddsEntry(t *testing.T) {
repoDir := setupGitRepo(t)

treehouseDir := filepath.Join(repoDir, ".worktrees", ".treehouse")

if err := EnsureGitignore(treehouseDir); err != nil {
t.Fatalf("EnsureGitignore failed: %v", err)
}

data, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Fatalf("failed to read .gitignore: %v", err)
}

expected := "/.worktrees/.treehouse"
if !strings.Contains(string(data), expected) {
t.Errorf("expected .gitignore to contain %q, got: %s", expected, data)
}
}

func TestEnsureGitignore_Idempotent(t *testing.T) {
repoDir := setupGitRepo(t)

treehouseDir := filepath.Join(repoDir, ".worktrees", ".treehouse")

if err := EnsureGitignore(treehouseDir); err != nil {
t.Fatal(err)
}
if err := EnsureGitignore(treehouseDir); err != nil {
t.Fatal(err)
}

data, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Fatal(err)
}

entry := "/.worktrees/.treehouse"
count := strings.Count(string(data), entry)
if count != 1 {
t.Errorf("expected entry exactly once, found %d times in:\n%s", count, data)
}
}

func TestEnsureGitignore_NotInRepo(t *testing.T) {
// A temp dir that is not a git repo.
dir := t.TempDir()
treehouseDir := filepath.Join(dir, ".treehouse")

if err := EnsureGitignore(treehouseDir); err != nil {
t.Fatalf("EnsureGitignore should be a no-op outside a repo, got: %v", err)
}

// No .gitignore should be created.
if _, err := os.Stat(filepath.Join(dir, ".gitignore")); !os.IsNotExist(err) {
t.Error("expected no .gitignore to be created outside a git repo")
}
}

func TestEnsureGitignore_DefaultRoot(t *testing.T) {
repoDir := setupGitRepo(t)

// When using default root ($HOME/.treehouse), the treehouse dir is outside
// the repo, so EnsureGitignore should be a no-op.
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
treehouseDir := filepath.Join(home, ".treehouse")

// This should not fail even though the dir is outside the repo.
if err := EnsureGitignore(treehouseDir); err != nil {
t.Fatalf("EnsureGitignore failed: %v", err)
}

// No .gitignore should be created/modified in the repo.
if _, err := os.Stat(filepath.Join(repoDir, ".gitignore")); err == nil {
// If .gitignore exists, it shouldn't contain .treehouse (the home one
// is in a different repo context).
_ = repoDir // just ensure we don't accidentally create one
}
}
Loading
Loading