Skip to content
Open
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
3 changes: 3 additions & 0 deletions cmd/wut/cmd_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/simonbs/wut/src/context"
"github.com/simonbs/wut/src/git"
"github.com/simonbs/wut/src/worktree"
"github.com/simonbs/wut/src/worktreeinclude"
)

func cmdNew(args []string) {
Expand Down Expand Up @@ -72,5 +73,7 @@ func cmdNew(args []string) {
fail(err.Error())
}

worktreeinclude.Copy(ctx.RepoRoot, worktreePath)

fmt.Printf("__WUT_CD__:%s\n", worktreePath)
}
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ module github.com/simonbs/wut

go 1.25.6

require github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b // indirect
require (
github.com/codeglyph/go-dotignore/v2 v2.2.0
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
github.com/codeglyph/go-dotignore/v2 v2.2.0 h1:9pza2HcxkSzOsihknLZCEAjOeeIPt9uqigbgdLq9qy8=
github.com/codeglyph/go-dotignore/v2 v2.2.0/go.mod h1:cAH4uxaZ0Rsl33QT2c++wAnk7C8J2c8pik9U4H0+F9w=
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ=
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
189 changes: 189 additions & 0 deletions src/worktreeinclude/worktreeinclude.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Package worktreeinclude copies untracked files listed in a repo's
// .worktreeinclude file into a freshly created worktree.
//
// Only files that are BOTH matched by a .worktreeinclude pattern AND
// gitignored in the source repo are copied, so tracked files are never
// duplicated. Errors are swallowed and surface as warnings on
// stderr — a misconfigured .worktreeinclude must not break `wut new`.
package worktreeinclude

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

dotignore "github.com/codeglyph/go-dotignore/v2"
"github.com/simonbs/wut/src/git"
)

// Copy reads <repoRoot>/.worktreeinclude, finds gitignored files matching its
// patterns, and copies them into worktreePath. Returns the relative paths of
// the files that were copied. Missing or empty .worktreeinclude is a no-op.
// Structural errors (read failure, git failure, matcher build failure) cause
// an early return with no copies; per-file copy failures are logged to stderr
// and do not abort the rest of the copies.
func Copy(repoRoot, worktreePath string) []string {
data, err := os.ReadFile(filepath.Join(repoRoot, ".worktreeinclude"))
if err != nil {
return nil
}

patterns := parsePatterns(string(data))
if len(patterns) == 0 {
return nil
}

matcher, err := dotignore.NewPatternMatcher(patterns)
if err != nil {
return nil
}

// --directory collapses fully-gitignored dirs (node_modules/, .turbo/, ...)
// into single trailing-slash entries. In a large repo this avoids walking
// hundreds of thousands of files just to filter them out.
out, err := git.Run(
[]string{"ls-files", "--others", "--ignored", "--exclude-standard", "--directory"},
repoRoot,
)
if err != nil || strings.TrimSpace(out) == "" {
return nil
}

collapsedDirs, files := splitEntries(out, matcher)

// A .worktreeinclude pattern may target a path inside a collapsed dir
// (e.g. `config/secrets/api.key` when all of `config/secrets/` is
// gitignored). For those, expand only the dirs the pattern actually
// reaches into — expanding every collapsed dir would defeat the perf win.
if expand := dirsToExpand(collapsedDirs, patterns, matcher); len(expand) > 0 {
args := append(
[]string{"ls-files", "--others", "--ignored", "--exclude-standard", "--"},
expand...,
)
expanded, err := git.Run(args, repoRoot)
if err == nil {
for _, f := range strings.Split(strings.TrimSpace(expanded), "\n") {
if f == "" {
continue
}
if ok, _ := matcher.Matches(f); ok {
files = append(files, f)
}
}
}
}

copied := make([]string, 0, len(files))
for _, rel := range files {
src := filepath.Join(repoRoot, rel)
dst := filepath.Join(worktreePath, rel)
if err := copyFile(src, dst); err != nil {
fmt.Fprintf(os.Stderr, "wut: failed to copy %s to worktree: %v\n", rel, err)
continue
}
copied = append(copied, rel)
}

if len(copied) > 0 {
fmt.Fprintf(
os.Stderr,
"wut: copied %d file(s) from .worktreeinclude: %s\n",
len(copied),
strings.Join(copied, ", "),
)
}
return copied
}

func parsePatterns(content string) []string {
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimRight(line, "\r")
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out
}

func splitEntries(out string, matcher *dotignore.PatternMatcher) (dirs, files []string) {
for _, e := range strings.Split(strings.TrimSpace(out), "\n") {
if e == "" {
continue
}
if strings.HasSuffix(e, "/") {
dirs = append(dirs, e)
continue
}
if ok, _ := matcher.Matches(e); ok {
files = append(files, e)
}
}
return dirs, files
}

// dirsToExpand returns the collapsed dirs that .worktreeinclude actually
// reaches into. We expand when a pattern's literal prefix lands inside the
// dir, or when the dir itself matches the include set. We deliberately skip
// anchorless patterns like `**/foo` or `*.tmp` — for those, expanding every
// collapsed dir would scan the whole gitignored tree, which is the cost
// `--directory` exists to avoid.
func dirsToExpand(dirs, patterns []string, matcher *dotignore.PatternMatcher) []string {
out := make([]string, 0, len(dirs))
for _, dir := range dirs {
if patternsTargetDir(dir, patterns) {
out = append(out, dir)
continue
}
if ok, _ := matcher.Matches(strings.TrimSuffix(dir, "/")); ok {
out = append(out, dir)
}
}
return out
}

func patternsTargetDir(dir string, patterns []string) bool {
for _, p := range patterns {
normalized := strings.TrimPrefix(p, "/")
if strings.HasPrefix(normalized, dir) {
return true
}
if globIdx := strings.IndexAny(normalized, "*?["); globIdx > 0 {
if strings.HasPrefix(dir, normalized[:globIdx]) {
return true
}
}
}
return false
}

func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()

info, err := in.Stat()
if err != nil {
return err
}

out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}