Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ hooks:
from: ".env" # Allowed even if gitignored. 'from' is always relative to the MAIN worktree
to: ".env" # Destination is relative to the NEW worktree

# Share directories between the MAIN and NEW worktree
- type: symlink
from: ".bin"
to: ".bin"

# Prefer explicit, single-step setup commands
- type: command
command: "npm ci" # Example for Node.js (replace with your build/deps tool)
Expand Down Expand Up @@ -202,6 +207,11 @@ hooks:
from: ".claude" # Copy AI context file (gitignored)
to: ".claude"

# Share directories between the main and new worktree
- type: symlink
from: ".bin"
to: ".bin"

# Execute commands in the new worktree
- type: command
command: "npm install"
Expand Down Expand Up @@ -246,6 +256,24 @@ hooks:
This behavior applies regardless of where you run `wtp add` from (main worktree
or any other worktree).

### Symlink Hooks: Shared Assets

Symlink hooks are useful for sharing large or mutable directories from the main
worktree (e.g. `.bin`, `.cache`, `node_modules`).

- `from`: path is resolved relative to the main worktree (or absolute).
- `to`: path is resolved relative to the newly created worktree (or absolute).

Example:

```yaml
hooks:
post_create:
- type: symlink
from: ".bin"
to: ".bin"
```

## Shell Integration

### Tab Completion Setup
Expand Down
5 changes: 5 additions & 0 deletions cmd/wtp/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ hooks:
# - type: copy
# from: .cursor/ # Cursor IDE settings
# to: .cursor/

# Share directories with symlinks:
# - type: symlink
# from: .bin # Shared tool cache
# to: .bin

# Run setup commands:
# - type: command
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ hooks:
- type: command
command: "npm install"
work_dir: "."
- type: symlink
from: ".bin"
to: ".bin"
```

## Hook System
Expand All @@ -122,6 +125,7 @@ hooks:
Post-create hooks support:
- File copying (for .env files, etc.)
- Command execution
- Symlink creation (for shared binaries, caches, etc.)

This covers 90% of use cases without over-engineering.

Expand Down
15 changes: 12 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Hooks struct {

// Hook represents a single hook configuration
type Hook struct {
Type string `yaml:"type"` // "copy" or "command"
Type string `yaml:"type"` // "copy", "command", or "symlink"
From string `yaml:"from,omitempty"`
To string `yaml:"to,omitempty"`
Command string `yaml:"command,omitempty"`
Expand All @@ -46,7 +46,9 @@ const (
// HookTypeCopy identifies a hook that copies files.
HookTypeCopy = "copy"
// HookTypeCommand identifies a hook that executes a command.
HookTypeCommand = "command"
HookTypeCommand = "command"
// HookTypeSymlink identifies a hook that creates symlinks.
HookTypeSymlink = "symlink"
configFilePermissions = 0o600
)

Expand Down Expand Up @@ -151,8 +153,15 @@ func (h *Hook) Validate() error {
if h.From != "" || h.To != "" {
return fmt.Errorf("command hook should not have 'from' or 'to' fields")
}
case HookTypeSymlink:
if h.From == "" || h.To == "" {
return fmt.Errorf("symlink hook requires both 'from' and 'to' fields")
}
if h.Command != "" {
return fmt.Errorf("symlink hook should not have 'command' field")
}
default:
return fmt.Errorf("invalid hook type '%s', must be 'copy' or 'command'", h.Type)
return fmt.Errorf("invalid hook type '%s', must be 'copy', 'command', or 'symlink'", h.Type)
}

return nil
Expand Down
46 changes: 44 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ hooks:
to: ".env"
- type: command
command: "echo test"
- type: symlink
from: ".bin"
to: ".bin"
`

err := os.WriteFile(configPath, []byte(configContent), 0644)
Expand All @@ -57,8 +60,8 @@ hooks:
t.Errorf("Expected base_dir '../my-worktrees', got %s", config.Defaults.BaseDir)
}

