Skip to content

Commit 1ac74c8

Browse files
authored
[fix] Atomicity and collision handling in rename and duplicate (#171) (#187)
* [fix] Rollback worktree on rename branch failure; guard duplicate collision B5: RenameWorktree now attempts to move the worktree back to its original path when git branch -m fails. If rollback succeeds the error surfaces "moved back" plus the retry hint; if rollback itself fails the error includes the rollback failure and a full manual-recovery command. B6: The branch and path collision checks in duplicate were already in place; this commit adds the missing test coverage — a unit test for the worktree-path collision path (branch absent, directory present) and an e2e test for `duplicate --as <existing-task>`. Closes #171 * [fix] Address review feedback: resolver-derived path + remove dead assertion - cmd/duplicate_test.go: derive worktree path via resolver.WorktreePath instead of hardcoded DirName-derived string so the test stays resilient to future DirName changes - tests/e2e/rename_test.go: remove dead `_ = newBranch` suppression (newBranch is already used to compute newPath which is asserted)
1 parent ce41820 commit 1ac74c8

6 files changed

Lines changed: 145 additions & 6 deletions

File tree

cmd/duplicate_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/lugassawan/rimba/internal/config"
11+
"github.com/lugassawan/rimba/internal/resolver"
1112
)
1213

1314
func TestDuplicateDefaultBranchError(t *testing.T) {
@@ -208,6 +209,62 @@ func TestDuplicateBranchAlreadyExists(t *testing.T) {
208209
}
209210
}
210211

