Skip to content

Commit 34144b2

Browse files
committed
app: unify workspace create pending-state handling
1 parent c06d0a4 commit 34144b2

5 files changed

Lines changed: 323 additions & 21 deletions

internal/app/app_input_messages_workspace.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ package app
22

33
import (
44
"errors"
5-
"path/filepath"
5+
"strings"
66

77
tea "charm.land/bubbletea/v2"
88

9-
"github.com/andyrewlee/amux/internal/data"
109
"github.com/andyrewlee/amux/internal/git"
1110
"github.com/andyrewlee/amux/internal/logging"
1211
"github.com/andyrewlee/amux/internal/messages"
@@ -89,18 +88,14 @@ func (a *App) handleWorkspaceActivated(msg messages.WorkspaceActivated) []tea.Cm
8988
// handleCreateWorkspace handles the CreateWorkspace message.
9089
func (a *App) handleCreateWorkspace(msg messages.CreateWorkspace) []tea.Cmd {
9190
var cmds []tea.Cmd
92-
if msg.Project != nil && msg.Name != "" {
93-
workspacePath := filepath.Join(
94-
a.config.Paths.WorkspacesRoot,
95-
msg.Project.Name,
96-
msg.Name,
97-
)
98-
pending := data.NewWorkspace(msg.Name, msg.Name, msg.Base, msg.Project.Path, workspacePath)
91+
name := strings.TrimSpace(msg.Name)
92+
if msg.Project != nil && name != "" && a.workspaceService != nil {
93+
pending := a.workspaceService.pendingWorkspace(msg.Project, name, msg.Base)
9994
if pending != nil {
10095
a.creatingWorkspaceIDs[string(pending.ID())] = true
101-
}
102-
if cmd := a.dashboard.SetWorkspaceCreating(pending, true); cmd != nil {
103-
cmds = append(cmds, cmd)
96+
if cmd := a.dashboard.SetWorkspaceCreating(pending, true); cmd != nil {
97+
cmds = append(cmds, cmd)
98+
}
10499
}
105100
}
106101
cmds = append(cmds, a.createWorkspace(msg.Project, msg.Name, msg.Base))
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package app
2+
3+
import (
4+
"errors"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/andyrewlee/amux/internal/data"
10+
"github.com/andyrewlee/amux/internal/messages"
11+
"github.com/andyrewlee/amux/internal/ui/dashboard"
12+
)
13+
14+
func TestHandleCreateWorkspaceSkipsPendingTrackingWithoutService(t *testing.T) {
15+
app := &App{
16+
dashboard: dashboard.New(),
17+
creatingWorkspaceIDs: make(map[string]bool),
18+
// workspaceService intentionally nil
19+
}
20+
21+
project := data.NewProject("/tmp/repo")
22+
msg := messages.CreateWorkspace{
23+
Project: project,
24+
Name: "feature",
25+
Base: "main",
26+
}
27+
28+
cmds := app.handleCreateWorkspace(msg)
29+
// Should not panic and should not track any pending IDs
30+
if len(app.creatingWorkspaceIDs) != 0 {
31+
t.Fatalf("expected no pending IDs without workspace service, got %d", len(app.creatingWorkspaceIDs))
32+
}
33+
// Should still return the createWorkspace cmd (which will be nil since service is nil)
34+
_ = cmds
35+
}
36+
37+
func TestHandleCreateWorkspaceTracksAndClearsPendingIDOnFailure(t *testing.T) {
38+
origCreate := createWorkspaceFn
39+
origTimeout := gitPathWaitTimeout
40+
t.Cleanup(func() {
41+
createWorkspaceFn = origCreate
42+
gitPathWaitTimeout = origTimeout
43+
})
44+
45+
gitErr := errors.New("git worktree add failed")
46+
createWorkspaceFn = func(repoPath, workspacePath, branch, base string) error {
47+
return gitErr
48+
}
49+
gitPathWaitTimeout = 50 * time.Millisecond
50+
51+
workspacesRoot := "/tmp/workspaces"
52+
store := data.NewWorkspaceStore(t.TempDir())
53+
svc := newWorkspaceService(nil, store, nil, workspacesRoot)
54+
55+
app := &App{
56+
dashboard: dashboard.New(),
57+
creatingWorkspaceIDs: make(map[string]bool),
58+
workspaceService: svc,
59+
}
60+
61+
project := data.NewProject("/tmp/repo")
62+
msg := messages.CreateWorkspace{
63+
Project: project,
64+
Name: "feature",
65+
Base: "main",
66+
}
67+
68+
// Step 1: handleCreateWorkspace should track the pending ID
69+
cmds := app.handleCreateWorkspace(msg)
70+
if len(app.creatingWorkspaceIDs) != 1 {
71+
t.Fatalf("expected 1 pending ID after handleCreateWorkspace, got %d", len(app.creatingWorkspaceIDs))
72+
}
73+
74+
// Capture the tracked ID
75+
var trackedID string
76+
for id := range app.creatingWorkspaceIDs {
77+
trackedID = id
78+
}
79+
80+
// Verify tracked ID matches expected path
81+
expectedPath := filepath.Join(workspacesRoot, project.Name, "feature")
82+
pending := svc.pendingWorkspace(project, "feature", "main")
83+
if pending == nil {
84+
t.Fatal("expected non-nil pending workspace")
85+
}
86+
if pending.Root != expectedPath {
87+
t.Fatalf("expected root %q, got %q", expectedPath, pending.Root)
88+
}
89+
if string(pending.ID()) != trackedID {
90+
t.Fatalf("tracked ID %q does not match pending workspace ID %q", trackedID, string(pending.ID()))
91+
}
92+
93+
// Step 2: Execute the create command to get the failure
94+
var createCmd func() interface{ String() string }
95+
_ = createCmd
96+
// Find the non-nil cmd (createWorkspace returns a tea.Cmd)
97+
for _, cmd := range cmds {
98+
if cmd == nil {
99+
continue
100+
}
101+
result := cmd()
102+
if failed, ok := result.(messages.WorkspaceCreateFailed); ok {
103+
// Step 3: handleWorkspaceCreateFailed should clear the pending ID
104+
app.handleWorkspaceCreateFailed(failed)
105+
if len(app.creatingWorkspaceIDs) != 0 {
106+
t.Fatalf("expected 0 pending IDs after failure, got %d", len(app.creatingWorkspaceIDs))
107+
}
108+
// Verify the failure workspace ID matches what was tracked
109+
if failed.Workspace != nil && string(failed.Workspace.ID()) != trackedID {
110+
t.Fatalf("failure workspace ID %q does not match tracked ID %q",
111+
string(failed.Workspace.ID()), trackedID)
112+
}
113+
return
114+
}
115+
}
116+
t.Fatal("expected at least one cmd to produce WorkspaceCreateFailed")
117+
}

internal/app/workspace_service.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -276,20 +276,27 @@ func (s *workspaceService) CreateWorkspace(project *data.Project, name, base str
276276
}
277277
}()
278278

279-
if project == nil || name == "" {
279+
if project == nil {
280280
return messages.WorkspaceCreateFailed{
281281
Err: errors.New("missing project or workspace name"),
282282
}
283283
}
284-
285-
workspacePath := filepath.Join(
286-
s.workspacesRoot,
287-
project.Name,
288-
name,
289-
)
290-
284+
name = strings.TrimSpace(name)
285+
if name == "" {
286+
return messages.WorkspaceCreateFailed{
287+
Err: errors.New("missing project or workspace name"),
288+
}
289+
}
290+
ws = s.pendingWorkspace(project, name, base)
291+
if ws == nil {
292+
return messages.WorkspaceCreateFailed{
293+
Err: errors.New("missing project or workspace name"),
294+
}
295+
}
296+
name = ws.Name
297+
base = ws.Base
298+
workspacePath := ws.Root
291299
branch := name
292-
ws = data.NewWorkspace(name, branch, base, project.Path, workspacePath)
293300

294301
if err := createWorkspaceFn(project.Path, workspacePath, branch, base); err != nil {
295302
return messages.WorkspaceCreateFailed{
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package app
2+
3+
import (
4+
"errors"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/andyrewlee/amux/internal/data"
10+
"github.com/andyrewlee/amux/internal/messages"
11+
)
12+
13+
func TestCreateWorkspaceNilProjectReturnsFailed(t *testing.T) {
14+
svc := newWorkspaceService(nil, nil, nil, "/tmp/workspaces")
15+
cmd := svc.CreateWorkspace(nil, "feature", "main")
16+
msg := cmd()
17+
failed, ok := msg.(messages.WorkspaceCreateFailed)
18+
if !ok {
19+
t.Fatalf("expected WorkspaceCreateFailed, got %T", msg)
20+
}
21+
if failed.Workspace != nil {
22+
t.Fatalf("expected nil workspace for nil project, got %+v", failed.Workspace)
23+
}
24+
if failed.Err == nil {
25+
t.Fatal("expected error, got nil")
26+
}
27+
}
28+
29+
func TestCreateWorkspaceEmptyNameReturnsFailed(t *testing.T) {
30+
project := data.NewProject("/tmp/repo")
31+
svc := newWorkspaceService(nil, nil, nil, "/tmp/workspaces")
32+
cmd := svc.CreateWorkspace(project, " ", "main")
33+
msg := cmd()
34+
failed, ok := msg.(messages.WorkspaceCreateFailed)
35+
if !ok {
36+
t.Fatalf("expected WorkspaceCreateFailed, got %T", msg)
37+
}
38+
if failed.Workspace != nil {
39+
t.Fatalf("expected nil workspace for empty name, got %+v", failed.Workspace)
40+
}
41+
if failed.Err == nil {
42+
t.Fatal("expected error, got nil")
43+
}
44+
}
45+
46+
func TestCreateWorkspaceGitFailureIncludesPendingWorkspace(t *testing.T) {
47+
origCreate := createWorkspaceFn
48+
t.Cleanup(func() { createWorkspaceFn = origCreate })
49+
50+
gitErr := errors.New("git worktree add failed")
51+
createWorkspaceFn = func(repoPath, workspacePath, branch, base string) error {
52+
return gitErr
53+
}
54+
55+
project := data.NewProject("/tmp/repo")
56+
svc := newWorkspaceService(nil, nil, nil, "/tmp/workspaces")
57+
cmd := svc.CreateWorkspace(project, "feature", "main")
58+
msg := cmd()
59+
failed, ok := msg.(messages.WorkspaceCreateFailed)
60+
if !ok {
61+
t.Fatalf("expected WorkspaceCreateFailed, got %T", msg)
62+
}
63+
if failed.Workspace == nil {
64+
t.Fatal("expected pending workspace in failure message")
65+
}
66+
if failed.Workspace.Name != "feature" {
67+
t.Fatalf("expected name 'feature', got %q", failed.Workspace.Name)
68+
}
69+
if failed.Workspace.Base != "main" {
70+
t.Fatalf("expected base 'main', got %q", failed.Workspace.Base)
71+
}
72+
if !errors.Is(failed.Err, gitErr) {
73+
t.Fatalf("expected git error, got %v", failed.Err)
74+
}
75+
}
76+
77+
func TestCreateWorkspaceEmptyBaseDefaultsToHEAD(t *testing.T) {
78+
origCreate := createWorkspaceFn
79+
t.Cleanup(func() { createWorkspaceFn = origCreate })
80+
81+
createWorkspaceFn = func(repoPath, workspacePath, branch, base string) error {
82+
return errors.New("stop")
83+
}
84+
85+
project := data.NewProject("/tmp/repo")
86+
svc := newWorkspaceService(nil, nil, nil, "/tmp/workspaces")
87+
cmd := svc.CreateWorkspace(project, "feature", "")
88+
msg := cmd()
89+
failed, ok := msg.(messages.WorkspaceCreateFailed)
90+
if !ok {
91+
t.Fatalf("expected WorkspaceCreateFailed, got %T", msg)
92+
}
93+
if failed.Workspace == nil {
94+
t.Fatal("expected pending workspace")
95+
}
96+
if failed.Workspace.Base != "HEAD" {
97+
t.Fatalf("expected base 'HEAD', got %q", failed.Workspace.Base)
98+
}
99+
}
100+
101+
func TestCreateWorkspacePendingMatchesAppSidePath(t *testing.T) {
102+
origCreate := createWorkspaceFn
103+
origTimeout := gitPathWaitTimeout
104+
t.Cleanup(func() {
105+
createWorkspaceFn = origCreate
106+
gitPathWaitTimeout = origTimeout
107+
})
108+
109+
gitErr := errors.New("git worktree add failed")
110+
createWorkspaceFn = func(repoPath, workspacePath, branch, base string) error {
111+
return gitErr
112+
}
113+
gitPathWaitTimeout = 50 * time.Millisecond
114+
115+
workspacesRoot := "/tmp/workspaces"
116+
project := data.NewProject("/tmp/repo")
117+
svc := newWorkspaceService(nil, nil, nil, workspacesRoot)
118+
119+
// Get the pending workspace the app side would use
120+
pending := svc.pendingWorkspace(project, "feature", "main")
121+
if pending == nil {
122+
t.Fatal("expected non-nil pending workspace")
123+
}
124+
125+
// Run CreateWorkspace and get the failure message
126+
cmd := svc.CreateWorkspace(project, "feature", "main")
127+
msg := cmd()
128+
failed, ok := msg.(messages.WorkspaceCreateFailed)
129+
if !ok {
130+
t.Fatalf("expected WorkspaceCreateFailed, got %T", msg)
131+
}
132+
if failed.Workspace == nil {
133+
t.Fatal("expected workspace in failure")
134+
}
135+
136+
// Core identity consistency: IDs must match
137+
if failed.Workspace.ID() != pending.ID() {
138+
t.Fatalf("workspace ID mismatch: service=%s pending=%s", failed.Workspace.ID(), pending.ID())
139+
}
140+
141+
// Verify the path is constructed consistently
142+
expectedPath := filepath.Join(workspacesRoot, project.Name, "feature")
143+
if failed.Workspace.Root != expectedPath {
144+
t.Fatalf("expected root %q, got %q", expectedPath, failed.Workspace.Root)
145+
}
146+
if pending.Root != expectedPath {
147+
t.Fatalf("expected pending root %q, got %q", expectedPath, pending.Root)
148+
}
149+
}

internal/app/workspace_service_paths.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,40 @@ func (s *workspaceService) managedProjectRoot() string {
3333
return lexicalWorkspacePath(base)
3434
}
3535

36+
func (s *workspaceService) pendingProjectRoot(project *data.Project) string {
37+
base := strings.TrimSpace(s.workspacesRoot)
38+
if base == "" {
39+
return ""
40+
}
41+
projectName := strings.TrimSpace(project.Name)
42+
if projectName == "" {
43+
projectName = filepath.Base(strings.TrimSpace(project.Path))
44+
}
45+
if projectName == "" {
46+
return ""
47+
}
48+
return filepath.Join(base, projectName)
49+
}
50+
51+
func (s *workspaceService) pendingWorkspace(project *data.Project, name, base string) *data.Workspace {
52+
if project == nil {
53+
return nil
54+
}
55+
name = strings.TrimSpace(name)
56+
if name == "" {
57+
return nil
58+
}
59+
base = strings.TrimSpace(base)
60+
if base == "" {
61+
base = "HEAD"
62+
}
63+
projectRoot := s.pendingProjectRoot(project)
64+
if projectRoot == "" {
65+
return nil
66+
}
67+
return data.NewWorkspace(name, name, base, project.Path, filepath.Join(projectRoot, name))
68+
}
69+
3670
func lexicalWorkspacePath(path string) string {
3771
value := strings.TrimSpace(path)
3872
if value == "" {

0 commit comments

Comments
 (0)