Skip to content

Commit fb76d52

Browse files
authored
Merge pull request #150 from usadamasa/support-delete-in-bare
feat: support delete operation in bare repositories
2 parents 7b677d0 + b00d8ba commit fb76d52

8 files changed

Lines changed: 323 additions & 107 deletions

File tree

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ The target can be specified as:
1919

2020
When deleting, the same target types apply: `git wt -d feature-branch`, `git wt -d .`, `git wt -d ../sibling`
2121

22-
> [!NOTE]
23-
> Bare repositories are not currently supported.
24-
> Bare repository support is planned: see [#130](https://github.com/k1LoW/git-wt/issues/130) for details.
25-
2622
> [!NOTE]
2723
> The default branch (e.g., main, master) is protected from accidental deletion.
2824
> - If the default branch has a worktree, the worktree is deleted but the branch is preserved.

cmd/root.go

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,6 @@ Note: The default branch (e.g., main, master) is protected from accidental delet
7272
- Without worktree: deletion is blocked entirely.
7373
Use --allow-delete-default to override and delete the branch.
7474
75-
Note: Bare repositories are supported for list and add/switch operations.
76-
Delete operation in bare repositories is not yet supported.
77-
See https://github.com/k1LoW/git-wt/issues/130 for details.
78-
7975
Shell Integration:
8076
Add the following to your shell config to enable worktree switching and completion:
8177
@@ -217,8 +213,8 @@ func runRoot(cmd *cobra.Command, args []string) error {
217213
}
218214

219215
// Detect repo context once and thread it through context.
220-
// Subsequent calls to DetectRepoContext (via AssertNotBareRepository etc.)
221-
// will reuse the cached value instead of spawning git processes again.
216+
// Subsequent calls to DetectRepoContext will reuse the cached value
217+
// instead of spawning git processes again.
222218
rc, err := git.DetectRepoContext(ctx)
223219
if err != nil {
224220
return err
@@ -231,21 +227,11 @@ func runRoot(cmd *cobra.Command, args []string) error {
231227
}
232228

233229
// Handle delete flags (multiple arguments allowed)
234-
// Guard: bare repositories are not supported for delete operation.
235-
// Remove this guard when bare delete support is implemented.
236230
if forceDeleteFlag {
237-
if err := git.AssertNotBareRepository(ctx); err != nil {
238-
return err
239-
}
240-
// Remove duplicates while preserving order
241231
args = uniqueArgs(args)
242232
return deleteWorktrees(ctx, cmd, args, true)
243233
}
244234
if deleteFlag {
245-
if err := git.AssertNotBareRepository(ctx); err != nil {
246-
return err
247-
}
248-
// Remove duplicates while preserving order
249235
args = uniqueArgs(args)
250236
return deleteWorktrees(ctx, cmd, args, false)
251237
}
@@ -662,6 +648,15 @@ func deleteWorktrees(ctx context.Context, cmd *cobra.Command, branches []string,
662648
continue
663649
}
664650

651+
// Check if the target matches the bare repository entry
652+
isBareEntry, err := git.IsBareEntry(ctx, branch)
653+
if err != nil {
654+
return fmt.Errorf("failed to check bare entry: %w", err)
655+
}
656+
if isBareEntry {
657+
return fmt.Errorf("cannot delete bare repository entry %q: the bare repository root cannot be removed as a worktree", branch)
658+
}
659+
665660
// Case 2: No worktree - try to delete branch only
666661
exists, err := git.LocalBranchExists(ctx, branch)
667662
if err != nil {

e2e/bare_test.go

Lines changed: 181 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
// Covered scenarios:
44
// - List operation: supported in both bare root and worktrees from bare repos
55
// - Add/switch operations: supported in both bare root and worktrees from bare repos
6-
// - Delete operation: not yet supported, should return errors
6+
// - Delete operation: supported from bare-derived worktrees; bare entry itself is protected
77
package e2e
88

99
import (
10+
"bytes"
1011
"encoding/json"
1112
"os"
1213
"path/filepath"
@@ -392,41 +393,202 @@ func TestE2E_BareRepository(t *testing.T) {
392393
}
393394
})
394395

395-
// --- Tests for operations that still don't support bare repositories ---
396+
}
397+
398+
// TestE2E_BareDelete tests delete operations in bare repository environments.
399+
func TestE2E_BareDelete(t *testing.T) {
400+
t.Parallel()
401+
binPath := buildBinary(t)
402+
403+
// --- Success: delete linked worktree from bare root ---
404+
405+
t.Run("bare_root_safe_delete", func(t *testing.T) {
406+
t.Parallel()
407+
bareRepo := testutil.NewBareTestRepo(t)
408+
wtPath := createBareWorktree(t, binPath, bareRepo.Root, "feature-del")
409+
410+
// Safe delete from bare root
411+
out, err := runGitWt(t, binPath, bareRepo.Root, "-d", "feature-del")
412+
if err != nil {
413+
t.Fatalf("git-wt -d failed: %v\noutput: %s", err, out)
414+
}
415+
416+
// Worktree should be deleted
417+
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
418+
t.Error("worktree should have been deleted")
419+
}
420+
if !strings.Contains(out, "Deleted") {
421+
t.Errorf("output should contain 'Deleted', got: %s", out)
422+
}
423+
})
424+
425+
t.Run("bare_root_force_delete", func(t *testing.T) {
426+
t.Parallel()
427+
bareRepo := testutil.NewBareTestRepo(t)
428+
wtPath := createBareWorktree(t, binPath, bareRepo.Root, "unmerged-del")
429+
commitUnmergedChange(t, wtPath)
430+
431+
// Force delete from bare root
432+
out, err := runGitWt(t, binPath, bareRepo.Root, "-D", "unmerged-del")
433+
if err != nil {
434+
t.Fatalf("git-wt -D failed: %v\noutput: %s", err, out)
435+
}
396436

397-
t.Run("direct_bare_delete", func(t *testing.T) {
437+
// Worktree should be deleted
438+
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
439+
t.Error("worktree should have been force deleted")
440+
}
441+
})
442+
443+
// --- Error: attempting to delete bare entry itself ---
444+
445+
t.Run("bare_root_delete_self", func(t *testing.T) {
398446
t.Parallel()
399447
bareRepo := testutil.NewBareTestRepo(t)
400448

401-
// Run git-wt with -d flag (delete mode) inside the bare repo
449+
// Try to delete main (the bare entry's branch) from bare root
402450
out, err := runGitWt(t, binPath, bareRepo.Root, "-d", "main")
403451
if err == nil {
404-
t.Fatalf("expected error for bare repository, but succeeded with output: %s", out)
452+
t.Fatalf("expected error when deleting bare entry, but succeeded with output: %s", out)
405453
}
406-
if !strings.Contains(out, "bare") {
407-
t.Errorf("error message should mention 'bare', got: %s", out)
454+
if !strings.Contains(out, "bare repository entry") {
455+
t.Errorf("error message should mention 'bare repository entry', got: %s", out)
408456
}
409457
})
410458

411-
t.Run("worktree_from_bare_delete", func(t *testing.T) {
459+
t.Run("bare_root_delete_self_dot", func(t *testing.T) {
412460
t.Parallel()
413461
bareRepo := testutil.NewBareTestRepo(t)
414462

415-
// Create a worktree from the bare repo
416-
wtPath := filepath.Join(bareRepo.ParentDir(), "wt-main")
417-
cmd := exec.Command("git", "-C", bareRepo.Root, "worktree", "add", wtPath, "main")
418-
if out, err := cmd.CombinedOutput(); err != nil {
419-
t.Fatalf("git worktree add failed: %v\noutput: %s", err, out)
463+
// Try to delete "." (current directory = bare root) from bare root
464+
out, err := runGitWt(t, binPath, bareRepo.Root, "-d", ".")
465+
if err == nil {
466+
t.Fatalf("expected error when deleting bare entry via '.', but succeeded with output: %s", out)
467+
}
468+
if !strings.Contains(out, "bare repository entry") {
469+
t.Errorf("error message should mention 'bare repository entry', got: %s", out)
470+
}
471+
})
472+
473+
// --- Success: delete from bare-derived worktree ---
474+
475+
t.Run("bare_worktree_delete_other", func(t *testing.T) {
476+
t.Parallel()
477+
bareRepo := testutil.NewBareTestRepo(t)
478+
wtPathA := createBareWorktree(t, binPath, bareRepo.Root, "wt-a")
479+
wtPathB := createBareWorktree(t, binPath, bareRepo.Root, "wt-b")
480+
481+
// From worktree B, delete worktree A
482+
out, err := runGitWt(t, binPath, wtPathB, "-d", "wt-a")
483+
if err != nil {
484+
t.Fatalf("git-wt -d failed: %v\noutput: %s", err, out)
485+
}
486+
487+
// Worktree A should be deleted
488+
if _, err := os.Stat(wtPathA); !os.IsNotExist(err) {
489+
t.Error("worktree A should have been deleted")
490+
}
491+
// Worktree B should still exist
492+
if _, err := os.Stat(wtPathB); os.IsNotExist(err) {
493+
t.Error("worktree B should still exist")
494+
}
495+
})
496+
497+
t.Run("bare_worktree_delete_current", func(t *testing.T) {
498+
t.Parallel()
499+
bareRepo := testutil.NewBareTestRepo(t)
500+
wtPath := createBareWorktree(t, binPath, bareRepo.Root, "current-del")
501+
502+
// Delete current worktree from inside it (with shell integration)
503+
cmd := exec.Command(binPath, "-D", "current-del")
504+
cmd.Dir = wtPath
505+
cmd.Env = append(os.Environ(), "GIT_WT_SHELL_INTEGRATION=1")
506+
var stdoutBuf, stderrBuf bytes.Buffer
507+
cmd.Stdout = &stdoutBuf
508+
cmd.Stderr = &stderrBuf
509+
if err := cmd.Run(); err != nil {
510+
t.Fatalf("git-wt -D failed: %v\nstderr: %s", err, stderrBuf.String())
511+
}
512+
513+
// Worktree should be deleted
514+
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
515+
t.Error("worktree should have been deleted")
516+
}
517+
518+
// Shell integration should output bare root path
519+
assertLastLine(t, stdoutBuf.String(), bareRepo.Root)
520+
})
521+
522+
// --- Delete last worktree, cd back to bare root ---
523+
524+
t.Run("bare_delete_last_worktree_cd", func(t *testing.T) {
525+
t.Parallel()
526+
bareRepo := testutil.NewBareTestRepo(t)
527+
wtPath := createBareWorktree(t, binPath, bareRepo.Root, "only-wt")
528+
529+
// Delete from inside the worktree (with shell integration)
530+
cmd := exec.Command(binPath, "-D", "only-wt")
531+
cmd.Dir = wtPath
532+
cmd.Env = append(os.Environ(), "GIT_WT_SHELL_INTEGRATION=1")
533+
var stdoutBuf, stderrBuf bytes.Buffer
534+
cmd.Stdout = &stdoutBuf
535+
cmd.Stderr = &stderrBuf
536+
if err := cmd.Run(); err != nil {
537+
t.Fatalf("git-wt -D failed: %v\nstderr: %s", err, stderrBuf.String())
538+
}
539+
540+
// Worktree should be deleted
541+
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
542+
t.Error("worktree should have been deleted")
420543
}
421-
t.Cleanup(func() { os.RemoveAll(wtPath) })
422544

423-
// Run git-wt with -d flag (delete mode) inside the worktree
424-
out, err := runGitWt(t, binPath, wtPath, "-d", "main")
545+
// Shell integration should output bare root path
546+
assertLastLine(t, stdoutBuf.String(), bareRepo.Root)
547+
})
548+
549+
// --- Error: modified files ---
550+
551+
t.Run("bare_worktree_delete_modified", func(t *testing.T) {
552+
t.Parallel()
553+
bareRepo := testutil.NewBareTestRepo(t)
554+
wtPath := createBareWorktree(t, binPath, bareRepo.Root, "mod-wt")
555+
556+
// Add an untracked file
557+
if err := os.WriteFile(filepath.Join(wtPath, "untracked.txt"), []byte("content"), 0600); err != nil {
558+
t.Fatalf("failed to create untracked file: %v", err)
559+
}
560+
561+
// Safe delete should fail
562+
out, err := runGitWt(t, binPath, bareRepo.Root, "-d", "mod-wt")
425563
if err == nil {
426-
t.Fatalf("expected error for worktree from bare repo, but succeeded with output: %s", out)
564+
t.Fatal("git-wt -d should fail when worktree has untracked files")
565+
}
566+
if !strings.Contains(out, "use -D to force deletion") {
567+
t.Errorf("error should suggest '-D to force deletion', got: %s", out)
427568
}
428-
if !strings.Contains(out, "bare") {
429-
t.Errorf("error message should mention 'bare', got: %s", out)
569+
570+
// Worktree should still exist
571+
if _, err := os.Stat(wtPath); os.IsNotExist(err) {
572+
t.Error("worktree should NOT have been deleted")
573+
}
574+
})
575+
576+
// --- dotgit bare variant ---
577+
578+
t.Run("dotgit_bare_delete", func(t *testing.T) {
579+
t.Parallel()
580+
bareRepo := testutil.NewDotGitBareTestRepo(t)
581+
wtPath := createBareWorktree(t, binPath, bareRepo.Root, "dotgit-del")
582+
583+
// Safe delete from bare root
584+
out, err := runGitWt(t, binPath, bareRepo.Root, "-d", "dotgit-del")
585+
if err != nil {
586+
t.Fatalf("git-wt -d failed: %v\noutput: %s", err, out)
587+
}
588+
589+
// Worktree should be deleted
590+
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
591+
t.Error("worktree should have been deleted")
430592
}
431593
})
432594
}

e2e/helper_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,59 @@ func worktreePath(output string) string {
7474
}
7575
return lines[len(lines)-1]
7676
}
77+
78+
// createBareWorktree creates a worktree from the given directory using git-wt
79+
// and returns the worktree path.
80+
func createBareWorktree(t *testing.T, binPath, dir, branch string) string {
81+
t.Helper()
82+
83+
stdout, _, err := runGitWtStdout(t, binPath, dir, branch)
84+
if err != nil {
85+
t.Fatalf("failed to create worktree %q: %v\nstdout: %s", branch, err, stdout)
86+
}
87+
return worktreePath(stdout)
88+
}
89+
90+
// commitUnmergedChange creates a file and commits it in the given directory,
91+
// producing an unmerged commit relative to the parent branch.
92+
func commitUnmergedChange(t *testing.T, dir string) {
93+
t.Helper()
94+
95+
// Ensure git user config is available (bare repo worktrees may not
96+
// inherit user config when GIT_CONFIG_GLOBAL is /dev/null).
97+
for _, args := range [][]string{
98+
{"config", "user.email", "test@example.com"},
99+
{"config", "user.name", "Test User"},
100+
} {
101+
cmd := exec.Command("git", args...)
102+
cmd.Dir = dir
103+
if err := cmd.Run(); err != nil {
104+
t.Fatalf("git %s failed: %v", strings.Join(args, " "), err)
105+
}
106+
}
107+
108+
if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("content"), 0600); err != nil {
109+
t.Fatalf("failed to create file: %v", err)
110+
}
111+
cmd := exec.Command("git", "add", "-A")
112+
cmd.Dir = dir
113+
if err := cmd.Run(); err != nil {
114+
t.Fatalf("git add failed: %v", err)
115+
}
116+
cmd = exec.Command("git", "commit", "-m", "unmerged commit")
117+
cmd.Dir = dir
118+
if err := cmd.Run(); err != nil {
119+
t.Fatalf("git commit failed: %v", err)
120+
}
121+
}
122+
123+
// assertLastLine checks that the last line of output matches the expected string.
124+
func assertLastLine(t *testing.T, output, expected string) {
125+
t.Helper()
126+
127+
lines := strings.Split(strings.TrimSpace(output), "\n")
128+
lastLine := lines[len(lines)-1]
129+
if lastLine != expected {
130+
t.Errorf("last line should be %q, got %q", expected, lastLine)
131+
}
132+
}

internal/git/branch.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package git
22

33
import (
44
"context"
5+
"fmt"
56
"os"
67
"strings"
78
)
@@ -187,6 +188,24 @@ func DefaultBranch(ctx context.Context) (string, error) {
187188
return configBranch, nil
188189
}
189190

191+
// HeadBranch returns the branch name that HEAD points to.
192+
// Returns an error if HEAD is detached or not a symbolic ref.
193+
func HeadBranch(ctx context.Context) (string, error) {
194+
cmd, err := gitCommand(ctx, "symbolic-ref", "HEAD", "--short")
195+
if err != nil {
196+
return "", fmt.Errorf("failed to create git command: %w", err)
197+
}
198+
out, err := cmd.Output()
199+
if err != nil {
200+
return "", fmt.Errorf("failed to resolve HEAD (HEAD may be detached): %w", err)
201+
}
202+
branch := strings.TrimSpace(string(out))
203+
if branch == "" {
204+
return "", fmt.Errorf("symbolic-ref HEAD resolved to empty string")
205+
}
206+
return branch, nil
207+
}
208+
190209
// IsDefaultBranch checks if the given branch name is the default branch.
191210
func IsDefaultBranch(ctx context.Context, branch string) (bool, error) {
192211
defaultBranch, err := DefaultBranch(ctx)

0 commit comments

Comments
 (0)