212+
func TestDuplicateWorktreePathAlreadyExists(t *testing.T) {
213+
repoDir := t.TempDir()
214+
wtDir := filepath.Join(repoDir, "worktrees")
215+
_ = os.MkdirAll(wtDir, 0755)
216+
cfg := &config.Config{DefaultSource: branchMain, WorktreeDir: "worktrees"}
217+
218+
// Create the target worktree path on disk (branch doesn't exist, but directory does)
219+
destBranch := resolver.FullBranchName("", "feature/", "orphaned")
220+
destPath := resolver.WorktreePath(wtDir, destBranch)
221+
_ = os.MkdirAll(destPath, 0755)
222+
223+
worktreeOut := strings.Join([]string{
224+
wtPrefix + repoDir,
225+
headABC123,
226+
branchRefMain,
227+
"",
228+
wtFeatureLogin,
229+
headDEF456,
230+
branchRefFeatureLogin,
231+
"",
232+
}, "\n")
233+
234+
r := &mockRunner{
235+
run: func(args ...string) (string, error) {
236+
if len(args) >= 2 && args[1] == cmdGitCommonDir {
237+
return filepath.Join(repoDir, ".git"), nil
238+
}
239+
if len(args) >= 2 && args[1] == cmdShowToplevel {
240+
return repoDir, nil
241+
}
242+
if len(args) >= 1 && args[0] == cmdRevParse {
243+
return "", errGitFailed // BranchExists returns false
244+
}
245+
return worktreeOut, nil
246+
},
247+
runInDir: noopRunInDir,
248+
}
249+
restore := overrideNewRunner(r)
250+
defer restore()
251+
252+
cmd, _ := newTestCmd()
253+
cmd.Flags().String(flagAs, "", "")
254+
cmd.Flags().Bool(flagSkipDeps, false, "")
255+
cmd.Flags().Bool(flagSkipHooks, false, "")
256+
_ = cmd.Flags().Set(flagAs, "orphaned")
257+
cmd.SetContext(config.WithConfig(context.Background(), cfg))
258+
259+
err := duplicateCmd.RunE(cmd, []string{"login"})
260+
if err == nil {
261+
t.Fatal("expected error for existing worktree path")
262+
}
263+
if !strings.Contains(err.Error(), "already exists") {
264+
t.Errorf("error = %q, want 'already exists'", err.Error())
265+
}
266+
}
267+
211268
func TestDuplicateWorktreeNotFound(t *testing.T) {
212269
repoDir := t.TempDir()
213270
cfg := &config.Config{DefaultSource: branchMain, WorktreeDir: "worktrees"}

internal/operations/rename.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ func RenameWorktree(r git.Runner, wt resolver.WorktreeInfo, newTask, wtDir strin
3838
}
3939

4040
if err := git.RenameBranch(r, wt.Branch, newBranch); err != nil {
41-
return RenameResult{}, fmt.Errorf("worktree moved but failed to rename branch %q: %w\nTo complete manually: git branch -m %s %s", wt.Branch, err, wt.Branch, newBranch)
41+
if rbErr := git.MoveWorktree(r, newPath, wt.Path, force); rbErr != nil {
42+
return RenameResult{}, fmt.Errorf("failed to rename branch %q → %q: %w\nRollback failed — worktree is at %s: %w\nTo recover: git worktree move %s %s && git branch -m %s %s",
43+
wt.Branch, newBranch, err, newPath, rbErr, newPath, wt.Path, wt.Branch, newBranch)
44+
}
45+
return RenameResult{}, fmt.Errorf("failed to rename branch %q → %q: %w\nWorktree moved back to %s\nTo retry: git branch -m %s %s",
46+
wt.Branch, newBranch, err, wt.Path, wt.Branch, newBranch)
4247
}
4348

4449
return RenameResult{

internal/operations/rename_test.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
const (
1212
cmdRevParse = "rev-parse"
1313
cmdWorktree = "worktree"
14+
cmdMove = "move"
1415
wtDir = "/worktrees"
1516
branchAuth = "feature/auth"
1617
errMoveFailed = "move failed"
@@ -71,7 +72,7 @@ func TestRenameWorktreeMoveFails(t *testing.T) {
7172
if len(args) >= 1 && args[0] == cmdRevParse {
7273
return "", errGitFailed
7374
}
74-
if len(args) >= 2 && args[0] == cmdWorktree && args[1] == "move" {
75+
if len(args) >= 2 && args[0] == cmdWorktree && args[1] == cmdMove {
7576
return "", errors.New(errMoveFailed)
7677
}
7778
return "", nil
@@ -87,11 +88,16 @@ func TestRenameWorktreeMoveFails(t *testing.T) {
8788
}
8889

8990
func TestRenameWorktreeBranchRenameFails(t *testing.T) {
91+
moveCount := 0
9092
r := &mockRunner{
9193
run: func(args ...string) (string, error) {
9294
if len(args) >= 1 && args[0] == cmdRevParse {
9395
return "", errGitFailed
9496
}
97+
if len(args) >= 2 && args[0] == cmdWorktree && args[1] == cmdMove {
98+
moveCount++
99+
return "", nil
100+
}
95101
if len(args) >= 2 && args[0] == "branch" && args[1] == "-m" {
96102
return "", errors.New(errRenameFailed)
97103
}
@@ -105,8 +111,49 @@ func TestRenameWorktreeBranchRenameFails(t *testing.T) {
105111
if err == nil {
106112
t.Fatal("expected error from branch rename failure")
107113
}
108-
if !strings.Contains(err.Error(), "worktree moved but failed") {
109-
t.Errorf("error = %q, want 'worktree moved but failed'", err.Error())
114+
if !strings.Contains(err.Error(), "failed to rename branch") {
115+
t.Errorf("error = %q, want 'failed to rename branch'", err.Error())
116+
}
117+
if !strings.Contains(err.Error(), "moved back") {
118+
t.Errorf("error = %q, want 'moved back' (rollback confirmation)", err.Error())
119+
}
120+
if moveCount != 2 {
121+
t.Errorf("expected 2 worktree move calls (forward + rollback), got %d", moveCount)
122+
}
123+
}
124+
125+
func TestRenameWorktreeBranchRenameFailsRollbackFails(t *testing.T) {
126+
moveCount := 0
127+
r := &mockRunner{
128+
run: func(args ...string) (string, error) {
129+
if len(args) >= 1 && args[0] == cmdRevParse {
130+
return "", errGitFailed
131+
}
132+
if len(args) >= 2 && args[0] == cmdWorktree && args[1] == cmdMove {
133+
moveCount++
134+
if moveCount == 2 {
135+
return "", errors.New("rollback move failed")
136+
}
137+
return "", nil
138+
}
139+
if len(args) >= 2 && args[0] == "branch" && args[1] == "-m" {
140+
return "", errors.New(errRenameFailed)
141+
}
142+
return "", nil
143+
},
144+
runInDir: noopRunInDir,
145+
}
146+
147+
wt := resolver.WorktreeInfo{Branch: branchFeature, Path: pathWtFeatureLogin}
148+
_, err := RenameWorktree(r, wt, "auth", wtDir, false)
149+
if err == nil {
150+
t.Fatal("expected error from branch rename + rollback failure")
151+
}
152+
if !strings.Contains(err.Error(), "failed to rename branch") {
153+
t.Errorf("error = %q, want 'failed to rename branch'", err.Error())
154+
}
155+
if !strings.Contains(err.Error(), "Rollback failed") {
156+
t.Errorf("error = %q, want 'Rollback failed'", err.Error())
110157
}
111158
}
112159

tests/e2e/duplicate_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ func TestDuplicateCopiesDirectory(t *testing.T) {
176176
}
177177
}
178178

179+
func TestDuplicateAsCollision(t *testing.T) {
180+
if testing.Short() {
181+
t.Skip(skipE2E)
182+
}
183+
184+
repo := setupInitializedRepo(t)
185+
rimbaSuccess(t, repo, "add", taskDupColSrc)
186+
rimbaSuccess(t, repo, "add", taskDupColDst)
187+
188+
// Duplicate source --as destination that already exists: must fail before any writes.
189+
r := rimbaFail(t, repo, "duplicate", taskDupColSrc, "--as", taskDupColDst)
190+
assertContains(t, r.Stderr, "already exists")
191+
}
192+
179193
func TestDuplicateSourceNotFound(t *testing.T) {
180194
if testing.Short() {
181195
t.Skip(skipE2E)

tests/e2e/e2e_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const (
5151
taskDupA = "task-a"
5252
taskDupB = "task-b"
5353
taskMyCopy = "my-copy"
54+
taskDupColSrc = "dup-col-src"
55+
taskDupColDst = "dup-col-dst"
5456
secretContent = "SECRET=test"
5557

5658
// Merge test constants.

tests/e2e/rename_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,22 +129,36 @@ func TestRenameFailsBranchExists(t *testing.T) {
129129
assertContains(t, r.Stderr, "already exists")
130130
}
131131

132-
func TestRenamePartialFailBranchHint(t *testing.T) {
132+
func TestRenamePartialFailRollback(t *testing.T) {
133133
if testing.Short() {
134134
t.Skip(skipE2E)
135135
}
136136

137137
repo := setupInitializedRepo(t)
138138
rimbaSuccess(t, repo, "add", "--bugfix", "rn-partial-old")
139139

140+
cfg := loadConfig(t, repo)
141+
wtDir := filepath.Join(repo, cfg.WorktreeDir)
142+
oldBranch := resolver.BranchName(bugfixPrefix, "rn-partial-old")
143+
oldPath := resolver.WorktreePath(wtDir, oldBranch)
144+
newBranch := resolver.BranchName(bugfixPrefix, "rn-partial-new")
145+
newPath := resolver.WorktreePath(wtDir, newBranch)
146+
140147
// Create a sub-branch that blocks the rename target in the git ref namespace.
141148
// "bugfix/rn-partial-new/sub" makes bugfix/rn-partial-new a directory in
142149
// .git/refs/heads/, so git branch -m cannot create it as a file.
143150
testutil.GitCmd(t, repo, "branch", "bugfix/rn-partial-new/sub")
144151

145152
r := rimbaFail(t, repo, "rename", "rn-partial-old", "rn-partial-new")
146-
assertContains(t, r.Stderr, "worktree moved but failed to rename branch")
153+
154+
// Error should report the branch rename failure and successful rollback.
155+
assertContains(t, r.Stderr, "failed to rename branch")
156+
assertContains(t, r.Stderr, "moved back")
147157
assertContains(t, r.Stderr, "git branch -m")
158+
159+
// Worktree should be back at its original path (rollback succeeded).
160+
assertFileExists(t, oldPath)
161+
assertFileNotExists(t, newPath)
148162
}
149163

150164
func TestRenameFailsNoArgs(t *testing.T) {

0 commit comments

Comments
 (0)