if len(config.Hooks.PostCreate) != 2 {
t.Errorf("Expected 2 hooks, got %d", len(config.Hooks.PostCreate))
if len(config.Hooks.PostCreate) != 3 {
t.Errorf("Expected 3 hooks, got %d", len(config.Hooks.PostCreate))
}

if config.Hooks.PostCreate[0].Type != HookTypeCopy {
Expand All @@ -68,6 +71,10 @@ hooks:
if config.Hooks.PostCreate[1].Type != HookTypeCommand {
t.Errorf("Expected second hook type 'command', got %s", config.Hooks.PostCreate[1].Type)
}

if config.Hooks.PostCreate[2].Type != HookTypeSymlink {
t.Errorf("Expected third hook type 'symlink', got %s", config.Hooks.PostCreate[2].Type)
}
}

func TestLoadConfig_InvalidYAML(t *testing.T) {
Expand Down Expand Up @@ -259,6 +266,15 @@ func TestHookValidate(t *testing.T) {
},
expectError: false,
},
{
name: "valid symlink hook",
hook: Hook{
Type: HookTypeSymlink,
From: ".bin",
To: ".bin",
},
expectError: false,
},
{
name: "copy hook missing from",
hook: Hook{
Expand Down Expand Up @@ -292,6 +308,32 @@ func TestHookValidate(t *testing.T) {
},
expectError: true,
},
{
name: "symlink hook missing from",
hook: Hook{
Type: HookTypeSymlink,
To: ".bin",
},
expectError: true,
},
{
name: "symlink hook missing to",
hook: Hook{
Type: HookTypeSymlink,
From: ".bin",
},
expectError: true,
},
{
name: "symlink hook with command field",
hook: Hook{
Type: HookTypeSymlink,
From: ".bin",
To: ".bin",
Command: "echo", // Should not have command
},
expectError: true,
},
{
name: "command hook with from/to fields",
hook: Hook{
Expand Down
65 changes: 65 additions & 0 deletions internal/hooks/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ func (e *Executor) executeHookWithWriter(w io.Writer, hook *config.Hook, worktre
return e.executeCopyHookWithWriter(w, hook, worktreePath)
case config.HookTypeCommand:
return e.executeCommandHookWithWriter(w, hook, worktreePath)
case config.HookTypeSymlink:
return e.executeSymlinkHookWithWriter(w, hook, worktreePath)
default:
return fmt.Errorf("unknown hook type: %s", hook.Type)
}
Expand Down Expand Up @@ -122,6 +124,69 @@ func (e *Executor) executeCopyHookWithWriter(w io.Writer, hook *config.Hook, wor
return e.copyFile(srcPath, dstPath)
}

// executeSymlinkHookWithWriter executes a symlink hook with output directed to writer
func (e *Executor) executeSymlinkHookWithWriter(w io.Writer, hook *config.Hook, worktreePath string) error {
// Resolve source path (relative to repo root)
srcPath := hook.From
if !filepath.IsAbs(srcPath) {
srcPath = filepath.Join(e.repoRoot, srcPath)
}
srcPath = filepath.Clean(srcPath)
if !filepath.IsAbs(hook.From) {
if err := ensureWithinBase(e.repoRoot, srcPath); err != nil {
return err
}
}

// Resolve destination path (relative to worktree)
dstPath := hook.To
if !filepath.IsAbs(dstPath) {
dstPath = filepath.Join(worktreePath, dstPath)
}
dstPath = filepath.Clean(dstPath)
if !filepath.IsAbs(hook.To) {
if err := ensureWithinBase(worktreePath, dstPath); err != nil {
return err
}
}

// Check if source exists
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("source path does not exist: %s", srcPath)
}

// Create destination directory if needed
dstDir := filepath.Dir(dstPath)
if err := os.MkdirAll(dstDir, directoryPermissions); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}

// Prevent clobbering existing paths
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("destination path already exists: %s", dstPath)
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect destination path: %w", err)
}

// Log the symlink operation to writer
relSrc, _ := filepath.Rel(e.repoRoot, srcPath)
relDst, _ := filepath.Rel(worktreePath, dstPath)
if _, err := fmt.Fprintf(w, " Symlinking: %s → %s\n", relSrc, relDst); err != nil {
return err
}

linkTarget := srcPath
if relTarget, err := filepath.Rel(dstDir, srcPath); err == nil {
linkTarget = relTarget
}

if err := os.Symlink(linkTarget, dstPath); err != nil {
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The symlink implementation creates relative symlinks when possible, which differs from the copy hook behavior that always uses absolute paths. This inconsistency could lead to unexpected behavior. Consider whether symlinks should always be absolute (like copy operations) for consistency, or document this difference clearly. Relative symlinks can break if the symlink is moved, while absolute symlinks remain valid but are less portable.

Suggested change
linkTarget := srcPath
if relTarget, err := filepath.Rel(dstDir, srcPath); err == nil {
linkTarget = relTarget
}
if err := os.Symlink(linkTarget, dstPath); err != nil {
// Use an absolute path for the symlink target for consistency with copy hooks.
if err := os.Symlink(srcPath, dstPath); err != nil {

Copilot uses AI. Check for mistakes.
return fmt.Errorf("failed to create symlink: %w", err)
}

return nil
}

func ensureWithinBase(base, target string) error {
rel, err := filepath.Rel(base, target)
if err != nil {
Expand Down
67 changes: 67 additions & 0 deletions internal/hooks/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ func TestExecutePostCreateHooks_NoHooks(t *testing.T) {
assert.NoError(t, err)
}

func requireSymlinkSupport(t *testing.T) {
t.Helper()
if runtime.GOOS != "windows" {
return
}

tempDir := t.TempDir()
srcPath := filepath.Join(tempDir, "src")
dstPath := filepath.Join(tempDir, "dst")
require.NoError(t, os.WriteFile(srcPath, []byte("ok"), 0644))
if err := os.Symlink(srcPath, dstPath); err != nil {
t.Skipf("symlink not supported on this system: %v", err)
}
}

func TestExecutePostCreateHooks_InvalidHookType(t *testing.T) {
cfg := &config.Config{
Hooks: config.Hooks{
Expand Down Expand Up @@ -100,6 +115,58 @@ func TestExecutePostCreateHooks_CopyFile(t *testing.T) {
assert.Contains(t, output, "✓ Hook 1 completed")
}

func TestExecutePostCreateHooks_Symlink(t *testing.T) {
requireSymlinkSupport(t)

tempDir := t.TempDir()
repoRoot := filepath.Join(tempDir, "repo")
worktreeDir := filepath.Join(tempDir, "worktree")

err := os.MkdirAll(repoRoot, directoryPermissions)
require.NoError(t, err)
err = os.MkdirAll(worktreeDir, directoryPermissions)
require.NoError(t, err)

srcDir := filepath.Join(repoRoot, ".bin")
require.NoError(t, os.MkdirAll(srcDir, directoryPermissions))
require.NoError(t, os.WriteFile(filepath.Join(srcDir, "tool"), []byte("bin"), 0644))

cfg := &config.Config{
Hooks: config.Hooks{
PostCreate: []config.Hook{
{
Type: config.HookTypeSymlink,
From: ".bin",
To: ".bin",
},
},
},
}

executor := NewExecutor(cfg, repoRoot)
var buf bytes.Buffer
err = executor.ExecutePostCreateHooks(&buf, worktreeDir)
assert.NoError(t, err)

dstPath := filepath.Join(worktreeDir, ".bin")
info, err := os.Lstat(dstPath)
require.NoError(t, err)
assert.NotZero(t, info.Mode()&os.ModeSymlink)

linkTarget, err := os.Readlink(dstPath)
require.NoError(t, err)

resolvedTarget := linkTarget
if !filepath.IsAbs(resolvedTarget) {
resolvedTarget = filepath.Join(filepath.Dir(dstPath), resolvedTarget)
}
assert.Equal(t, filepath.Clean(srcDir), filepath.Clean(resolvedTarget))

output := buf.String()
assert.Contains(t, output, "Symlinking: .bin → .bin")
assert.Contains(t, output, "✓ Hook 1 completed")
}
Comment on lines +118 to +165
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the symlink hook's error handling cases. The test suite should include tests for: source path doesn't exist, destination path already exists, and path traversal attempts. The copy hook has similar test coverage (e.g., TestExecutePostCreateHooks_CopyNonExistentFile), and symlink hooks should have equivalent coverage.

Copilot uses AI. Check for mistakes.

func TestExecutePostCreateHooks_Command(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping command test on Windows")
Expand Down
Loading