diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go index 3e950ae0d5..13fe39c01f 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go @@ -389,6 +389,49 @@ func TestLocalPreparer_BaseBranchCheckout(t *testing.T) { } } +// TestLocalPreparer_FreshBranchInputs covers the post-fresh-branch state: the +// HTTP layer now persists the new branch name as the task's BaseBranch, so the +// preparer must successfully checkout that branch even when it was just +// created moments earlier. This is essentially the existing BaseBranch path — +// the test exists to catch any regression that breaks resume after a fresh +// checkout. +func TestLocalPreparer_FreshBranchPersistedAsBaseBranch(t *testing.T) { + isolateGitEnv(t) + log := newTestLocalLogger() + preparer := NewLocalPreparer(log) + + repoDir := initGitRepo(t) + env := newIsolatedGitEnv() + // Simulate fresh-branch having just created "feature/new" from main. + for _, args := range [][]string{ + {"checkout", "-b", "feature/new"}, + {"commit", "--allow-empty", "-m", "fresh"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repoDir + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %s", args, out) + } + } + + req := &EnvPrepareRequest{ + TaskID: "task-1", + RepositoryPath: repoDir, + BaseBranch: "feature/new", + } + result, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("Prepare() error: %v", err) + } + if !result.Success { + t.Fatalf("Prepare() failed: %s", result.ErrorMessage) + } + if branch := currentBranch(t, repoDir); branch != "feature/new" { + t.Fatalf("expected branch 'feature/new', got %q", branch) + } +} + func TestLocalPreparer_CheckoutBranchPriorityOverBaseBranch(t *testing.T) { isolateGitEnv(t) log := newTestLocalLogger() diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index dc7d46e162..f03c2ba6dd 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -306,8 +306,17 @@ type BranchDTO struct { } type RepositoryBranchesResponse struct { - Branches []BranchDTO `json:"branches"` - Total int `json:"total"` + Branches []BranchDTO `json:"branches"` + Total int `json:"total"` + CurrentBranch string `json:"current_branch,omitempty"` +} + +// LocalRepositoryStatusResponse reports current branch + dirty paths for a +// local repository on disk (no session required). Used by the task-create +// dialog to preflight the fresh-branch flow on local executors. +type LocalRepositoryStatusResponse struct { + CurrentBranch string `json:"current_branch"` + DirtyFiles []string `json:"dirty_files"` } type LocalRepositoryDTO struct { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 086f782685..aa4fdf6030 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -39,6 +39,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api.POST("/workspaces/:id/repositories", h.httpCreateRepository) api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) api.GET("/workspaces/:id/repositories/branches", h.httpListLocalRepositoryBranches) + api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus) api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath) api.GET("/repositories/:id", h.httpGetRepository) api.GET("/repositories/:id/branches", h.httpListRepositoryBranches) @@ -166,13 +167,45 @@ func (h *RepositoryHandlers) httpListLocalRepositoryBranches(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list local repository branches"}) return } + current, err := h.service.LocalRepositoryCurrentBranch(c.Request.Context(), path) + if err != nil { + // Branches succeeded — current branch is best-effort metadata for the UI. + h.logger.Debug("failed to read current branch", zap.Error(err)) + } dtoBranches := make([]dto.BranchDTO, len(branches)) for i, branch := range branches { dtoBranches[i] = dto.FromBranch(branch) } c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{ - Branches: dtoBranches, - Total: len(dtoBranches), + Branches: dtoBranches, + Total: len(dtoBranches), + CurrentBranch: current, + }) +} + +func (h *RepositoryHandlers) httpLocalRepositoryStatus(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + return + } + status, err := h.service.LocalRepositoryStatus(c.Request.Context(), path) + if err != nil { + if errors.Is(err, service.ErrPathNotAllowed) { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is not within allowed roots"}) + return + } + h.logger.Error("failed to read local repository status", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read local repository status"}) + return + } + dirty := status.DirtyFiles + if dirty == nil { + dirty = []string{} + } + c.JSON(http.StatusOK, dto.LocalRepositoryStatusResponse{ + CurrentBranch: status.CurrentBranch, + DirtyFiles: dirty, }) } @@ -240,7 +273,8 @@ func (h *RepositoryHandlers) httpGetRepository(c *gin.Context) { } func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) { - branches, err := h.service.ListRepositoryBranches(c.Request.Context(), c.Param("id")) + repoID := c.Param("id") + branches, err := h.service.ListRepositoryBranches(c.Request.Context(), repoID) if err != nil { if errors.Is(err, service.ErrPathNotAllowed) { c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not allowed"}) @@ -250,16 +284,33 @@ func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list repository branches"}) return } + current := h.currentBranchForWorkspaceRepo(c.Request.Context(), repoID) dtoBranches := make([]dto.BranchDTO, len(branches)) for i, branch := range branches { dtoBranches[i] = dto.FromBranch(branch) } c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{ - Branches: dtoBranches, - Total: len(dtoBranches), + Branches: dtoBranches, + Total: len(dtoBranches), + CurrentBranch: current, }) } +// currentBranchForWorkspaceRepo is best-effort; failures (repo not found, +// detached HEAD, IO error) return an empty string so the branches response +// still ships. +func (h *RepositoryHandlers) currentBranchForWorkspaceRepo(ctx context.Context, repoID string) string { + repo, err := h.service.GetRepository(ctx, repoID) + if err != nil || repo == nil || repo.LocalPath == "" { + return "" + } + branch, err := h.service.LocalRepositoryCurrentBranch(ctx, repo.LocalPath) + if err != nil { + return "" + } + return branch +} + type httpUpdateRepositoryRequest struct { Name *string `json:"name"` SourceType *string `json:"source_type"` diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index c3870a56f1..aa976da81f 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -14,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" ) @@ -317,6 +319,20 @@ type httpTaskRepositoryInput struct { Name string `json:"name"` DefaultBranch string `json:"default_branch"` GitHubURL string `json:"github_url"` + + // Fresh-branch flow (local executor only): when FreshBranch is true the + // handler discards uncommitted changes in the local clone and creates + // NewBranchName from BaseBranch before the task is persisted. + // ConfirmDiscard must be true if the working tree is dirty; otherwise + // the request is rejected with 409 + the dirty file list. + // ConsentedDirtyFiles is the dirty-file list the UI showed the user; + // the backend rejects with 409 if the live dirty set has any path + // that wasn't on this list, protecting against silent loss of files + // that became dirty between preflight and execution. + FreshBranch bool `json:"fresh_branch,omitempty"` + NewBranchName string `json:"new_branch_name,omitempty"` + ConfirmDiscard bool `json:"confirm_discard,omitempty"` + ConsentedDirtyFiles []string `json:"consented_dirty_files,omitempty"` } type httpCreateTaskRequest struct { @@ -444,6 +460,10 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { return } + if !h.commitFreshBranch(c, task.ID, title, body.WorkspaceID, body.Repositories, repos) { + return + } + taskDTO := dto.FromTask(task) response := createTaskResponse{TaskDTO: taskDTO} // Use the backend-resolved workflow step ID (from the created task) instead of the request's @@ -456,6 +476,158 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { c.JSON(http.StatusOK, response) } +// commitFreshBranch wraps the post-CreateTask fresh-branch sequence: run the +// destructive checkout, compensate by deleting the task if it fails, then +// persist the rewritten BaseBranch onto the now-existing task. Returns false +// when an HTTP error response was already written. +func (h *TaskHandlers) commitFreshBranch( + c *gin.Context, + taskID, title, workspaceID string, + inputs []httpTaskRepositoryInput, + repos []dto.TaskRepositoryInput, +) bool { + hasFresh := false + for _, raw := range inputs { + if raw.FreshBranch { + hasFresh = true + break + } + } + if !hasFresh { + // No fresh-branch opt-in — repos were already persisted by CreateTask, + // so skip the destructive checkout and the DELETE+INSERT rewrite. + return true + } + if !h.applyFreshBranch(c, title, inputs, repos) { + if delErr := h.service.DeleteTask(c.Request.Context(), taskID); delErr != nil { + h.logger.Warn("failed to compensate by deleting task after fresh-branch failure", + zap.String("task_id", taskID), zap.Error(delErr)) + } + return false + } + // Persist the rewritten BaseBranch (set by applyFreshBranch) onto the task. + // applyFreshBranch already mutated the git repo, so we can't roll back — + // but we must surface a 5xx so the caller knows the DB still references + // the user's original fork point. Otherwise every subsequent session + // resume would silently check out the old branch and abandon the new one. + if err := h.service.ReplaceTaskRepositories(c.Request.Context(), taskID, workspaceID, convertToServiceRepos(repos)); err != nil { + h.logger.Error("failed to persist fresh-branch base branch onto task", + zap.String("task_id", taskID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "fresh branch was created but the task record could not be updated; please check the repository", + }) + return false + } + return true +} + +// applyFreshBranch executes the fresh-branch flow for any local-executor +// repository inputs that opted in. Mutates `repos[i].BaseBranch` to the +// newly-created branch on success so the persisted task uses it as the +// effective base branch on every session resume. Writes the appropriate +// HTTP error response and returns false on failure. +// +// When the caller doesn't supply NewBranchName, the backend generates a +// semantic name from the task title (matching the worktree executor's +// branch-naming) so the user only has to flip a switch. +func (h *TaskHandlers) applyFreshBranch(c *gin.Context, taskTitle string, inputs []httpTaskRepositoryInput, repos []dto.TaskRepositoryInput) bool { + ctx := c.Request.Context() + for i, raw := range inputs { + if !raw.FreshBranch { + continue + } + repoPath, ok := h.resolveLocalRepoPath(c, raw) + if !ok { + return false + } + baseBranch := raw.BaseBranch + if baseBranch == "" { + // User didn't pick one — fall back to the repo's checked-out branch. + baseBranch, _ = h.service.LocalRepositoryCurrentBranch(ctx, repoPath) + } + newBranch := resolveFreshBranchName(raw.NewBranchName, taskTitle) + err := h.service.PerformFreshBranch(ctx, service.FreshBranchRequest{ + RepoPath: repoPath, + BaseBranch: baseBranch, + NewBranch: newBranch, + ConfirmDiscard: raw.ConfirmDiscard, + ConsentedDirtyFiles: raw.ConsentedDirtyFiles, + }) + if err != nil { + h.respondFreshBranchError(c, err) + return false + } + repos[i].BaseBranch = newBranch + repos[i].CheckoutBranch = "" + } + return true +} + +// resolveFreshBranchName returns the user-supplied branch name when present, +// otherwise generates one from the task title using the same semantic name + +// random-suffix scheme as the worktree executor. Returns an empty string only +// when both the raw name and the task title would produce nothing useful; +// PerformFreshBranch's sanitizeGitRef rejects that downstream. +func resolveFreshBranchName(rawNewBranch, taskTitle string) string { + if name := strings.TrimSpace(rawNewBranch); name != "" { + return name + } + return worktree.SemanticWorktreeName(taskTitle, worktree.SmallSuffix(3)) +} + +func (h *TaskHandlers) resolveLocalRepoPath(c *gin.Context, raw httpTaskRepositoryInput) (string, bool) { + if raw.LocalPath != "" { + return raw.LocalPath, true + } + if raw.RepositoryID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "fresh_branch requires repository_id or local_path"}) + return "", false + } + repo, err := h.service.GetRepository(c.Request.Context(), raw.RepositoryID) + if err != nil || repo == nil || repo.LocalPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository has no local path"}) + return "", false + } + return repo.LocalPath, true +} + +func (h *TaskHandlers) respondFreshBranchError(c *gin.Context, err error) { + var dirty *service.ErrDirtyWorkingTree + if errors.As(err, &dirty) { + c.JSON(http.StatusConflict, gin.H{ + "error": "working tree has uncommitted changes", + "dirty_files": dirty.DirtyFiles, + }) + return + } + if errors.Is(err, service.ErrPathNotAllowed) { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not within an allowed root"}) + return + } + if errors.Is(err, service.ErrInvalidGitRef) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, service.ErrFreshBranchCheckout) { + // git checkout failure (e.g. branch already exists, base branch unknown). + // Surface the underlying message — it tells the user what to fix. + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, service.ErrPartialDiscard) { + // Tracked files already gone, untracked survived. The user needs to + // know they have lost work before retrying — never a generic 500. + h.logger.Error("partial discard during fresh-branch", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "tracked changes were discarded but git clean failed; untracked files remain — inspect the repo before retrying", + "partial": true, + }) + return + } + h.logger.Error("fresh branch checkout failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare fresh branch"}) +} + // convertCreateTaskRepositories converts httpTaskRepositoryInput slice to dto.TaskRepositoryInput slice. // Returns (nil, false) and writes a 400 response if any entry is missing both repository_id and local_path. func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryInput) ([]dto.TaskRepositoryInput, bool) { diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index eb7819e380..c5d80fcdf6 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "strings" "sync" "testing" @@ -24,6 +25,63 @@ func newTestLogger(t *testing.T) *logger.Logger { return log } +func TestResolveFreshBranchName(t *testing.T) { + tests := []struct { + name string + raw string + taskTitle string + assert func(t *testing.T, got string) + }{ + { + name: "uses raw name when provided", + raw: "feature/x", + taskTitle: "ignored", + assert: func(t *testing.T, got string) { + if got != "feature/x" { + t.Fatalf("expected feature/x, got %q", got) + } + }, + }, + { + name: "trims whitespace from raw name", + raw: " feature/y ", + taskTitle: "ignored", + assert: func(t *testing.T, got string) { + if got != "feature/y" { + t.Fatalf("expected feature/y, got %q", got) + } + }, + }, + { + name: "derives from title when raw is empty", + raw: "", + taskTitle: "Fix login bug", + assert: func(t *testing.T, got string) { + if !strings.HasPrefix(got, "fix-login-bug_") { + t.Fatalf("expected fix-login-bug_ prefix, got %q", got) + } + }, + }, + { + name: "title with only special chars falls back to suffix only", + raw: "", + taskTitle: "!!!", + assert: func(t *testing.T, got string) { + // SemanticWorktreeName returns just the suffix (3 chars from + // the alphabet) when the sanitized title is empty. + if len(got) != 3 { + t.Fatalf("expected 3-char suffix, got %q (len %d)", got, len(got)) + } + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.assert(t, resolveFreshBranchName(tc.raw, tc.taskTitle)) + }) + } +} + func TestAssociatePRFromRepoInputs(t *testing.T) { log := newTestLogger(t) diff --git a/apps/backend/internal/task/service/fresh_branch.go b/apps/backend/internal/task/service/fresh_branch.go new file mode 100644 index 0000000000..3458f1886c --- /dev/null +++ b/apps/backend/internal/task/service/fresh_branch.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// gitRefRe accepts the conservative subset of git ref names we expose to user +// input: ASCII letters, digits, and `._/-`, with no leading `-` (which git +// would treat as an option). This rejects any value that could be interpreted +// as a flag by `git fetch` / `git checkout` even though we pass refs as +// positional argv entries. +var gitRefRe = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_./-]*$`) + +// ErrInvalidGitRef is returned by sanitizeGitRef when the supplied ref name +// doesn't match the allowlist. Returned errors wrap this sentinel so the +// HTTP layer can map it to a 400 response while preserving the descriptive +// inner message. +var ErrInvalidGitRef = errors.New("invalid git ref name") + +// ErrFreshBranchCheckout is returned when the underlying `git checkout` +// fails (e.g. the requested NewBranch already exists or BaseBranch can't be +// resolved). The caller can map this to a 4xx + the wrapped message. +var ErrFreshBranchCheckout = errors.New("checkout failed") + +// ErrPartialDiscard is returned when discarding the working tree partially +// succeeded — `git reset --hard` mutated tracked files but `git clean -fd` +// then failed (locked file, permission error, etc.) so untracked files +// survive. Callers must surface a distinct message: a generic 500 would +// hide the fact that some user work has already been destroyed. +var ErrPartialDiscard = errors.New("partial discard: tracked changes were reset but git clean failed") + +// FreshBranchRequest performs a destructive checkout on a local repository: +// discard uncommitted changes, then create NewBranch from BaseBranch. +// +// ConsentedDirtyFiles is the dirty-file list the caller already showed to +// the user. The backend re-reads dirty files at execution time and rejects +// the request (with the new list) if any path appears that wasn't on the +// consented list — this protects against silent loss of files that became +// dirty between the consent dialog and the actual discard. +type FreshBranchRequest struct { + RepoPath string + BaseBranch string + NewBranch string + ConfirmDiscard bool + ConsentedDirtyFiles []string +} + +// ErrDirtyWorkingTree is returned by PerformFreshBranch when the repository +// has uncommitted changes and the caller did not set ConfirmDiscard, OR +// when the dirty file set grew beyond the consented list. Callers should +// surface DirtyFiles to the user and re-issue the request with +// ConfirmDiscard=true and ConsentedDirtyFiles=DirtyFiles once consent is +// re-confirmed. +type ErrDirtyWorkingTree struct { + DirtyFiles []string +} + +func (e *ErrDirtyWorkingTree) Error() string { + return fmt.Sprintf("working tree has %d uncommitted change(s)", len(e.DirtyFiles)) +} + +// PerformFreshBranch validates the request, optionally discards uncommitted +// changes, then creates the new branch from the base branch. +// +// On success the repository is left checked out on NewBranch. The caller is +// responsible for persisting NewBranch as the task's effective base branch so +// that future session resumes return to it. +func (s *Service) PerformFreshBranch(ctx context.Context, req FreshBranchRequest) error { + // Validate refs against an allowlist before we shell out. The returned + // values are the trusted strings used for the rest of the function — we + // never use req.NewBranch / req.BaseBranch directly past this point. + newBranch, err := sanitizeGitRef(req.NewBranch, "new branch") + if err != nil { + return err + } + baseBranch, err := sanitizeGitRef(req.BaseBranch, "base branch") + if err != nil { + return err + } + absPath, err := s.resolveAllowedLocalPath(req.RepoPath) + if err != nil { + return err + } + + dirty, err := readGitDirtyFiles(ctx, absPath) + if err != nil { + return err + } + if len(dirty) > 0 { + if !req.ConfirmDiscard { + return &ErrDirtyWorkingTree{DirtyFiles: dirty} + } + consented := make(map[string]struct{}, len(req.ConsentedDirtyFiles)) + for _, p := range req.ConsentedDirtyFiles { + consented[p] = struct{}{} + } + for _, p := range dirty { + if _, ok := consented[p]; !ok { + return &ErrDirtyWorkingTree{DirtyFiles: dirty} + } + } + if err := discardLocalChanges(ctx, absPath); err != nil { + return err + } + } + + // Best-effort fetch so a remote-tracking ref like "origin/main" resolves. + _, _ = runGit(ctx, absPath, "fetch", "origin", baseBranch) + + // Use `-b` (not `-B`) so we refuse to overwrite an existing branch — that + // would silently orphan commits only reachable from it. + if out, err := runGit(ctx, absPath, "checkout", "-b", newBranch, baseBranch); err != nil { + return fmt.Errorf("%w: %q from %q: %s", ErrFreshBranchCheckout, newBranch, baseBranch, out) + } + return nil +} + +// sanitizeGitRef returns name unchanged when it matches the allowlist, or an +// error otherwise. The returned value is the trusted ref string callers +// should pass to git. +func sanitizeGitRef(name, label string) (string, error) { + if name == "" { + return "", fmt.Errorf("%w: %s is required", ErrInvalidGitRef, label) + } + if !gitRefRe.MatchString(name) || strings.Contains(name, "..") { + return "", fmt.Errorf("%w: %s %q", ErrInvalidGitRef, label, name) + } + return name, nil +} + +func discardLocalChanges(ctx context.Context, repoPath string) error { + if out, err := runGit(ctx, repoPath, "reset", "--hard"); err != nil { + return fmt.Errorf("git reset --hard: %w (%s)", err, out) + } + if out, err := runGit(ctx, repoPath, "clean", "-fd"); err != nil { + // reset already mutated tracked files; signal partial state so the + // HTTP handler can surface a distinct message instead of a generic 500. + return fmt.Errorf("%w: git clean -fd: %v (%s)", ErrPartialDiscard, err, out) + } + return nil +} + +func runGit(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} diff --git a/apps/backend/internal/task/service/fresh_branch_test.go b/apps/backend/internal/task/service/fresh_branch_test.go new file mode 100644 index 0000000000..2ffe97a407 --- /dev/null +++ b/apps/backend/internal/task/service/fresh_branch_test.go @@ -0,0 +1,355 @@ +package service + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPerformFreshBranch_CleanWorkingTree(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + + svc := newDiscoveryService(t, root) + err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, + BaseBranch: "main", + NewBranch: "feature/x", + }) + if err != nil { + t.Fatalf("PerformFreshBranch error: %v", err) + } + if got := readCurrentBranch(t, repoPath); got != "feature/x" { + t.Fatalf("expected current branch feature/x, got %q", got) + } +} + +func TestPerformFreshBranch_DirtyWithoutConfirm(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + writeDirty(t, repoPath, "untracked.txt", "hi") + + svc := newDiscoveryService(t, root) + err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, + BaseBranch: "main", + NewBranch: "feature/x", + }) + var dirty *ErrDirtyWorkingTree + if !errors.As(err, &dirty) { + t.Fatalf("expected ErrDirtyWorkingTree, got %v", err) + } + if len(dirty.DirtyFiles) == 0 { + t.Fatalf("expected dirty files in error") + } + // Branch must NOT have changed when caller didn't confirm. + if got := readCurrentBranch(t, repoPath); got != "main" { + t.Fatalf("expected branch unchanged on rejection, got %q", got) + } + // Untracked file must NOT have been deleted. + if _, err := os.Stat(filepath.Join(repoPath, "untracked.txt")); err != nil { + t.Fatalf("expected dirty file preserved, stat err: %v", err) + } +} + +func TestPerformFreshBranch_DirtyWithConfirm(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + writeDirty(t, repoPath, "untracked.txt", "hi") + + svc := newDiscoveryService(t, root) + err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, + BaseBranch: "main", + NewBranch: "feature/x", + ConfirmDiscard: true, + ConsentedDirtyFiles: []string{"untracked.txt"}, + }) + if err != nil { + t.Fatalf("PerformFreshBranch error: %v", err) + } + if got := readCurrentBranch(t, repoPath); got != "feature/x" { + t.Fatalf("expected branch feature/x, got %q", got) + } + if _, err := os.Stat(filepath.Join(repoPath, "untracked.txt")); !os.IsNotExist(err) { + t.Fatalf("expected dirty file removed, got err=%v", err) + } +} + +func TestPerformFreshBranch_DirtyWithExtraUnconsented(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + writeDirty(t, repoPath, "consented.txt", "hi") + writeDirty(t, repoPath, "extra.txt", "hi") + + svc := newDiscoveryService(t, root) + err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, + BaseBranch: "main", + NewBranch: "feature/x", + ConfirmDiscard: true, + ConsentedDirtyFiles: []string{"consented.txt"}, + }) + var dirty *ErrDirtyWorkingTree + if !errors.As(err, &dirty) { + t.Fatalf("expected ErrDirtyWorkingTree when extras present, got %v", err) + } + if len(dirty.DirtyFiles) != 2 { + t.Fatalf("expected 2 dirty files in error, got %d", len(dirty.DirtyFiles)) + } + // Repo must NOT have been mutated when consent didn't cover all files. + if got := readCurrentBranch(t, repoPath); got != "main" { + t.Fatalf("expected branch unchanged, got %q", got) + } + if _, err := os.Stat(filepath.Join(repoPath, "extra.txt")); err != nil { + t.Fatalf("expected extra.txt preserved, got err=%v", err) + } +} + +func TestPerformFreshBranch_RefusesExistingBranch(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + // Create an existing branch with a unique commit. + env := isolatedGitEnv() + for _, args := range [][]string{ + {"checkout", "-b", "feature/existing"}, + {"commit", "--allow-empty", "-m", "important work"}, + {"checkout", "main"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %s", args, out) + } + } + + svc := newDiscoveryService(t, root) + err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, + BaseBranch: "main", + NewBranch: "feature/existing", + }) + if err == nil { + t.Fatal("expected PerformFreshBranch to refuse overwriting an existing branch") + } +} + +func TestPerformFreshBranch_RejectsFlagLikeRefs(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + + svc := newDiscoveryService(t, root) + if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, BaseBranch: "main", NewBranch: "--upload-pack=evil", + }); err == nil { + t.Fatal("expected rejection for ref starting with --") + } + if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, BaseBranch: "-flag", NewBranch: "feature/x", + }); err == nil { + t.Fatal("expected rejection for ref starting with -") + } +} + +func TestPerformFreshBranch_RejectsEmptyFields(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + + svc := newDiscoveryService(t, root) + if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, NewBranch: "feature/x", + }); err == nil { + t.Fatal("expected error for empty BaseBranch") + } + if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{ + RepoPath: repoPath, BaseBranch: "main", + }); err == nil { + t.Fatal("expected error for empty NewBranch") + } +} + +func TestLocalRepositoryStatus_CleanRepo(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + + svc := newDiscoveryService(t, root) + status, err := svc.LocalRepositoryStatus(context.Background(), repoPath) + if err != nil { + t.Fatalf("LocalRepositoryStatus error: %v", err) + } + if status.CurrentBranch != "main" { + t.Fatalf("expected current branch main, got %q", status.CurrentBranch) + } + if len(status.DirtyFiles) != 0 { + t.Fatalf("expected clean tree, got dirty files: %v", status.DirtyFiles) + } +} + +func TestLocalRepositoryStatus_HandlesSpacesAndRenames(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + env := isolatedGitEnv() + // Commit "old name.txt" so we can rename it. + writeDirty(t, repoPath, "old name.txt", "hi") + for _, args := range [][]string{ + {"add", "old name.txt"}, + {"commit", "-m", "add file"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %s", args, out) + } + } + // Stage a rename + add an unicode-named untracked file. + for _, args := range [][]string{ + {"mv", "old name.txt", "renamed name.txt"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %s", args, out) + } + } + writeDirty(t, repoPath, "café.txt", "x") + + svc := newDiscoveryService(t, root) + status, err := svc.LocalRepositoryStatus(context.Background(), repoPath) + if err != nil { + t.Fatalf("LocalRepositoryStatus error: %v", err) + } + // Expect the rename target ("renamed name.txt") with its space preserved + // (no surrounding quotes), and the unicode untracked file un-escaped. + wantRename := "renamed name.txt" + wantUnicode := "café.txt" + got := status.DirtyFiles + hasRename := false + hasUnicode := false + for _, p := range got { + if p == wantRename { + hasRename = true + } + if p == wantUnicode { + hasUnicode = true + } + } + if !hasRename { + t.Fatalf("expected rename target %q in dirty files, got %#v", wantRename, got) + } + if !hasUnicode { + t.Fatalf("expected unicode path %q in dirty files, got %#v", wantUnicode, got) + } +} + +func TestLocalRepositoryStatus_ListsDirtyFiles(t *testing.T) { + isolateGitEnvForTest(t) + root := t.TempDir() + repoPath := filepath.Join(root, "repo") + initRealGitRepo(t, repoPath) + writeDirty(t, repoPath, "a.txt", "hi") + writeDirty(t, repoPath, "b.txt", "hi") + + svc := newDiscoveryService(t, root) + status, err := svc.LocalRepositoryStatus(context.Background(), repoPath) + if err != nil { + t.Fatalf("LocalRepositoryStatus error: %v", err) + } + if len(status.DirtyFiles) != 2 { + t.Fatalf("expected 2 dirty files, got %d: %v", len(status.DirtyFiles), status.DirtyFiles) + } +} + +// initRealGitRepo creates a git repo with a single commit on `main`. +func initRealGitRepo(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + env := isolatedGitEnv() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "core.hooksPath", "/dev/null"}, + {"commit", "--allow-empty", "-m", "init"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %s", args, out) + } + } +} + +func readCurrentBranch(t *testing.T, repoPath string) string { + t.Helper() + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = repoPath + out, err := cmd.Output() + if err != nil { + t.Fatalf("git rev-parse failed: %v", err) + } + return strings.TrimSpace(string(out)) +} + +func writeDirty(t *testing.T, repoPath, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(repoPath, name), []byte(content), 0o644); err != nil { + t.Fatalf("write dirty %q: %v", name, err) + } +} + +func isolatedGitEnv() []string { + env := make([]string, 0, len(os.Environ())) + for _, e := range os.Environ() { + key, _, _ := strings.Cut(e, "=") + if strings.HasPrefix(key, "GIT_") { + continue + } + env = append(env, e) + } + return append(env, + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@test.local", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@test.local", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + ) +} + +func isolateGitEnvForTest(t *testing.T) { + t.Helper() + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} { + if val, ok := os.LookupEnv(key); ok { + _ = os.Unsetenv(key) + t.Cleanup(func() { _ = os.Setenv(key, val) }) + } + } +} diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 5f51cbfec0..3864b030ed 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -16,6 +17,14 @@ type RepositoryDiscoveryConfig struct { MaxDepth int } +// LocalRepoStatus reports the current branch and dirty file paths for a +// local repository on disk. Used by the task-create dialog to preflight the +// fresh-branch flow. +type LocalRepoStatus struct { + CurrentBranch string + DirtyFiles []string +} + type LocalRepository struct { Path string Name string @@ -153,26 +162,99 @@ func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([] } func (s *Service) ListLocalRepositoryBranches(ctx context.Context, path string) ([]Branch, error) { - if path == "" { - return nil, fmt.Errorf("repository path is required") - } - absPath, err := filepath.Abs(path) - if err != nil { - return nil, fmt.Errorf("invalid repository path: %w", err) - } - if !isPathAllowed(absPath, s.discoveryRoots()) { - return nil, ErrPathNotAllowed - } - info, err := os.Stat(absPath) + absPath, err := s.resolveAllowedLocalPath(path) if err != nil { return nil, err } - if !info.IsDir() { - return nil, fmt.Errorf("repository path is not a directory") - } return listGitBranches(absPath) } +// LocalRepositoryCurrentBranch returns the currently checked-out branch for a +// local repository on disk. Returns the branch name (e.g. "main") or an empty +// string if HEAD is detached or unreadable. +func (s *Service) LocalRepositoryCurrentBranch(ctx context.Context, path string) (string, error) { + absPath, err := s.resolveAllowedLocalPath(path) + if err != nil { + return "", err + } + return readGitCurrentBranch(absPath, s.discoveryRoots()), nil +} + +// LocalRepositoryStatus returns the current branch and dirty file list for a +// local repository on disk. Used by the task-create dialog to preflight the +// fresh-branch flow before committing to a destructive checkout. +func (s *Service) LocalRepositoryStatus(ctx context.Context, path string) (LocalRepoStatus, error) { + absPath, err := s.resolveAllowedLocalPath(path) + if err != nil { + return LocalRepoStatus{}, err + } + dirty, err := readGitDirtyFiles(ctx, absPath) + if err != nil { + return LocalRepoStatus{}, err + } + return LocalRepoStatus{ + CurrentBranch: readGitCurrentBranch(absPath, s.discoveryRoots()), + DirtyFiles: dirty, + }, nil +} + +func (s *Service) resolveAllowedLocalPath(repoPath string) (string, error) { + if repoPath == "" { + return "", fmt.Errorf("repository path is required") + } + roots := s.discoveryRoots() + abs, err := filepath.Abs(filepath.Clean(repoPath)) + if err != nil { + return "", fmt.Errorf("invalid repository path: %w", err) + } + safe, err := pathWithinRoots(abs, roots) + if err != nil { + return "", err + } + // Resolve symlinks then re-validate so a symlink within a root cannot + // escape outside the allowed roots. + resolved, err := filepath.EvalSymlinks(safe) + if err != nil { + return "", err + } + resolvedSafe, err := pathWithinRoots(filepath.Clean(resolved), roots) + if err != nil { + return "", err + } + info, err := os.Stat(resolvedSafe) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("repository path is not a directory") + } + return resolvedSafe, nil +} + +// pathWithinRoots returns abs only when it sits inside one of the allowed +// roots after resolving relative segments. The returned string is the +// trusted, validated path; callers should never use the original input for +// subsequent file operations. +func pathWithinRoots(abs string, roots []string) (string, error) { + for _, root := range roots { + if root == "" { + continue + } + rel, err := filepath.Rel(root, abs) + if err != nil { + continue + } + if rel == "." { + return abs, nil + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + continue + } + return abs, nil + } + return "", ErrPathNotAllowed +} + func (s *Service) discoveryRoots() []string { if len(s.discoveryConfig.Roots) > 0 { return normalizeRoots(s.discoveryConfig.Roots) @@ -354,6 +436,69 @@ func isWithinRoot(path string, root string) bool { return strings.HasPrefix(absPath, absRoot) } +// readGitCurrentBranch returns the currently checked-out branch by reading +// .git/HEAD directly. Returns an empty string if HEAD is detached, the path +// is not a clean absolute path, the resolved git dir escapes the allowed +// roots, or HEAD is unreadable. We avoid `git rev-parse --abbrev-ref HEAD` +// to skip the subprocess cost on the hot path of branch discovery. +func readGitCurrentBranch(repoPath string, allowedRoots []string) string { + if !filepath.IsAbs(repoPath) { + return "" + } + cleanRepo := filepath.Clean(repoPath) + gitDir, err := resolveGitDirWithin(cleanRepo, allowedRoots) + if err != nil { + return "" + } + headPath := filepath.Clean(filepath.Join(gitDir, gitHEAD)) + if !filepath.IsAbs(headPath) { + return "" + } + content, err := os.ReadFile(headPath) + if err != nil { + return "" + } + trimmed := strings.TrimSpace(string(content)) + ref, ok := strings.CutPrefix(trimmed, "ref: ") + if !ok { + return "" + } + return strings.TrimPrefix(ref, "refs/heads/") +} + +// readGitDirtyFiles returns the list of dirty file paths in a repository, as +// reported by `git status --porcelain=v1 -z`. The `-z` form is NUL-terminated +// and disables path quoting, so paths with spaces, unicode, or control chars +// round-trip cleanly through the consent flow. Renames (status `R`/`C`) +// emit two NUL-separated records: the rename target then the original; we +// keep only the target since that's what's currently in the working tree. +// Returns an empty slice for a clean working tree. +func readGitDirtyFiles(ctx context.Context, repoPath string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain=v1", "-z") + cmd.Dir = repoPath + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git status: %w", err) + } + entries := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") + var paths []string + for i := 0; i < len(entries); i++ { + entry := entries[i] + if len(entry) < 4 { + continue + } + status := entry[:2] + path := entry[3:] + paths = append(paths, path) + // Rename / copy entries push an extra "old name" record after the + // "new name" record in -z mode; consume and skip it. + if status[0] == 'R' || status[0] == 'C' { + i++ + } + } + return paths, nil +} + func readGitDefaultBranch(repoPath string) (string, error) { gitDir, err := resolveGitDir(repoPath) if err != nil { @@ -402,6 +547,43 @@ func resolveGitDir(repoPath string) (string, error) { return filepath.Clean(filepath.Join(repoPath, gitDir)), nil } +// resolveGitDirWithin is the trust-boundary-aware variant of resolveGitDir: +// when `.git` is a file pointer (worktree case), the embedded `gitdir:` line +// can point anywhere on disk. This wrapper rejects any resolved gitdir that +// is not inside the repo path or one of the allowed discovery roots, so a +// crafted `.git` file inside an otherwise-allowed directory cannot make the +// caller read from outside the sandbox. +func resolveGitDirWithin(repoPath string, allowedRoots []string) (string, error) { + gitDir, err := resolveGitDir(repoPath) + if err != nil { + return "", err + } + // Resolve symlinks before the root check — `repoPath/.git` itself can be + // a symlink to a directory outside the sandbox, and a lexical Clean on + // the gitdir string would not catch that. + resolved, err := filepath.EvalSymlinks(gitDir) + if err != nil { + return "", err + } + cleaned := filepath.Clean(resolved) + if !filepath.IsAbs(cleaned) { + abs, absErr := filepath.Abs(cleaned) + if absErr != nil { + return "", absErr + } + cleaned = abs + } + if isWithinRoot(cleaned, repoPath) { + return cleaned, nil + } + for _, root := range allowedRoots { + if root != "" && isWithinRoot(cleaned, root) { + return cleaned, nil + } + } + return "", ErrPathNotAllowed +} + func resolveCommonGitDir(gitDir string) string { commonFile := filepath.Join(gitDir, "commondir") content, err := os.ReadFile(commonFile) diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index 80554675fc..d413f0eec0 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "os" "path/filepath" "sort" @@ -203,6 +204,91 @@ func TestResolveGitDir(t *testing.T) { } } +// canonicalTempDir returns a t.TempDir() path with symlinks resolved. macOS +// returns `/var/folders/...` from t.TempDir() while EvalSymlinks (used inside +// resolveGitDirWithin) yields `/private/var/folders/...`; without +// canonicalization the test's expected paths and the function's output live in +// different namespaces and equality checks fail. On Linux this is a no-op. +func canonicalTempDir(t *testing.T) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("eval temp dir: %v", err) + } + return resolved +} + +// TestResolveGitDirWithin_RejectsEscapedGitdir exercises the security-critical +// branch of resolveGitDirWithin: a repo whose `.git` is a file pointer +// (worktree style) whose embedded `gitdir:` points outside any allowed root. +// The function must return ErrPathNotAllowed; if the bounds check is ever +// removed, callers like readGitCurrentBranch would read arbitrary files on +// disk. +func TestResolveGitDirWithin_RejectsEscapedGitdir(t *testing.T) { + root := canonicalTempDir(t) + outside := canonicalTempDir(t) + + repoPath := filepath.Join(root, "repo") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + gitFile := filepath.Join(repoPath, ".git") + if err := os.WriteFile(gitFile, []byte("gitdir: "+outside+"\n"), 0o644); err != nil { + t.Fatalf("write .git pointer: %v", err) + } + + if _, err := resolveGitDirWithin(repoPath, []string{root}); !errors.Is(err, ErrPathNotAllowed) { + t.Fatalf("expected ErrPathNotAllowed for gitdir outside roots, got %v", err) + } +} + +// TestResolveGitDirWithin_RejectsSymlinkedGitDir covers the case where +// `.git` itself is a symlink whose target lives outside the allowed roots. +// The lexical path looks fine — it's `/repo/.git` — but following +// the symlink would read from elsewhere. EvalSymlinks must catch that. +func TestResolveGitDirWithin_RejectsSymlinkedGitDir(t *testing.T) { + root := canonicalTempDir(t) + outside := canonicalTempDir(t) + repoPath := filepath.Join(root, "repo") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + // `.git` symlinks to an out-of-root directory containing a HEAD file. + if err := os.Symlink(outside, filepath.Join(repoPath, ".git")); err != nil { + t.Skipf("symlink not supported on this platform: %v", err) + } + + if _, err := resolveGitDirWithin(repoPath, []string{root}); !errors.Is(err, ErrPathNotAllowed) { + t.Fatalf("expected ErrPathNotAllowed for symlinked .git escaping roots, got %v", err) + } +} + +// TestResolveGitDirWithin_AllowsRepoLocalGitFile covers the legitimate +// worktree case: `.git` file points to a sibling path that's still inside the +// repo (e.g. main repo's `.git/worktrees/`). +func TestResolveGitDirWithin_AllowsRepoLocalGitFile(t *testing.T) { + repoPath := canonicalTempDir(t) + worktreesDir := filepath.Join(repoPath, ".git", "worktrees", "wt") + if err := os.MkdirAll(worktreesDir, 0o755); err != nil { + t.Fatalf("mkdir worktrees: %v", err) + } + gitFile := filepath.Join(repoPath, "wt-clone", ".git") + if err := os.MkdirAll(filepath.Dir(gitFile), 0o755); err != nil { + t.Fatalf("mkdir wt-clone: %v", err) + } + if err := os.WriteFile(gitFile, []byte("gitdir: "+worktreesDir+"\n"), 0o644); err != nil { + t.Fatalf("write .git pointer: %v", err) + } + + got, err := resolveGitDirWithin(filepath.Dir(gitFile), []string{repoPath}) + if err != nil { + t.Fatalf("expected legitimate worktree to resolve, got %v", err) + } + if got != worktreesDir { + t.Fatalf("expected gitdir %q, got %q", worktreesDir, got) + } +} + func TestListGitBranches(t *testing.T) { repoPath := t.TempDir() gitDir := filepath.Join(repoPath, ".git") diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 7b666b81b9..549b040273 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -249,6 +249,14 @@ func parseGitHubRepoURL(rawURL string) (owner, name string, err error) { return parts[0], parts[1], nil } +// ReplaceTaskRepositories deletes all existing task-repository associations +// and recreates them. Exported for callers that mutate repository inputs +// (e.g. the fresh-branch flow rewriting BaseBranch) after CreateTask has +// already persisted the original set. +func (s *Service) ReplaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error { + return s.replaceTaskRepositories(ctx, taskID, workspaceID, repositories) +} + // replaceTaskRepositories deletes all existing task-repository associations and recreates them. func (s *Service) replaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error { if err := s.taskRepos.DeleteTaskRepositoriesByTask(ctx, taskID); err != nil { diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index c608120b5c..f43614f232 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -10,6 +10,7 @@ import type { RepositoryDiscoveryResponse, ListRepositoriesResponse, RepositoryBranchesResponse, + LocalRepositoryStatusResponse, ListRepositoryScriptsResponse, ListWorkspacesResponse, RepositoryPathValidationResponse, @@ -174,6 +175,16 @@ export async function listLocalRepositoryBranchesAction( ); } +export async function getLocalRepositoryStatusAction( + workspaceId: string, + path: string, +): Promise { + const params = `?path=${encodeURIComponent(path)}`; + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/local-status${params}`, + ); +} + export async function discoverRepositoriesAction( workspaceId: string, root?: string, diff --git a/apps/web/components/discard-local-changes-dialog.tsx b/apps/web/components/discard-local-changes-dialog.tsx new file mode 100644 index 0000000000..ed73ccbdd2 --- /dev/null +++ b/apps/web/components/discard-local-changes-dialog.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; + +const MAX_VISIBLE_FILES = 20; + +export type DiscardLocalChangesDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + dirtyFiles: string[]; + repoPath?: string; + onConfirm: () => void; + onCancel: () => void; +}; + +export function DiscardLocalChangesDialog({ + open, + onOpenChange, + dirtyFiles, + repoPath, + onConfirm, + onCancel, +}: DiscardLocalChangesDialogProps) { + const visible = dirtyFiles.slice(0, MAX_VISIBLE_FILES); + const overflow = dirtyFiles.length - visible.length; + const target = repoPath ? ` in your local clone at ${repoPath}` : " in your local clone"; + + return ( + + e.stopPropagation()} + > + + Discard local changes? + + Starting this task will permanently discard the uncommitted changes + {target}. Back up anything you want to keep before continuing. + + +
    + {visible.map((path) => ( +
  • + {path} +
  • + ))} + {overflow > 0 && ( +
  • + +{overflow} more +
  • + )} +
