Skip to content
Draft
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,26 @@ $ git wt --nocd feature-branch
> - The `--nocd` flag always prevents cd regardless of config value.
> - Using `--nocd` with `--init` disables the `git()` wrapper entirely (only shell completion is output). The `wt.nocd` config does not affect `--init` output.

#### `wt.nogitignore`

Do not create `.gitignore` in the worktree base directory.

``` console
$ git config wt.nogitignore true
```

Default: `false`

#### `wt.noreadme`

Do not create `README.md` in the worktree base directory.

``` console
$ git config wt.noreadme true
```

Default: `false`

#### `wt.relative` / `--relative`

Append the current subdirectory path to the worktree output path. When running from a subdirectory, the output path will include the subdirectory relative to the repository root (like `git diff --relative`).
Expand Down
13 changes: 2 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,15 +710,6 @@ func handleWorktree(ctx context.Context, cmd *cobra.Command, branch, startPoint
}
}

// Build copy options from config
copyOpts := git.CopyOptions{
CopyIgnored: cfg.CopyIgnored,
CopyUntracked: cfg.CopyUntracked,
CopyModified: cfg.CopyModified,
NoCopy: cfg.NoCopy,
Copy: cfg.Copy,
}

// Check if worktree already exists for this branch or directory name
wt, err := git.FindWorktreeByBranchOrDir(ctx, branch)
if err != nil {
Expand Down Expand Up @@ -747,12 +738,12 @@ func handleWorktree(ctx context.Context, cmd *cobra.Command, branch, startPoint
if exists {
// Branch exists, create worktree with existing branch
// start-point is ignored when using existing branch
if err := git.AddWorktree(ctx, wtPath, branch, copyOpts); err != nil {
if err := git.AddWorktree(ctx, wtPath, branch, cfg); err != nil {
return fmt.Errorf("failed to create worktree: %w", err)
}
} else {
// Branch doesn't exist, create new branch and worktree
if err := git.AddWorktreeWithNewBranch(ctx, wtPath, branch, startPoint, copyOpts); err != nil {
if err := git.AddWorktreeWithNewBranch(ctx, wtPath, branch, startPoint, cfg); err != nil {
return fmt.Errorf("failed to create worktree with new branch: %w", err)
}
}
Expand Down
18 changes: 18 additions & 0 deletions internal/git/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const (
configKeyRemover = "wt.remover"
configKeyNoCd = "wt.nocd"
configKeyRelative = "wt.relative"
configKeyNoGitignore = "wt.nogitignore"
configKeyNoReadme = "wt.noreadme"
)

// Config holds all wt configuration values.
Expand All @@ -37,6 +39,8 @@ type Config struct {
Remover string
NoCd bool
Relative bool
NoGitignore bool
NoReadme bool
}

// GitConfig retrieves all git config values for a key.
Expand Down Expand Up @@ -148,6 +152,20 @@ func LoadConfig(ctx context.Context) (Config, error) {
}
cfg.Relative = len(val) > 0 && val[len(val)-1] == "true"

// NoGitignore
val, err = GitConfig(ctx, configKeyNoGitignore)
if err != nil {
return cfg, err
}
cfg.NoGitignore = len(val) > 0 && val[len(val)-1] == "true"

// NoReadme
val, err = GitConfig(ctx, configKeyNoReadme)
if err != nil {
return cfg, err
}
cfg.NoReadme = len(val) > 0 && val[len(val)-1] == "true"

return cfg, nil
}

Expand Down
24 changes: 24 additions & 0 deletions internal/git/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,30 @@ func TestLoadConfig(t *testing.T) {
if !cfg.NoCd {
t.Errorf("LoadConfig().NoCd = %v, want true", cfg.NoCd)
}

// Test defaults for NoGitignore and NoReadme
if cfg.NoGitignore {
t.Errorf("LoadConfig().NoGitignore default = %v, want false", cfg.NoGitignore)
}
if cfg.NoReadme {
t.Errorf("LoadConfig().NoReadme default = %v, want false", cfg.NoReadme)
}

// Test NoGitignore and NoReadme settings
repo.Git("config", "wt.nogitignore", "true")
repo.Git("config", "wt.noreadme", "true")

cfg, err = LoadConfig(t.Context())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !cfg.NoGitignore {
t.Errorf("LoadConfig().NoGitignore = %v, want true", cfg.NoGitignore)
}
if !cfg.NoReadme {
t.Errorf("LoadConfig().NoReadme = %v, want true", cfg.NoReadme)
}
}

func TestExpandPath(t *testing.T) {
Expand Down
42 changes: 28 additions & 14 deletions internal/git/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ type addWorktreeContext struct {

// prepareAdd detects the repository type (bare vs normal), determines the
// copy source worktree root, and initializes the destination parent directory.
func prepareAdd(ctx context.Context, path string) (*addWorktreeContext, error) {
func prepareAdd(ctx context.Context, path string, cfg Config) (*addWorktreeContext, error) {
isBareRoot, err := IsBareRoot(ctx)
if err != nil {
return nil, err
Expand All @@ -299,7 +299,7 @@ func prepareAdd(ctx context.Context, path string) (*addWorktreeContext, error) {
if err := os.MkdirAll(parentDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create parent directory: %w", err)
}
if err := initBaseDir(parentDir); err != nil {
if err := initBaseDir(parentDir, cfg); err != nil {
return nil, err
}

Expand All @@ -308,11 +308,19 @@ func prepareAdd(ctx context.Context, path string) (*addWorktreeContext, error) {

// copyAfterAdd copies files from the current worktree to the newly created worktree.
// It is a no-op when running from a bare root (no working tree to copy from).
func copyAfterAdd(ctx context.Context, ac *addWorktreeContext, dstPath string, copyOpts CopyOptions) error {
func copyAfterAdd(ctx context.Context, ac *addWorktreeContext, dstPath string, cfg Config) error {
if ac.isBareRoot {
return nil
}

copyOpts := CopyOptions{
CopyIgnored: cfg.CopyIgnored,
CopyUntracked: cfg.CopyUntracked,
CopyModified: cfg.CopyModified,
NoCopy: cfg.NoCopy,
Copy: cfg.Copy,
}

// Exclude basedir from copy to prevent circular copying, but only when
// srcRoot is outside the basedir. When srcRoot is inside the basedir
// (e.g., bare-derived worktree at .wt/main), git ls-files already scopes
Expand All @@ -333,8 +341,8 @@ func copyAfterAdd(ctx context.Context, ac *addWorktreeContext, dstPath string, c
}

// AddWorktree creates a new worktree for the given branch.
func AddWorktree(ctx context.Context, path, branch string, copyOpts CopyOptions) error {
ac, err := prepareAdd(ctx, path)
func AddWorktree(ctx context.Context, path, branch string, cfg Config) error {
ac, err := prepareAdd(ctx, path, cfg)
if err != nil {
return err
}
Expand All @@ -349,13 +357,13 @@ func AddWorktree(ctx context.Context, path, branch string, copyOpts CopyOptions)
return err
}

return copyAfterAdd(ctx, ac, path, copyOpts)
return copyAfterAdd(ctx, ac, path, cfg)
}

// AddWorktreeWithNewBranch creates a new worktree with a new branch.
// If startPoint is specified, the new branch will be created from that commit/branch.
func AddWorktreeWithNewBranch(ctx context.Context, path, branch, startPoint string, copyOpts CopyOptions) error {
ac, err := prepareAdd(ctx, path)
func AddWorktreeWithNewBranch(ctx context.Context, path, branch, startPoint string, cfg Config) error {
ac, err := prepareAdd(ctx, path, cfg)
if err != nil {
return err
}
Expand All @@ -375,19 +383,25 @@ func AddWorktreeWithNewBranch(ctx context.Context, path, branch, startPoint stri
return err
}

return copyAfterAdd(ctx, ac, path, copyOpts)
return copyAfterAdd(ctx, ac, path, cfg)
}

// initBaseDir initializes the basedir with .gitignore and README.md files.
// It creates these files only if they don't already exist.
func initBaseDir(baseDir string) error {
gitignorePath := filepath.Join(baseDir, ".gitignore")
if _, err := os.Stat(gitignorePath); os.IsNotExist(err) {
if err := os.WriteFile(gitignorePath, []byte("*\n"), 0600); err != nil {
return fmt.Errorf("failed to create .gitignore: %w", err)
func initBaseDir(baseDir string, cfg Config) error {
if !cfg.NoGitignore {
gitignorePath := filepath.Join(baseDir, ".gitignore")
if _, err := os.Stat(gitignorePath); os.IsNotExist(err) {
if err := os.WriteFile(gitignorePath, []byte("*\n"), 0600); err != nil {
return fmt.Errorf("failed to create .gitignore: %w", err)
}
}
}

if cfg.NoReadme {
return nil
}

readmePath := filepath.Join(baseDir, "README.md")
if _, err := os.Stat(readmePath); os.IsNotExist(err) {
readmeContent := `# Git worktrees added by ` + "`git wt`" + `
Expand Down
56 changes: 52 additions & 4 deletions internal/git/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ func TestAddWorktree(t *testing.T) {
defer restore()

wtPath := filepath.Join(repo.ParentDir(), "worktree-existing")
err := AddWorktree(t.Context(), wtPath, "existing-branch", CopyOptions{})
err := AddWorktree(t.Context(), wtPath, "existing-branch", Config{})
if err != nil {
t.Fatalf("AddWorktree failed: %v", err)
}
Expand Down Expand Up @@ -315,7 +315,7 @@ func TestAddWorktreeWithNewBranch(t *testing.T) {
defer restore()

wtPath := filepath.Join(repo.ParentDir(), "worktree-new")
err := AddWorktreeWithNewBranch(t.Context(), wtPath, "new-branch", "", CopyOptions{})
err := AddWorktreeWithNewBranch(t.Context(), wtPath, "new-branch", "", Config{})
if err != nil {
t.Fatalf("AddWorktreeWithNewBranch failed: %v", err)
}
Expand Down Expand Up @@ -353,6 +353,54 @@ func TestAddWorktreeWithNewBranch(t *testing.T) {
}
}

func TestAddWorktree_NoGitignore(t *testing.T) {
repo := testutil.NewTestRepo(t)
repo.CreateFile("README.md", "# Test")
repo.Commit("initial commit")
repo.Git("branch", "existing-branch")

restore := repo.Chdir()
defer restore()

wtPath := filepath.Join(repo.ParentDir(), "worktree-no-gitignore")
err := AddWorktree(t.Context(), wtPath, "existing-branch", Config{NoGitignore: true})
if err != nil {
t.Fatalf("AddWorktree failed: %v", err)
}

baseDir := filepath.Dir(wtPath)
if _, err := os.Stat(filepath.Join(baseDir, ".gitignore")); !os.IsNotExist(err) {
t.Error(".gitignore should not be created when NoGitignore is true")
}
if _, err := os.Stat(filepath.Join(baseDir, "README.md")); os.IsNotExist(err) {
t.Error("README.md should still be created when only NoGitignore is true")
}
}

func TestAddWorktree_NoReadme(t *testing.T) {
repo := testutil.NewTestRepo(t)
repo.CreateFile("README.md", "# Test")
repo.Commit("initial commit")
repo.Git("branch", "existing-branch")

restore := repo.Chdir()
defer restore()

wtPath := filepath.Join(repo.ParentDir(), "worktree-no-readme")
err := AddWorktree(t.Context(), wtPath, "existing-branch", Config{NoReadme: true})
if err != nil {
t.Fatalf("AddWorktree failed: %v", err)
}

baseDir := filepath.Dir(wtPath)
if _, err := os.Stat(filepath.Join(baseDir, ".gitignore")); os.IsNotExist(err) {
t.Error(".gitignore should still be created when only NoReadme is true")
}
if _, err := os.Stat(filepath.Join(baseDir, "README.md")); !os.IsNotExist(err) {
t.Error("README.md should not be created when NoReadme is true")
}
}

func TestAddWorktree_FromBareRepository(t *testing.T) {
bareRepo := testutil.NewBareTestRepo(t)

Expand All @@ -370,7 +418,7 @@ func TestAddWorktree_FromBareRepository(t *testing.T) {
}()

wtPath := filepath.Join(bareRepo.ParentDir(), "wt-existing")
err = AddWorktree(t.Context(), wtPath, "main", CopyOptions{})
err = AddWorktree(t.Context(), wtPath, "main", Config{})
if err != nil {
t.Fatalf("AddWorktree from bare repo failed: %v", err)
}
Expand Down Expand Up @@ -414,7 +462,7 @@ func TestAddWorktreeWithNewBranch_FromBareRepository(t *testing.T) {
}()

wtPath := filepath.Join(bareRepo.ParentDir(), "wt-new-branch")
err = AddWorktreeWithNewBranch(t.Context(), wtPath, "new-feature", "", CopyOptions{})
err = AddWorktreeWithNewBranch(t.Context(), wtPath, "new-feature", "", Config{})
if err != nil {
t.Fatalf("AddWorktreeWithNewBranch from bare repo failed: %v", err)
}
Expand Down