+ + + Cancel + + + Discard and continue + + +
+
+ ); +} diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 55e06f5bb3..66845c7b84 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -7,6 +7,7 @@ import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; import { discoverRepositoriesAction, + getLocalRepositoryStatusAction, listLocalRepositoryBranchesAction, } from "@/app/actions/workspaces"; import { getLocalStorage } from "@/lib/local-storage"; @@ -153,7 +154,8 @@ export function useLocalBranchesEffect( workspaceId: string | null, toast: ReturnType["toast"], ) { - const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading } = fs; + const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading, setCurrentLocalBranch } = + fs; useEffect(() => { if (!open || !workspaceId || !selectedLocalRepo) return; const repoPath = selectedLocalRepo.path; @@ -162,6 +164,7 @@ export function useLocalBranchesEffect( .then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath)) .then((r) => { setLocalBranches(r.branches); + setCurrentLocalBranch(r.current_branch ?? ""); }) .catch((e) => { toast({ @@ -170,11 +173,71 @@ export function useLocalBranchesEffect( variant: "error", }); setLocalBranches([]); + setCurrentLocalBranch(""); }) .finally(() => { setLocalBranchesLoading(false); }); - }, [open, selectedLocalRepo, toast, workspaceId, setLocalBranches, setLocalBranchesLoading]); + }, [ + open, + selectedLocalRepo, + toast, + workspaceId, + setLocalBranches, + setLocalBranchesLoading, + setCurrentLocalBranch, + ]); +} + +/** + * Resolves the absolute repo path for whichever repository the user has selected + * (workspace-attached or discovered) and fetches its currently-checked-out branch. + * Used by the disabled branch-selector placeholder so users see exactly which + * branch their local clone is on. + */ +export function useCurrentLocalBranchEffect( + fs: DialogFormState, + open: boolean, + workspaceId: string | null, + repositories: Repository[], +) { + const { repositoryId, selectedLocalRepo, useGitHubUrl, setCurrentLocalBranch } = fs; + useEffect(() => { + if (!open || !workspaceId || useGitHubUrl) { + setCurrentLocalBranch(""); + return; + } + let path = ""; + if (selectedLocalRepo) { + path = selectedLocalRepo.path; + } else if (repositoryId) { + const repo = repositories.find((r: Repository) => r.id === repositoryId); + if (repo?.local_path) path = repo.local_path; + } + if (!path) { + setCurrentLocalBranch(""); + return; + } + let cancelled = false; + getLocalRepositoryStatusAction(workspaceId, path) + .then((r) => { + if (!cancelled) setCurrentLocalBranch(r.current_branch ?? ""); + }) + .catch(() => { + if (!cancelled) setCurrentLocalBranch(""); + }); + return () => { + cancelled = true; + }; + }, [ + open, + workspaceId, + useGitHubUrl, + repositoryId, + selectedLocalRepo, + repositories, + setCurrentLocalBranch, + ]); } export function useDefaultSelectionsEffect( @@ -413,6 +476,7 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast); useBranchAutoSelectEffect(fs, branches); useLocalBranchesEffect(fs, open, workspaceId, toast); + useCurrentLocalBranchEffect(fs, open, workspaceId, repositories); useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows); useGitHubUrlBranchesEffect(fs, open); } diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index f059e454ea..a46f9a13d6 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -9,6 +9,10 @@ import { AgentLogo } from "@/components/agent-logo"; import type { DialogFormState } from "@/components/task-create-dialog-types"; import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; import { TaskFormInputs } from "@/components/task-create-dialog-selectors"; +import { + FreshBranchToggle, + computeBranchPlaceholder, +} from "@/components/task-create-dialog-fresh-branch"; import type { JiraTicket } from "@/lib/types/jira"; type SelectorOption = { @@ -67,94 +71,124 @@ type CreateEditSelectorsProps = { isLocalExecutor: boolean; useGitHubUrl: boolean; workflowAgentLocked: boolean; + freshBranchEnabled: boolean; + onToggleFreshBranch: (enabled: boolean) => void; + currentLocalBranch: string; }; -export const CreateEditSelectors = memo(function CreateEditSelectors({ - isTaskStarted, - hasRepositorySelection, - branchOptions, - branch, - onBranchChange, - branchesLoading, - localBranchesLoading, +type AgentColumnProps = Pick< + CreateEditSelectorsProps, + | "agentProfiles" + | "agentProfilesLoading" + | "agentProfileOptions" + | "agentProfileId" + | "onAgentProfileChange" + | "isCreatingSession" + | "AgentSelectorComponent" + | "workflowAgentLocked" +>; + +function AgentColumn({ agentProfiles, agentProfilesLoading, agentProfileOptions, agentProfileId, onAgentProfileChange, isCreatingSession, - executorProfileOptions, - executorProfileId, - onExecutorProfileChange, - executorsLoading, - isLocalExecutor, - useGitHubUrl, - workflowAgentLocked, - BranchSelectorComponent, AgentSelectorComponent, - ExecutorProfileSelectorComponent, -}: CreateEditSelectorsProps) { - if (isTaskStarted) return null; + workflowAgentLocked, +}: AgentColumnProps) { + if (agentProfiles.length === 0 && !agentProfilesLoading) { + return ( +
+ No agents found. + + Add agent + +
+ ); + } + const placeholder = agentProfilesLoading ? "Loading agents..." : "Select agent"; + return ( + <> + + {workflowAgentLocked && ( +

Agent set by workflow

+ )} + + ); +} - const agentLockedByWorkflow = workflowAgentLocked; +export const CreateEditSelectors = memo(function CreateEditSelectors( + props: CreateEditSelectorsProps, +) { + if (props.isTaskStarted) return null; + const { + hasRepositorySelection, + branchOptions, + branch, + onBranchChange, + branchesLoading, + localBranchesLoading, + executorProfileOptions, + executorProfileId, + onExecutorProfileChange, + executorsLoading, + isLocalExecutor, + useGitHubUrl, + freshBranchEnabled, + onToggleFreshBranch, + currentLocalBranch, + BranchSelectorComponent, + ExecutorProfileSelectorComponent, + } = props; const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl; - - const branchPlaceholder = (() => { - if (isLocalWithoutGitHubUrl) return "Uses current branch"; - if (!hasRepositorySelection) return "Select repository first"; - if (branchesLoading || localBranchesLoading) return "Loading branches..."; - return branchOptions.length > 0 ? "Select branch" : "No branches found"; - })(); - + const lockedToCurrentBranch = isLocalWithoutGitHubUrl && !freshBranchEnabled; + const branchPlaceholder = computeBranchPlaceholder({ + lockedToCurrentBranch, + currentLocalBranch, + hasRepositorySelection, + loading: branchesLoading || localBranchesLoading, + optionCount: branchOptions.length, + }); const branchDisabled = - isLocalWithoutGitHubUrl || + lockedToCurrentBranch || !hasRepositorySelection || branchesLoading || localBranchesLoading || branchOptions.length === 0; - - const agentPlaceholder = (() => { - if (agentProfilesLoading) return "Loading agents..."; - if (agentProfiles.length === 0) return "No agents available"; - return "Select agent"; - })(); + // When the local executor locks to the current branch, ignore any branch + // value the user picked under a different executor — the placeholder + // should reflect the actual checked-out branch instead. + const branchValue = lockedToCurrentBranch ? "" : branch; return (
-
- +
+
+ +
+ {isLocalWithoutGitHubUrl && ( + + )}
- {agentProfiles.length === 0 && !agentProfilesLoading ? ( -
- No agents found. - - Add agent - -
- ) : ( - <> - - {agentLockedByWorkflow && ( -

Agent set by workflow

- )} - - )} +
[0]; +type CreateTaskResponse = Awaited>; + +export function dirtyFilesFromApiError(err: unknown): string[] | null { + if (!(err instanceof ApiError) || err.status !== 409) return null; + const body = err.body as { dirty_files?: unknown } | null; + if (!body || !Array.isArray(body.dirty_files)) return null; + // Backend should never send an empty list with 409, but guard anyway so a + // malformed response doesn't open the consent dialog with no files in it. + const files = body.dirty_files.filter((p): p is string => typeof p === "string"); + return files.length > 0 ? files : null; +} + +export type PendingDiscard = { + dirtyFiles: string[]; + repoPath: string; + resolve: (confirmed: boolean) => void; +}; + +/** + * Result of the consent check: + * - `null` when the user cancelled (caller should abort) + * - empty array when fresh-branch is inactive or the working tree is clean + * (proceed without consent) + * - non-empty array of dirty paths the user explicitly OK'd discarding + */ +export type ConsentResult = string[] | null; + +type Args = { + isFreshBranchActive: boolean; + workspaceId: string | null; + repositoryLocalPath: string; + toast: ReturnType["toast"]; +}; + +/** + * Coordinates the destructive-checkout consent modal for the fresh-branch flow. + * + * `ensureFreshBranchConsent()` runs the preflight via `getLocalRepositoryStatusAction` + * and prompts only when the working tree is dirty. `confirmForDirtyFiles(list)` + * skips the preflight and reuses a list provided by the caller — used to + * recover when the backend rejects with 409 because new files became dirty + * between preflight and submit. + */ +export function useFreshBranchConsent({ + isFreshBranchActive, + workspaceId, + repositoryLocalPath, + toast, +}: Args) { + const [pendingDiscard, setPendingDiscard] = useState(null); + + const promptForList = useCallback( + (dirtyFiles: string[]): Promise => + new Promise((resolve) => { + setPendingDiscard({ + dirtyFiles, + repoPath: repositoryLocalPath, + resolve: (confirmed) => { + setPendingDiscard(null); + resolve(confirmed ? dirtyFiles : null); + }, + }); + }), + [repositoryLocalPath], + ); + + const ensureFreshBranchConsent = useCallback(async (): Promise => { + if (!isFreshBranchActive || !workspaceId) return []; + try { + const status = await getLocalRepositoryStatusAction(workspaceId, repositoryLocalPath); + if (status.dirty_files.length === 0) return []; + return await promptForList(status.dirty_files); + } catch (error) { + toast({ + title: "Failed to check local repository status", + description: error instanceof Error ? error.message : "Request failed", + variant: "error", + }); + return null; + } + }, [isFreshBranchActive, workspaceId, repositoryLocalPath, promptForList, toast]); + + /** + * Calls createTask once, then re-prompts the user with the updated dirty + * list and retries once if the backend returned 409 because the working + * tree gained new dirty files between the consent dialog and the actual + * discard. Returns null if the user cancelled the second prompt. + */ + const createTaskWithFreshBranchRetry = useCallback( + async ( + build: (consented: string[]) => CreateTaskParams, + initial: string[], + ): Promise => { + try { + return await createTask(build(initial)); + } catch (err) { + if (!isFreshBranchActive) throw err; + const dirty = dirtyFilesFromApiError(err); + if (!dirty) throw err; + const reconsented = await promptForList(dirty); + if (reconsented === null) return null; + return await createTask(build(reconsented)); + } + }, + [isFreshBranchActive, promptForList], + ); + + return { + pendingDiscard, + ensureFreshBranchConsent, + confirmForDirtyFiles: promptForList, + createTaskWithFreshBranchRetry, + }; +} diff --git a/apps/web/components/task-create-dialog-fresh-branch.tsx b/apps/web/components/task-create-dialog-fresh-branch.tsx new file mode 100644 index 0000000000..119b99d194 --- /dev/null +++ b/apps/web/components/task-create-dialog-fresh-branch.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { IconGitFork } from "@tabler/icons-react"; +import { Toggle } from "@kandev/ui/toggle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; + +const FRESH_BRANCH_TOOLTIP = + "Create a new branch from the selected base. Any uncommitted changes in your local clone will be discarded; you'll be asked to confirm if there are any."; + +export type FreshBranchToggleProps = { + enabled: boolean; + onToggle: (enabled: boolean) => void; +}; + +/** + * Compact icon toggle shown beside the branch selector for local executors. + * Pressed = "fork a new branch from the chosen base on submit". Matches the + * affordance pattern used by other inline toggles in the dialog (e.g. the + * paperclip in the prompt input). + */ +export function FreshBranchToggle({ enabled, onToggle }: FreshBranchToggleProps) { + return ( + + + + + + + {FRESH_BRANCH_TOOLTIP} + + ); +} + +export type BranchPlaceholderArgs = { + lockedToCurrentBranch: boolean; + currentLocalBranch: string; + hasRepositorySelection: boolean; + loading: boolean; + optionCount: number; +}; + +export function computeBranchPlaceholder({ + lockedToCurrentBranch, + currentLocalBranch, + hasRepositorySelection, + loading, + optionCount, +}: BranchPlaceholderArgs) { + if (lockedToCurrentBranch) return currentLocalBranch || "Uses current branch"; + if (!hasRepositorySelection) return "Select repository first"; + if (loading) return "Loading branches..."; + return optionCount > 0 ? "Select branch" : "No branches found"; +} diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts new file mode 100644 index 0000000000..88ed8dff9a --- /dev/null +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -0,0 +1,149 @@ +"use client"; + +import { useCallback } from "react"; +import type { Repository } from "@/lib/types/http"; +import { setLocalStorage } from "@/lib/local-storage"; +import { STORAGE_KEYS } from "@/lib/settings/constants"; +import type { DialogFormState } from "@/components/task-create-dialog-types"; + +function clearFreshBranch(fs: DialogFormState) { + fs.setFreshBranchEnabled(false); + fs.setCurrentLocalBranch(""); +} + +function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) { + const handleSelectLocalRepository = useCallback( + (path: string) => { + fs.setDiscoveredRepoPath(path); + fs.setSelectedLocalRepo(fs.discoveredRepositories.find((r) => r.path === path) ?? null); + fs.setRepositoryId(""); + fs.setBranch(""); + fs.setLocalBranches([]); + clearFreshBranch(fs); + }, + [fs], + ); + + const handleRepositoryChange = useCallback( + (value: string) => { + if (repositories.find((r: Repository) => r.id === value)) { + fs.setRepositoryId(value); + setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); + fs.setDiscoveredRepoPath(""); + fs.setSelectedLocalRepo(null); + fs.setLocalBranches([]); + fs.setBranch(""); + fs.setUseGitHubUrl(false); + fs.setGitHubUrl(""); + fs.setGitHubBranches([]); + clearFreshBranch(fs); + return; + } + handleSelectLocalRepository(value); + }, + [repositories, fs, handleSelectLocalRepository], + ); + + return { handleRepositoryChange, handleSelectLocalRepository }; +} + +function useBranchAndProfileHandlers(fs: DialogFormState) { + const handleAgentProfileChange = useCallback( + (value: string) => { + fs.setAgentProfileId(value); + setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value); + }, + [fs], + ); + const handleExecutorProfileChange = useCallback( + (value: string) => { + fs.setExecutorProfileId(value); + setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value); + }, + [fs], + ); + const handleTaskNameChange = useCallback( + (value: string) => { + fs.setTaskName(value); + fs.setHasTitle(value.trim().length > 0); + }, + [fs], + ); + const handleBranchChange = useCallback( + (value: string) => { + fs.setBranch(value); + setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); + }, + [fs], + ); + const handleWorkflowChange = useCallback( + (value: string) => fs.setSelectedWorkflowId(value), + [fs], + ); + return { + handleAgentProfileChange, + handleExecutorProfileChange, + handleTaskNameChange, + handleBranchChange, + handleWorkflowChange, + }; +} + +function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { + const handleToggleGitHubUrl = useCallback(() => { + const next = !fs.useGitHubUrl; + fs.setUseGitHubUrl(next); + if (next) { + fs.setRepositoryId(""); + fs.setDiscoveredRepoPath(""); + fs.setSelectedLocalRepo(null); + fs.setLocalBranches([]); + } else { + fs.setGitHubUrl(""); + fs.setGitHubBranches([]); + fs.setGitHubUrlError(null); + fs.setGitHubPrHeadBranch(null); + } + fs.setBranch(""); + clearFreshBranch(fs); + }, [fs]); + + const handleToggleFreshBranch = useCallback( + (enabled: boolean) => { + fs.setFreshBranchEnabled(enabled); + // Always clear the branch so we don't carry a value over from a + // different executor (e.g. "develop" picked under worktree) into the + // newly-enabled fresh-branch picker. + fs.setBranch(""); + }, + [fs], + ); + + const handleGitHubUrlChange = useCallback( + (value: string) => { + fs.setGitHubUrl(value); + fs.setBranch(""); + fs.setGitHubBranches([]); + fs.setGitHubUrlError(null); + fs.setGitHubPrHeadBranch(null); + }, + [fs], + ); + + return { + handleToggleGitHubUrl, + handleToggleFreshBranch, + handleGitHubUrlChange, + }; +} + +export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) { + const repo = useRepositoryHandlers(fs, repositories); + const profile = useBranchAndProfileHandlers(fs); + const gh = useGitHubAndFreshBranchHandlers(fs); + return { + handleRepositoryChange: repo.handleRepositoryChange, + ...profile, + ...gh, + }; +} diff --git a/apps/web/components/task-create-dialog-header.tsx b/apps/web/components/task-create-dialog-header.tsx new file mode 100644 index 0000000000..b6b6e32fa7 --- /dev/null +++ b/apps/web/components/task-create-dialog-header.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { DialogTitle } from "@kandev/ui/dialog"; +import { RepositorySelector, InlineTaskName } from "@/components/task-create-dialog-selectors"; +import type { useRepositoryOptions } from "@/components/task-create-dialog-options"; + +function getRepositoryPlaceholder( + workspaceId: string | null, + repositoriesLoading: boolean, + discoverReposLoading: boolean, +) { + if (!workspaceId) return "Select workspace first"; + if (repositoriesLoading || discoverReposLoading) return "Loading..."; + return "Select repository"; +} + +export type DialogHeaderContentProps = { + isCreateMode: boolean; + isEditMode: boolean; + isTaskStarted: boolean; + sessionRepoName?: string; + initialTitle?: string; + taskName: string; + repositoryId: string; + discoveredRepoPath: string; + workspaceId: string | null; + repositoriesLoading: boolean; + discoverReposLoading: boolean; + headerRepositoryOptions: ReturnType["headerRepositoryOptions"]; + onRepositoryChange: (v: string) => void; + onTaskNameChange: (v: string) => void; + useGitHubUrl: boolean; + githubUrl: string; + githubUrlError: string | null; + onToggleGitHubUrl: () => void; + onGitHubUrlChange: (v: string) => void; +}; + +function RepoSourceInput({ + useGitHubUrl, + githubUrl, + githubUrlError, + onGitHubUrlChange, + isTaskStarted, + headerRepositoryOptions, + repositoryId, + discoveredRepoPath, + onRepositoryChange, + workspaceId, + repositoriesLoading, + discoverReposLoading, +}: Pick< + DialogHeaderContentProps, + | "useGitHubUrl" + | "githubUrl" + | "githubUrlError" + | "onGitHubUrlChange" + | "isTaskStarted" + | "headerRepositoryOptions" + | "repositoryId" + | "discoveredRepoPath" + | "onRepositoryChange" + | "workspaceId" + | "repositoriesLoading" + | "discoverReposLoading" +>): React.ReactNode { + if (useGitHubUrl) { + return ( +
+ onGitHubUrlChange(e.target.value)} + placeholder="github.com/owner/repo" + data-testid="github-url-input" + aria-label="GitHub repository URL" + aria-invalid={!!githubUrlError} + aria-describedby={githubUrlError ? "github-url-error" : undefined} + size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)} + className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`} + disabled={isTaskStarted} + autoFocus + /> + {githubUrlError && ( + + )} +
+ ); + } + return ( + + ); +} + +export function DialogHeaderContent(props: DialogHeaderContentProps) { + const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props; + const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props; + + if (isCreateMode || isEditMode) { + return ( + <> + +
+ + / + +
+
+ {!isTaskStarted && ( +
+ +
+ )} + + ); + } + return ( + +
+ {sessionRepoName && ( + <> + {sessionRepoName} + / + + )} + {initialTitle || "Task"} + / + new session +
+
+ ); +} diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 4dadc33a98..f274d3fbde 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -160,6 +160,7 @@ export function buildRepositoriesPayload(opts: { githubPrHeadBranch: string | null; repositoryId: string; selectedLocalRepo: LocalRepository | null; + freshBranch?: { confirmDiscard: boolean; consentedDirtyFiles: string[] }; }): NonNullable { if (opts.useGitHubUrl && opts.githubUrl) { return [ @@ -171,12 +172,20 @@ export function buildRepositoriesPayload(opts: { }, ]; } + const fresh = opts.freshBranch + ? { + fresh_branch: true, + confirm_discard: opts.freshBranch.confirmDiscard, + consented_dirty_files: opts.freshBranch.consentedDirtyFiles, + } + : {}; if (opts.repositoryId) { return [ { repository_id: opts.repositoryId, base_branch: opts.branch || undefined, checkout_branch: opts.githubPrHeadBranch || undefined, + ...fresh, }, ]; } @@ -187,6 +196,7 @@ export function buildRepositoriesPayload(opts: { base_branch: opts.branch || undefined, local_path: opts.selectedLocalRepo.path, default_branch: opts.selectedLocalRepo.default_branch || undefined, + ...fresh, }, ]; } diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index f487e87f59..0efb0006b1 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -1,13 +1,7 @@ "use client"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; -import type { - LocalRepository, - Workspace, - Repository, - ExecutorProfile, - Branch, -} from "@/lib/types/http"; +import type { LocalRepository, Workspace, ExecutorProfile, Branch } from "@/lib/types/http"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; @@ -21,13 +15,7 @@ import { useExecutorProfileOptions, useIsLocalExecutor, } from "@/components/task-create-dialog-options"; -import { - setLocalStorage, - getTaskCreateDraft, - setTaskCreateDraft, - removeTaskCreateDraft, -} from "@/lib/local-storage"; -import { STORAGE_KEYS } from "@/lib/settings/constants"; +import { getTaskCreateDraft, setTaskCreateDraft, removeTaskCreateDraft } from "@/lib/local-storage"; import type { StepType, TaskCreateDialogInitialValues, @@ -67,6 +55,8 @@ type FormResetters = { setGitHubBranches: (v: Branch[]) => void; setGitHubUrlError: (v: string | null) => void; setGitHubPrHeadBranch: (v: string | null) => void; + setFreshBranchEnabled: (v: boolean) => void; + setCurrentLocalBranch: (v: string) => void; }; type FormResetEffectsArgs = { @@ -173,6 +163,8 @@ function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInit resetters.setGitHubBranches([]); resetters.setGitHubUrlError(null); resetters.setGitHubPrHeadBranch(iv?.checkoutBranch ?? null); + resetters.setFreshBranchEnabled(false); + resetters.setCurrentLocalBranch(""); } /** Hook to manage draft persistence for task creation dialog */ @@ -223,6 +215,17 @@ function useWorkflowAgentProfileState() { return { workflowAgentProfileId, setWorkflowAgentProfileId }; } +function useFreshBranchState() { + const [freshBranchEnabled, setFreshBranchEnabled] = useState(false); + const [currentLocalBranch, setCurrentLocalBranch] = useState(""); + return { + freshBranchEnabled, + setFreshBranchEnabled, + currentLocalBranch, + setCurrentLocalBranch, + }; +} + function useGitHubUrlState() { const [useGitHubUrl, setUseGitHubUrl] = useState(false); const [githubUrl, setGitHubUrl] = useState(""); @@ -354,6 +357,7 @@ export function useDialogFormState( const discovery = useDiscoveryState(); const ghUrl = useGitHubUrlState(); const wfAgent = useWorkflowAgentProfileState(); + const freshBranch = useFreshBranchState(); useFormResetEffects({ open, @@ -385,6 +389,8 @@ export function useDialogFormState( setGitHubBranches: ghUrl.setGitHubBranches, setGitHubUrlError: ghUrl.setGitHubUrlError, setGitHubPrHeadBranch: ghUrl.setGitHubPrHeadBranch, + setFreshBranchEnabled: freshBranch.setFreshBranchEnabled, + setCurrentLocalBranch: freshBranch.setCurrentLocalBranch, }, }); @@ -396,7 +402,7 @@ export function useDialogFormState( form.descriptionInputRef, ); - return { ...form, ...discovery, ...ghUrl, ...wfAgent, clearDraft }; + return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft }; } export type { DialogFormState } from "@/components/task-create-dialog-types"; @@ -407,111 +413,7 @@ export { } from "@/components/task-create-dialog-helpers"; export { useTaskCreateDialogEffects } from "@/components/task-create-dialog-effects"; -export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) { - const handleSelectLocalRepository = useCallback( - (path: string) => { - fs.setDiscoveredRepoPath(path); - fs.setSelectedLocalRepo(fs.discoveredRepositories.find((r) => r.path === path) ?? null); - fs.setRepositoryId(""); - fs.setBranch(""); - fs.setLocalBranches([]); - }, - [fs], - ); - - const handleRepositoryChange = useCallback( - (value: string) => { - if (repositories.find((r: Repository) => r.id === value)) { - fs.setRepositoryId(value); - setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); - fs.setDiscoveredRepoPath(""); - fs.setSelectedLocalRepo(null); - fs.setLocalBranches([]); - fs.setBranch(""); - fs.setUseGitHubUrl(false); - fs.setGitHubUrl(""); - fs.setGitHubBranches([]); - return; - } - handleSelectLocalRepository(value); - }, - [repositories, fs, handleSelectLocalRepository], - ); - - const handleAgentProfileChange = useCallback( - (value: string) => { - fs.setAgentProfileId(value); - setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value); - }, - [fs], - ); - const handleExecutorProfileChange = useCallback( - (value: string) => { - fs.setExecutorProfileId(value); - setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value); - }, - [fs], - ); - const handleTaskNameChange = useCallback( - (value: string) => { - fs.setTaskName(value); - fs.setHasTitle(value.trim().length > 0); - }, - [fs], - ); - const handleBranchChange = useCallback( - (value: string) => { - fs.setBranch(value); - setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); - }, - [fs], - ); - const handleWorkflowChange = useCallback( - (value: string) => { - fs.setSelectedWorkflowId(value); - }, - [fs], - ); - - const handleToggleGitHubUrl = useCallback(() => { - const next = !fs.useGitHubUrl; - fs.setUseGitHubUrl(next); - if (next) { - fs.setRepositoryId(""); - fs.setDiscoveredRepoPath(""); - fs.setSelectedLocalRepo(null); - fs.setLocalBranches([]); - } else { - fs.setGitHubUrl(""); - fs.setGitHubBranches([]); - fs.setGitHubUrlError(null); - fs.setGitHubPrHeadBranch(null); - } - fs.setBranch(""); - }, [fs]); - - const handleGitHubUrlChange = useCallback( - (value: string) => { - fs.setGitHubUrl(value); - fs.setBranch(""); - fs.setGitHubBranches([]); - fs.setGitHubUrlError(null); - fs.setGitHubPrHeadBranch(null); - }, - [fs], - ); - - return { - handleRepositoryChange, - handleAgentProfileChange, - handleExecutorProfileChange, - handleTaskNameChange, - handleBranchChange, - handleWorkflowChange, - handleToggleGitHubUrl, - handleGitHubUrlChange, - }; -} +export { useDialogHandlers } from "@/components/task-create-dialog-handlers"; export function useDialogComputed({ fs, diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index a630f7c298..35b058fb38 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -2,13 +2,14 @@ import { useCallback, FormEvent } from "react"; import { useRouter } from "next/navigation"; -import { createTask, updateTask } from "@/lib/api"; +import { updateTask } from "@/lib/api"; import { useAppStore } from "@/components/state-provider"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildStartRequest } from "@/lib/services/session-launch-helpers"; import { useToast } from "@/components/toast-provider"; import { linkToTask } from "@/lib/links"; import type { SubmitHandlersDeps } from "@/components/task-create-dialog-types"; +import { useFreshBranchConsent } from "@/components/task-create-dialog-fresh-branch-consent"; import { activatePlanMode, @@ -16,7 +17,6 @@ import { buildRepositoriesPayload, validateCreateInputs, toMessageAttachments, - type CreateTaskParams, } from "@/components/task-create-dialog-helpers"; const GENERIC_ERROR_MESSAGE = "An error occurred"; @@ -59,12 +59,42 @@ export function useTaskSubmitHandlers({ setSelectedWorkflowId, setFetchedSteps, clearDraft, + freshBranchEnabled, + isLocalExecutor, + repositoryLocalPath, }: SubmitHandlersDeps) { const router = useRouter(); const { toast } = useToast(); const setActiveDocument = useAppStore((state) => state.setActiveDocument); const setPlanMode = useAppStore((state) => state.setPlanMode); + const isFreshBranchActive = + freshBranchEnabled && isLocalExecutor && !useGitHubUrl && repositoryLocalPath !== ""; + const { pendingDiscard, ensureFreshBranchConsent, createTaskWithFreshBranchRetry } = + useFreshBranchConsent({ + isFreshBranchActive, + workspaceId, + repositoryLocalPath, + toast, + }); + + const buildFreshBranchPayload = (consentedDirtyFiles: string[]) => + isFreshBranchActive ? { confirmDiscard: true, consentedDirtyFiles } : undefined; + + const validateForCreate = useCallback( + (trimmedTitle: string) => + validateCreateInputs({ + trimmedTitle, + workspaceId, + effectiveWorkflowId, + repositoryId, + selectedLocalRepo, + githubUrl, + agentProfileId, + }), + [workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, githubUrl, agentProfileId], + ); + const resetForm = useCallback(() => { setHasTitle(false); setHasDescription(false); @@ -90,7 +120,7 @@ export function useTaskSubmitHandlers({ ]); const getRepositoriesPayload = useCallback( - () => + (consentedDirtyFiles: string[] = []) => buildRepositoriesPayload({ useGitHubUrl, githubUrl, @@ -98,8 +128,19 @@ export function useTaskSubmitHandlers({ githubPrHeadBranch, repositoryId, selectedLocalRepo, + freshBranch: buildFreshBranchPayload(consentedDirtyFiles), }), - [useGitHubUrl, repositoryId, branch, githubUrl, githubPrHeadBranch, selectedLocalRepo], + // buildFreshBranchPayload is intentionally a closure over current scope; lint exception kept narrow. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + useGitHubUrl, + repositoryId, + branch, + githubUrl, + githubPrHeadBranch, + selectedLocalRepo, + isFreshBranchActive, + ], ); const handleSessionSubmit = useCallback(async () => { @@ -232,36 +273,38 @@ export function useTaskSubmitHandlers({ } }, [performTaskUpdate, onSuccess, onOpenChange, toast, setIsCreatingTask]); - const performCreateWithAgent = useCallback( - async ( - trimmedTitle: string, - trimmedDescription: string, - repositoriesPayload: CreateTaskParams["repositories"], - planMode?: boolean, - attachments?: ReturnType, - ) => { + const performCreate = useCallback( + async (opts: { + trimmedTitle: string; + trimmedDescription: string; + consented: string[]; + withAgent: boolean; + planMode?: boolean; + attachments?: ReturnType; + }) => { if (!workspaceId || !effectiveWorkflowId) return; - const taskResponse = await createTask( + const buildPayload = (c: string[]) => buildCreateTaskPayload({ workspaceId, effectiveWorkflowId, - trimmedTitle, - trimmedDescription, - repositoriesPayload, + trimmedTitle: opts.trimmedTitle, + trimmedDescription: opts.trimmedDescription, + repositoriesPayload: getRepositoriesPayload(c), agentProfileId, executorId, executorProfileId, - withAgent: true, - planMode, - attachments, + withAgent: opts.withAgent, + planMode: opts.planMode, + attachments: opts.attachments, parentId: parentTaskId, - }), - ); + }); + const taskResponse = await createTaskWithFreshBranchRetry(buildPayload, opts.consented); + if (!taskResponse) return; const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); clearDraft(); onOpenChange(false); - if (planMode && newSessionId) { + if (opts.planMode && newSessionId) { activatePlanMode({ sessionId: newSessionId, taskId: taskResponse.id, @@ -269,7 +312,7 @@ export function useTaskSubmitHandlers({ setPlanMode, router, }); - } else if (isPassthroughProfile) { + } else if (opts.withAgent && isPassthroughProfile) { router.push(linkToTask(taskResponse.id)); } }, @@ -287,55 +330,21 @@ export function useTaskSubmitHandlers({ setActiveDocument, setPlanMode, router, + getRepositoriesPayload, + createTaskWithFreshBranchRetry, ], ); const handleCreatePlanMode = useCallback( - async (trimmedTitle: string, repositoriesPayload: CreateTaskParams["repositories"]) => { - if (!workspaceId || !effectiveWorkflowId) return; - const taskResponse = await createTask( - buildCreateTaskPayload({ - workspaceId, - effectiveWorkflowId, - trimmedTitle, - trimmedDescription: "", - repositoriesPayload, - agentProfileId, - executorId, - executorProfileId, - withAgent: false, - planMode: true, - parentId: parentTaskId, - }), - ); - const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; - onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); - clearDraft(); - onOpenChange(false); - if (newSessionId) { - activatePlanMode({ - sessionId: newSessionId, - taskId: taskResponse.id, - setActiveDocument, - setPlanMode, - router, - }); - } - }, - [ - workspaceId, - effectiveWorkflowId, - agentProfileId, - executorId, - executorProfileId, - parentTaskId, - onSuccess, - onOpenChange, - clearDraft, - setActiveDocument, - setPlanMode, - router, - ], + (trimmedTitle: string, consented: string[]) => + performCreate({ + trimmedTitle, + trimmedDescription: "", + consented, + withAgent: false, + planMode: true, + }), + [performCreate], ); const performEditWithPlanMode = useCallback(async () => { @@ -374,37 +383,38 @@ export function useTaskSubmitHandlers({ ]); const handleCreateWithPlanMode = useCallback(async () => { + if (isEditMode) { + setIsCreatingTask(true); + try { + await performEditWithPlanMode(); + } catch (error) { + toast({ + title: "Failed to start task in plan mode", + description: error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE, + variant: "error", + }); + } finally { + setIsCreatingTask(false); + } + return; + } + const trimmedTitle = taskName.trim(); + const description = descriptionInputRef.current?.getValue() ?? ""; + const trimmedDescription = description.trim(); + const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []); + if (!validateForCreate(trimmedTitle)) return; + const consent = await ensureFreshBranchConsent(); + if (consent === null) return; setIsCreatingTask(true); try { - if (isEditMode) { - await performEditWithPlanMode(); - } else { - const trimmedTitle = taskName.trim(); - const description = descriptionInputRef.current?.getValue() ?? ""; - const trimmedDescription = description.trim(); - const attachments = toMessageAttachments( - descriptionInputRef.current?.getAttachments() ?? [], - ); - if ( - !validateCreateInputs({ - trimmedTitle, - workspaceId, - effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - githubUrl, - agentProfileId, - }) - ) - return; - await performCreateWithAgent( - trimmedTitle, - trimmedDescription, - getRepositoriesPayload(), - true, - attachments, - ); - } + await performCreate({ + trimmedTitle, + trimmedDescription, + consented: consent, + withAgent: true, + planMode: true, + attachments, + }); } catch (error) { toast({ title: "Failed to start task in plan mode", @@ -418,14 +428,9 @@ export function useTaskSubmitHandlers({ isEditMode, performEditWithPlanMode, taskName, - workspaceId, - effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - githubUrl, - agentProfileId, - getRepositoriesPayload, - performCreateWithAgent, + validateForCreate, + ensureFreshBranchConsent, + performCreate, toast, descriptionInputRef, setIsCreatingTask, @@ -436,31 +441,21 @@ export function useTaskSubmitHandlers({ const description = descriptionInputRef.current?.getValue() ?? ""; const trimmedDescription = description.trim(); const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []); - if ( - !validateCreateInputs({ - trimmedTitle, - workspaceId, - effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - githubUrl, - agentProfileId, - }) - ) - return; - const repositoriesPayload = getRepositoriesPayload(); + if (!validateForCreate(trimmedTitle)) return; + const consent = await ensureFreshBranchConsent(); + if (consent === null) return; setIsCreatingTask(true); try { if (trimmedDescription || isPassthroughProfile) { - await performCreateWithAgent( + await performCreate({ trimmedTitle, trimmedDescription, - repositoriesPayload, - undefined, + consented: consent, + withAgent: true, attachments, - ); + }); } else { - await handleCreatePlanMode(trimmedTitle, repositoriesPayload); + await handleCreatePlanMode(trimmedTitle, consent); } } catch (error) { toast({ @@ -473,15 +468,10 @@ export function useTaskSubmitHandlers({ } }, [ taskName, - workspaceId, - effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - githubUrl, - agentProfileId, isPassthroughProfile, - getRepositoriesPayload, - performCreateWithAgent, + validateForCreate, + ensureFreshBranchConsent, + performCreate, handleCreatePlanMode, toast, descriptionInputRef, @@ -491,36 +481,31 @@ export function useTaskSubmitHandlers({ const handleCreateWithoutAgent = useCallback(async () => { const trimmedTitle = taskName.trim(); const trimmedDescription = (descriptionInputRef.current?.getValue() ?? "").trim(); - if ( - !validateCreateInputs({ - trimmedTitle, - workspaceId, - effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - githubUrl, - agentProfileId, - }) - ) - return; + if (!validateForCreate(trimmedTitle)) return; if (!trimmedDescription || !effectiveDefaultStepId || !workspaceId || !effectiveWorkflowId) return; + const consent = await ensureFreshBranchConsent(); + if (consent === null) return; setIsCreatingTask(true); try { - const payload = buildCreateTaskPayload({ - workspaceId, - effectiveWorkflowId, - trimmedTitle, - trimmedDescription, - repositoriesPayload: getRepositoriesPayload(), - agentProfileId, - executorId, - executorProfileId, - withAgent: false, - }); - payload.workflow_step_id = effectiveDefaultStepId; - const taskResponse = await createTask(payload); + const buildPayload = (c: string[]) => { + const p = buildCreateTaskPayload({ + workspaceId, + effectiveWorkflowId, + trimmedTitle, + trimmedDescription, + repositoriesPayload: getRepositoriesPayload(c), + agentProfileId, + executorId, + executorProfileId, + withAgent: false, + }); + p.workflow_step_id = effectiveDefaultStepId; + return p; + }; + const taskResponse = await createTaskWithFreshBranchRetry(buildPayload, consent); + if (!taskResponse) return; onSuccess?.(taskResponse, "create"); clearDraft(); onOpenChange(false); @@ -537,14 +522,14 @@ export function useTaskSubmitHandlers({ taskName, workspaceId, effectiveWorkflowId, - repositoryId, - selectedLocalRepo, - githubUrl, agentProfileId, effectiveDefaultStepId, executorId, executorProfileId, + validateForCreate, getRepositoriesPayload, + ensureFreshBranchConsent, + createTaskWithFreshBranchRetry, onSuccess, onOpenChange, clearDraft, @@ -575,5 +560,6 @@ export function useTaskSubmitHandlers({ handleCreateWithoutAgent, handleCreateWithPlanMode, handleCancel, + pendingDiscard, }; } diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index c44ebe8b83..c6917dd2b6 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -166,6 +166,12 @@ export type DialogFormState = { setWorkflowAgentProfileId: (v: string) => void; /** Clear draft on successful submission (before closing dialog) */ clearDraft: () => void; + /** Local executor only: opt-in to discard local changes and start the task on a new branch */ + freshBranchEnabled: boolean; + setFreshBranchEnabled: (v: boolean) => void; + /** Currently checked-out branch in the selected local repo (for the disabled selector placeholder) */ + currentLocalBranch: string; + setCurrentLocalBranch: (v: string) => void; }; export type SubmitHandlersDeps = { @@ -216,4 +222,8 @@ export type SubmitHandlersDeps = { setSelectedWorkflowId: (v: string | null) => void; setFetchedSteps: (v: null) => void; clearDraft: () => void; + freshBranchEnabled: boolean; + isLocalExecutor: boolean; + /** Resolved on-disk path for the selected repository (workspace or discovered). Empty if not local. */ + repositoryLocalPath: string; }; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 0b6cc1b57c..14e1564e0c 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -2,7 +2,7 @@ import { FormEvent, useCallback } from "react"; import type { JiraTicket } from "@/lib/types/jira"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; import type { Task } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import { SHORTCUTS } from "@/lib/keyboard/constants"; @@ -10,23 +10,19 @@ import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer"; +import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog"; +import { DialogHeaderContent } from "@/components/task-create-dialog-header"; import { CreateEditSelectors, SessionSelectors, WorkflowSection, DialogPromptSection, } from "@/components/task-create-dialog-form-body"; +import { useBranchOptions, useAgentProfileOptions } from "@/components/task-create-dialog-options"; import { - useRepositoryOptions, - useBranchOptions, - useAgentProfileOptions, -} from "@/components/task-create-dialog-options"; -import { - RepositorySelector, BranchSelector, AgentSelector, ExecutorProfileSelector, - InlineTaskName, } from "@/components/task-create-dialog-selectors"; import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit"; import { useToast } from "@/components/toast-provider"; @@ -75,164 +71,6 @@ interface TaskCreateDialogProps { parentTaskId?: string; } -function getRepositoryPlaceholder( - workspaceId: string | null, - repositoriesLoading: boolean, - discoverReposLoading: boolean, -) { - if (!workspaceId) return "Select workspace first"; - if (repositoriesLoading || discoverReposLoading) return "Loading..."; - return "Select repository"; -} - -type DialogHeaderContentProps = { - isCreateMode: boolean; - isEditMode: boolean; - isTaskStarted: boolean; - sessionRepoName?: string; - initialTitle?: string; - taskName: string; - repositoryId: string; - discoveredRepoPath: string; - workspaceId: string | null; - repositoriesLoading: boolean; - discoverReposLoading: boolean; - headerRepositoryOptions: ReturnType["headerRepositoryOptions"]; - onRepositoryChange: (v: string) => void; - onTaskNameChange: (v: string) => void; - useGitHubUrl: boolean; - githubUrl: string; - githubUrlError: string | null; - onToggleGitHubUrl: () => void; - onGitHubUrlChange: (v: string) => void; -}; - -function RepoSourceInput({ - useGitHubUrl, - githubUrl, - githubUrlError, - onGitHubUrlChange, - isTaskStarted, - headerRepositoryOptions, - repositoryId, - discoveredRepoPath, - onRepositoryChange, - workspaceId, - repositoriesLoading, - discoverReposLoading, -}: Pick< - DialogHeaderContentProps, - | "useGitHubUrl" - | "githubUrl" - | "githubUrlError" - | "onGitHubUrlChange" - | "isTaskStarted" - | "headerRepositoryOptions" - | "repositoryId" - | "discoveredRepoPath" - | "onRepositoryChange" - | "workspaceId" - | "repositoriesLoading" - | "discoverReposLoading" ->): React.ReactNode { - if (useGitHubUrl) { - return ( -
- onGitHubUrlChange(e.target.value)} - placeholder="github.com/owner/repo" - data-testid="github-url-input" - aria-label="GitHub repository URL" - aria-invalid={!!githubUrlError} - aria-describedby={githubUrlError ? "github-url-error" : undefined} - size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)} - className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`} - disabled={isTaskStarted} - autoFocus - /> - {githubUrlError && ( - - )} -
- ); - } - return ( - - ); -} - -function DialogHeaderContent(props: DialogHeaderContentProps) { - const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props; - const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props; - - if (isCreateMode || isEditMode) { - return ( - <> - -
- - / - -
-
- {!isTaskStarted && ( -
- -
- )} - - ); - } - return ( - -
- {sessionRepoName && ( - <> - {sessionRepoName} - / - - )} - {initialTitle || "Task"} - / - new session -
-
- ); -} - type DialogFormBodyProps = { isSessionMode: boolean; isCreateMode: boolean; @@ -267,6 +105,7 @@ type DialogFormBodyProps = { isLocalExecutor: boolean; enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; workflowAgentLocked: boolean; + onToggleFreshBranch: (enabled: boolean) => void; }; function DialogFormBody({ @@ -299,6 +138,7 @@ function DialogFormBody({ isLocalExecutor, enhance, workflowAgentLocked, + onToggleFreshBranch, }: DialogFormBodyProps) { return (
@@ -314,33 +154,26 @@ function DialogFormBody({ workspaceId={workspaceId} onJiraImport={onJiraImport} /> - {!isSessionMode && ( - - )} + {!isSessionMode && + renderCreateEditSelectors({ + isTaskStarted, + hasRepositorySelection, + branchOptions, + branchesLoading, + agentProfiles, + agentProfilesLoading, + agentProfileOptions, + isCreatingSession, + executorProfileOptions, + executorsLoading, + isLocalExecutor, + workflowAgentLocked, + fs, + onBranchChange, + onAgentProfileChange, + onExecutorProfileChange, + onToggleFreshBranch, + })} ; + +function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) { + const { fs } = args; + return ( + + ); +} + function useEnhanceForDialog(fs: DialogFormState) { const isConfigured = useIsUtilityConfigured(); const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ @@ -402,43 +290,27 @@ function useJiraImportHandler(fs: DialogFormState) { ); } -function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { - const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props; - const { editingTask, onSuccess, onCreateSession, initialValues, parentTaskId } = props; +type SubmitWiringArgs = { + props: TaskCreateDialogProps; + fs: ReturnType; + computed: ReturnType["computed"]; + repositoryLocalPath: string; + isSessionMode: boolean; + isEditMode: boolean; +}; + +function useSubmitHandlersWiring({ + props, + fs, + computed, + repositoryLocalPath, + isSessionMode, + isEditMode, +}: SubmitWiringArgs) { + const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props; + const { parentTaskId } = props; const taskId = props.taskId ?? null; - const isSessionMode = mode === "session"; - const isEditMode = mode === "edit"; - const isCreateMode = mode === "create"; - const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask); - const fs = useDialogFormState(open, workspaceId, workflowId, initialValues); - const { toast } = useToast(); - const sessionRepoName = useSessionRepoName(isSessionMode); - const { - workflows, - agentProfiles, - executors, - snapshots, - repositories, - repositoriesLoading, - branches, - branchesLoading, - computed, - } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); - useTaskCreateDialogEffects(fs, { - open, - workspaceId, - workflowId, - repositories, - repositoriesLoading, - branches, - agentProfiles, - executors, - workspaceDefaults: computed.workspaceDefaults, - toast, - workflows, - }); - const handlers = useDialogHandlers(fs, repositories); - const submitHandlers = useTaskSubmitHandlers({ + return useTaskSubmitHandlers({ isSessionMode, isEditMode, isPassthroughProfile: computed.isPassthroughProfile, @@ -475,6 +347,62 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { setSelectedWorkflowId: fs.setSelectedWorkflowId, setFetchedSteps: fs.setFetchedSteps, clearDraft: fs.clearDraft, + freshBranchEnabled: fs.freshBranchEnabled, + isLocalExecutor: computed.isLocalExecutor, + repositoryLocalPath, + }); +} + +function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { + const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props; + const { editingTask, initialValues } = props; + const isSessionMode = mode === "session"; + const isEditMode = mode === "edit"; + const isCreateMode = mode === "create"; + const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask); + const fs = useDialogFormState(open, workspaceId, workflowId, initialValues); + const { toast } = useToast(); + const sessionRepoName = useSessionRepoName(isSessionMode); + const { + workflows, + agentProfiles, + executors, + snapshots, + repositories, + repositoriesLoading, + branches, + branchesLoading, + computed, + } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); + const repositoryLocalPath = (() => { + if (fs.selectedLocalRepo) return fs.selectedLocalRepo.path; + if (fs.repositoryId) { + const repo = repositories.find((r) => r.id === fs.repositoryId); + return repo?.local_path ?? ""; + } + return ""; + })(); + useTaskCreateDialogEffects(fs, { + open, + workspaceId, + workflowId, + repositories, + repositoriesLoading, + branches, + agentProfiles, + executors, + workspaceDefaults: computed.workspaceDefaults, + toast, + workflows, + }); + const handlers = useDialogHandlers(fs, repositories); + const submitHandlers = useSubmitHandlersWiring({ + props, + fs, + computed, + repositoryLocalPath, + isSessionMode, + isEditMode, }); const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => { submitHandlers.handleSubmit(event as unknown as FormEvent); @@ -569,6 +497,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { isLocalExecutor={computed.isLocalExecutor} enhance={setup.enhance} workflowAgentLocked={computed.workflowAgentLocked} + onToggleFreshBranch={handlers.handleToggleFreshBranch} /> + ); } + +function PendingDiscardModal({ + pending, +}: { + pending: ReturnType["pendingDiscard"]; +}) { + if (!pending) return null; + return ( + { + if (!next) pending.resolve(false); + }} + dirtyFiles={pending.dirtyFiles} + repoPath={pending.repoPath} + onConfirm={() => pending.resolve(true)} + onCancel={() => pending.resolve(false)} + /> + ); +} diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts index aab6b554af..eb34e55e0b 100644 --- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts +++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts @@ -1,5 +1,9 @@ +import path from "node:path"; +import fs from "node:fs"; +import { execSync } from "node:child_process"; import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; +import { makeGitEnv } from "../../helpers/git-helper"; test.describe("Branch selector behavior with executor types", () => { test.describe.configure({ retries: 1 }); @@ -75,7 +79,7 @@ test.describe("Branch selector behavior with executor types", () => { // Select local executor -> branch should be disabled const executorSelector = testPage.getByTestId("executor-profile-selector"); await executorSelector.click(); - await testPage.getByRole("option", { name: /E2E Local/i }).click(); + await testPage.getByRole("option", { name: /^E2E Local Local$/i }).click(); const branchSelector = testPage.getByTestId("branch-selector"); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); @@ -161,3 +165,356 @@ test.describe("Branch selector behavior with executor types", () => { } }); }); + +/** + * Fresh-branch flow tests. + * + * Each test seeds an isolated repo inside backend.tmpDir so the discovery roots + * check passes, then opens the create-task dialog and exercises the toggle path. + * + * Important: the seedData repo is shared across the worker, so we mutate or + * inspect a NEW repo per test to avoid cross-test interference. We register the + * repo via apiClient.createRepository so it shows up in the workspace selector. + */ +test.describe("Fresh-branch flow", () => { + test.describe.configure({ retries: 1 }); + + type Setup = { + repoDir: string; + repositoryId: string; + profileId: string; + profileName: string; + }; + + type ApiClientType = import("../../helpers/api-client").ApiClient; + + async function setupLocalRepo( + apiClient: ApiClientType, + backendTmpDir: string, + workspaceId: string, + suffix: string, + ): Promise<(Setup & { repoName: string }) | null> { + const { executors } = await apiClient.listExecutors(); + const localExec = executors.find((e) => e.type === "local"); + if (!localExec) return null; + const profileName = `E2E Fresh Branch ${suffix}`; + const profile = await apiClient.createExecutorProfile(localExec.id, profileName); + + const repoName = `E2E Fresh Repo ${suffix}`; + const repoDir = path.join(backendTmpDir, "repos", `e2e-fresh-branch-${suffix}`); + fs.mkdirSync(repoDir, { recursive: true }); + const env = makeGitEnv(backendTmpDir); + execSync("git init -b main", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env }); + execSync("git checkout -b develop", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "develop"', { cwd: repoDir, env }); + execSync("git checkout main", { cwd: repoDir, env }); + const repo = await apiClient.createRepository(workspaceId, repoDir, "main", { name: repoName }); + return { repoDir, repositoryId: repo.id, profileId: profile.id, profileName, repoName }; + } + + async function openDialogWithLocalProfile( + testPage: import("@playwright/test").Page, + profileName: string, + repoName: string, + ) { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + await testPage.getByTestId("task-title-input").fill("Fresh Branch Test"); + await testPage.getByTestId("task-description-input").fill("testing fresh branch"); + await testPage.getByTestId("repository-selector").click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(repoName)}\\b`, "i") }) + .first() + .click(); + await testPage.getByTestId("executor-profile-selector").click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(profileName)}\\b`, "i") }) + .first() + .click(); + } + + function escapeRe(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + test("toggle off (default) — branch selector disabled, placeholder shows current branch", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "default"); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); + const toggle = testPage.getByTestId("fresh-branch-toggle"); + await expect(toggle).toBeVisible(); + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + const branchSelector = testPage.getByTestId("branch-selector"); + await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); + // Placeholder should show actual current branch (main), not the generic copy. + await expect(branchSelector).toContainText("main", { timeout: 5_000 }); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("toggle on, clean working tree — selector enabled, no discard modal on submit", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "clean"); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); + await testPage.getByTestId("fresh-branch-toggle").click(); + const branchSelector = testPage.getByTestId("branch-selector"); + await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); + // Pick the develop base branch so the new branch will fork from it. + await branchSelector.click(); + await testPage + .getByRole("option", { name: /develop/ }) + .first() + .click(); + + // Submit and assert the discard modal never appears (clean tree). + // Wait for the create-task request to fire so we know the submit path + // really executed and didn't short-circuit before the modal would render. + const createTaskRequest = testPage.waitForRequest( + (req) => req.url().endsWith("/api/v1/tasks") && req.method() === "POST", + ); + await testPage.getByTestId("submit-start-agent").click(); + await createTaskRequest; + await expect(testPage.getByTestId("discard-local-changes-dialog")).toHaveCount(0); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("toggle on, dirty working tree — confirm modal lists files; cancel keeps form state", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "dirty"); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + // Add an untracked file so `git status` reports it as dirty. + fs.writeFileSync(path.join(setup.repoDir, "WIP.txt"), "draft"); + + await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); + await testPage.getByTestId("fresh-branch-toggle").click(); + // Submit triggers the dirty preflight; the modal lists WIP.txt. + await testPage.getByTestId("submit-start-agent").click(); + + const modal = testPage.getByTestId("discard-local-changes-dialog"); + await expect(modal).toBeVisible({ timeout: 5_000 }); + await expect(testPage.getByTestId("discard-local-changes-files")).toContainText("WIP.txt"); + + // Cancel returns to the form with the toggle still on. + await testPage.getByTestId("discard-local-changes-cancel").click(); + await expect(modal).toBeHidden(); + await expect(testPage.getByTestId("fresh-branch-toggle")).toHaveAttribute( + "aria-pressed", + "true", + ); + + // Untracked file must still exist (we didn't confirm). + expect(fs.existsSync(path.join(setup.repoDir, "WIP.txt"))).toBe(true); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("dirty working tree — confirm overwrite removes the file and proceeds", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "confirm"); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + const wipPath = path.join(setup.repoDir, "WIP-confirm.txt"); + fs.writeFileSync(wipPath, "draft"); + + await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); + await testPage.getByTestId("fresh-branch-toggle").click(); + await testPage.getByTestId("submit-start-agent").click(); + + await expect(testPage.getByTestId("discard-local-changes-dialog")).toBeVisible({ + timeout: 5_000, + }); + await testPage.getByTestId("discard-local-changes-confirm").click(); + + // After confirm the backend runs reset --hard + clean -fd: the untracked + // file must be gone. + await expect.poll(() => fs.existsSync(wipPath), { timeout: 10_000 }).toBe(false); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("truncated dirty list — many dirty files show '+N more'", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "many"); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + // Seed 25 untracked files so the modal cap (20) kicks in. + for (let i = 0; i < 25; i++) { + fs.writeFileSync(path.join(setup.repoDir, `f${i}.txt`), "x"); + } + await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); + await testPage.getByTestId("fresh-branch-toggle").click(); + await testPage.getByTestId("submit-start-agent").click(); + + await expect(testPage.getByTestId("discard-local-changes-dialog")).toBeVisible({ + timeout: 5_000, + }); + await expect(testPage.getByTestId("discard-local-changes-overflow")).toContainText("+5 more"); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("GitHub URL flow hides fresh-branch toggle", async ({ testPage, apiClient }) => { + const { executors } = await apiClient.listExecutors(); + const localExec = executors.find((e) => e.type === "local"); + if (!localExec) { + test.skip(true, "No local executor available"); + return; + } + const profile = await apiClient.createExecutorProfile(localExec.id, "E2E Fresh Branch GH"); + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + await testPage.getByTestId("task-title-input").fill("Hide toggle"); + await testPage.getByTestId("task-description-input").fill("github url"); + await testPage.getByTestId("toggle-github-url").click(); + await testPage + .getByTestId("github-url-input") + .fill("https://github.com/branch-test-owner/branch-test-repo"); + await testPage.getByTestId("executor-profile-selector").click(); + await testPage.getByRole("option", { name: /E2E Fresh Branch GH/i }).click(); + + // Toggle is gated behind isLocalExecutor && !useGitHubUrl, so it must not render. + await expect(testPage.getByTestId("fresh-branch-toggle")).toHaveCount(0); + } finally { + await apiClient.deleteExecutorProfile(profile.id).catch(() => {}); + } + }); + + test("non-local executor (worktree) hides fresh-branch toggle", async ({ + testPage, + apiClient, + seedData, + }) => { + if (!seedData.worktreeExecutorProfileId) { + test.skip(true, "No worktree executor profile in seed data"); + return; + } + // Look up the actual profile name so the option matcher doesn't depend on + // the seed naming containing "worktree". + const { executors } = await apiClient.listExecutors(); + const worktreeProfileName = executors + .flatMap((e) => e.profiles ?? []) + .find((p) => p.id === seedData.worktreeExecutorProfileId)?.name; + if (!worktreeProfileName) { + test.skip(true, "Could not resolve worktree profile name"); + return; + } + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + await testPage.getByTestId("task-title-input").fill("No toggle for worktree"); + await testPage.getByTestId("task-description-input").fill("worktree mode"); + const executorSelector = testPage.getByTestId("executor-profile-selector"); + await executorSelector.click(); + await testPage.getByRole("option", { name: worktreeProfileName }).click(); + await expect(testPage.getByTestId("fresh-branch-toggle")).toHaveCount(0); + }); + + test("switching worktree → local resets the disabled selector to current branch", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "switch"); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + await testPage.getByTestId("task-title-input").fill("Switcheroo"); + await testPage.getByTestId("task-description-input").fill("repro the leftover-branch bug"); + await testPage.getByTestId("repository-selector").click(); + await testPage + .getByRole("option", { name: new RegExp(`^${setup.repoName}\\b`, "i") }) + .first() + .click(); + + // Pick the worktree executor first and select the develop branch. + const executorSelector = testPage.getByTestId("executor-profile-selector"); + await executorSelector.click(); + await testPage + .getByRole("option") + .filter({ hasText: /worktree/i }) + .first() + .click(); + const branchSelector = testPage.getByTestId("branch-selector"); + await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); + await branchSelector.click(); + await testPage + .getByRole("option", { name: /develop/ }) + .first() + .click(); + await expect(branchSelector).toContainText("develop"); + + // Switch back to local — disabled selector must show the actual current branch (main), + // not the develop selection that came from the worktree executor. + await executorSelector.click(); + await testPage + .getByRole("option", { name: new RegExp(`^${setup.profileName}\\b`, "i") }) + .first() + .click(); + await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); + await expect(branchSelector).toContainText("main", { timeout: 5_000 }); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); +}); diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts index 01ebadb1db..d89e5455f6 100644 --- a/apps/web/lib/api/client.ts +++ b/apps/web/lib/api/client.ts @@ -6,6 +6,22 @@ export type ApiRequestOptions = { init?: RequestInit; }; +/** + * Error thrown by fetchJson on non-2xx responses. `body` carries the parsed + * JSON response body (if any) so callers can react to structured fields like + * the dirty_files list returned with HTTP 409. + */ +export class ApiError extends Error { + readonly status: number; + readonly body: unknown; + constructor(message: string, status: number, body: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + function resolveUrl(pathOrUrl: string, baseUrl: string) { if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) { return pathOrUrl; @@ -13,6 +29,21 @@ function resolveUrl(pathOrUrl: string, baseUrl: string) { return `${baseUrl}${pathOrUrl}`; } +async function throwFromResponse(response: Response): Promise { + let body: unknown = null; + let message = `Request failed: ${response.status} ${response.statusText}`; + try { + body = await response.json(); + } catch { + // body remains null + } + if (body && typeof body === "object" && "error" in body) { + const errVal = (body as { error?: unknown }).error; + if (typeof errVal === "string") message = errVal; + } + throw new ApiError(message, response.status, body); +} + export async function fetchJson(pathOrUrl: string, options?: ApiRequestOptions): Promise { const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; const url = resolveUrl(pathOrUrl, baseUrl); @@ -24,25 +55,9 @@ export async function fetchJson(pathOrUrl: string, options?: ApiRequestOption ...(options?.init?.headers ?? {}), }, }); - if (!response.ok) { - // Try to extract error message from response body - let errorMessage = `Request failed: ${response.status} ${response.statusText}`; - try { - const errorBody = await response.json(); - if (errorBody?.error) { - errorMessage = errorBody.error; - } - } catch { - // Ignore JSON parse errors, use default message - } - throw new Error(errorMessage); - } - if (response.status === 204) { - return undefined as T; - } + if (!response.ok) await throwFromResponse(response); + if (response.status === 204) return undefined as T; const text = await response.text(); - if (!text) { - return undefined as T; - } + if (!text) return undefined as T; return JSON.parse(text) as T; } diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index fb800d3f33..ae1ef32758 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -53,6 +53,9 @@ export async function createTask( name?: string; default_branch?: string; github_url?: string; + fresh_branch?: boolean; + confirm_discard?: boolean; + consented_dirty_files?: string[]; }>; state?: Task["state"]; start_agent?: boolean; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 686fa14f81..68f9bf6083 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -494,6 +494,12 @@ export type Branch = { export type RepositoryBranchesResponse = { branches: Branch[]; total: number; + current_branch?: string; +}; + +export type LocalRepositoryStatusResponse = { + current_branch: string; + dirty_files: string[]; }; export type ListWorkspacesResponse = {