feat: create local repositories from new tasks (#1849)
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
@@ -38,6 +39,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) {
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/workspaces/:id/repositories", h.httpListRepositories)
|
||||
api.POST("/workspaces/:id/repositories", h.httpCreateRepository)
|
||||
api.POST("/workspaces/:id/repositories/initialize-local", h.httpInitializeLocalRepository)
|
||||
api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories)
|
||||
// Unified branch listing — accepts either ?repository_id= for an imported
|
||||
// workspace repo, or ?path= for an on-machine folder discovered but not
|
||||
@@ -49,6 +51,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus)
|
||||
api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath)
|
||||
api.GET("/fs/list-dir", h.httpListDirectory)
|
||||
api.POST("/fs/create-dir", h.httpCreateDirectory)
|
||||
api.GET("/repositories/:id", h.httpGetRepository)
|
||||
api.GET("/repositories/:id/branches", h.httpListRepositoryBranches)
|
||||
api.GET("/repositories/:id/active-session-count", h.httpGetRepositoryActiveSessionCount)
|
||||
@@ -154,14 +157,51 @@ func (h *RepositoryHandlers) httpListDirectory(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to list directory"})
|
||||
return
|
||||
}
|
||||
entries := make([]gin.H, 0, len(result.Entries))
|
||||
for _, e := range result.Entries {
|
||||
entries = append(entries, gin.H{"name": e.Name, "path": e.Path})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"path": result.Path,
|
||||
"parent": result.Parent,
|
||||
"entries": entries,
|
||||
"entries": directoryEntriesResponse(result.Entries),
|
||||
"choosable": result.Choosable,
|
||||
})
|
||||
}
|
||||
|
||||
func directoryEntriesResponse(entries []service.DirectoryEntry) []gin.H {
|
||||
response := make([]gin.H, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
response = append(response, gin.H{"name": entry.Name, "path": entry.Path})
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
type httpCreateDirectoryRequest struct {
|
||||
ParentPath string `json:"parent_path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpCreateDirectory(c *gin.Context) {
|
||||
var body httpCreateDirectoryRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateDirectory(c.Request.Context(), body.ParentPath, body.Name)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrDirectoryAlreadyExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "folder already exists"})
|
||||
case errors.Is(err, service.ErrInvalidDirectoryCreation),
|
||||
errors.Is(err, service.ErrInvalidLocalRepositoryInitialization):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder location or name"})
|
||||
default:
|
||||
h.logger.Warn("failed to create directory", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"path": result.Path,
|
||||
"parent": result.Parent,
|
||||
"entries": directoryEntriesResponse(result.Entries),
|
||||
"choosable": result.Choosable,
|
||||
})
|
||||
}
|
||||
@@ -280,6 +320,39 @@ type httpCreateRepositoryRequest struct {
|
||||
CopyFiles string `json:"copy_files"`
|
||||
}
|
||||
|
||||
type httpInitializeLocalRepositoryRequest struct {
|
||||
Name string `json:"name"`
|
||||
ParentPath string `json:"parent_path"`
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpInitializeLocalRepository(c *gin.Context) {
|
||||
var body httpInitializeLocalRepositoryRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
initialized, err := h.service.InitializeLocalRepository(c.Request.Context(), &service.InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
ParentPath: body.ParentPath,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrInvalidLocalRepositoryInitialization):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, repository.ErrWorkspaceNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
case errors.Is(err, service.ErrLocalRepositoryTargetExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "repository target already exists"})
|
||||
default:
|
||||
h.logger.Error("failed to initialize local repository", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize local repository"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, dto.FromRepository(initialized))
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) {
|
||||
var body httpCreateRepositoryRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -51,6 +53,115 @@ func TestHTTPCreateRepositoryRejectsInvalidLocalPathWithoutPersistence(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPInitializeLocalRepositoryCreatesRepository(t *testing.T) {
|
||||
router, repo := newRepositoryHTTPTestRouter(t)
|
||||
parentPath := t.TempDir()
|
||||
body := []byte(`{"name":"new-project","parent_path":` + strconv.Quote(parentPath) + `}`)
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/workspaces/ws-1/repositories/initialize-local",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusCreated, response.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
SourceType string `json:"source_type"`
|
||||
LocalPath string `json:"local_path"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("Unmarshal response: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(parentPath, "new-project")
|
||||
if created.WorkspaceID != "ws-1" || created.Name != "new-project" || created.SourceType != "local" ||
|
||||
created.LocalPath != wantPath || created.DefaultBranch != "main" {
|
||||
t.Fatalf("created repository = %+v, want workspace ws-1 local repository at %q on main", created, wantPath)
|
||||
}
|
||||
stored, err := repo.GetRepository(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepository: %v", err)
|
||||
}
|
||||
if stored.LocalPath != wantPath || stored.DefaultBranch != "main" {
|
||||
t.Fatalf("stored repository = %+v, want path %q on main", stored, wantPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPInitializeLocalRepositoryMapsClientErrors(t *testing.T) {
|
||||
t.Run("invalid input", func(t *testing.T) {
|
||||
router, _ := newRepositoryHTTPTestRouter(t)
|
||||
response := performInitializeLocalRepositoryRequest(t, router, "ws-1", `{"name":"nested/name","parent_path":"/tmp"}`)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown workspace before mutation", func(t *testing.T) {
|
||||
router, _ := newRepositoryHTTPTestRouter(t)
|
||||
parent := t.TempDir()
|
||||
body := `{"name":"new-project","parent_path":` + strconv.Quote(parent) + `}`
|
||||
response := performInitializeLocalRepositoryRequest(t, router, "missing", body)
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusNotFound, response.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target stat error = %v, want not exist", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing target", func(t *testing.T) {
|
||||
router, repo := newRepositoryHTTPTestRouter(t)
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "existing")
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir target: %v", err)
|
||||
}
|
||||
marker := filepath.Join(target, "marker")
|
||||
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile marker: %v", err)
|
||||
}
|
||||
body := `{"name":"existing","parent_path":` + strconv.Quote(parent) + `}`
|
||||
response := performInitializeLocalRepositoryRequest(t, router, "ws-1", body)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusConflict, response.Body.String())
|
||||
}
|
||||
content, err := os.ReadFile(marker)
|
||||
if err != nil || string(content) != "keep" {
|
||||
t.Fatalf("marker = %q, error %v; existing target was modified", content, err)
|
||||
}
|
||||
repositories, err := repo.ListRepositories(context.Background(), "ws-1")
|
||||
if err != nil || len(repositories) != 0 {
|
||||
t.Fatalf("repositories = %+v, error %v; want none", repositories, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func performInitializeLocalRepositoryRequest(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
workspaceID string,
|
||||
body string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/workspaces/"+workspaceID+"/repositories/initialize-local",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestHTTPCreateRepositoryIgnoresRemoteURL(t *testing.T) {
|
||||
router, repo := newRepositoryHTTPTestRouter(t)
|
||||
request := httptest.NewRequest(
|
||||
@@ -176,6 +287,31 @@ func TestHTTPListDirectoryIncludesChoosableContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPCreateDirectoryCreatesFolder(t *testing.T) {
|
||||
router, _ := newRepositoryHTTPTestRouter(t)
|
||||
parent := t.TempDir()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/fs/create-dir",
|
||||
strings.NewReader(`{"parent_path":`+strconv.Quote(parent)+`,"name":"projects"}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusCreated, response.Body.String())
|
||||
}
|
||||
wantPath := filepath.Join(parent, "projects")
|
||||
if info, err := os.Stat(wantPath); err != nil || !info.IsDir() {
|
||||
t.Fatalf("created directory %q: info=%v error=%v", wantPath, info, err)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), strconv.Quote(wantPath)) {
|
||||
t.Fatalf("response missing created path %q: %s", wantPath, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPListBranchesRejectsRepositoryFromAnotherWorkspace(t *testing.T) {
|
||||
router, repo, svc := newRepositoryHTTPTestRouterWithService(t)
|
||||
for _, workspaceID := range []string{"ws-a", "ws-b"} {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -168,3 +169,28 @@ func TestListDirectory_ParentEmptyAtFilesystemRoot(t *testing.T) {
|
||||
t.Errorf("filesystem root %q should be choosable", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDirectoryRejectsInvalidOrExistingChild(t *testing.T) {
|
||||
svc := &Service{}
|
||||
parent := t.TempDir()
|
||||
existing := filepath.Join(parent, "existing")
|
||||
if err := os.Mkdir(existing, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir existing: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.CreateDirectory(context.Background(), parent, "nested/name"); !errors.Is(
|
||||
err,
|
||||
ErrInvalidDirectoryCreation,
|
||||
) {
|
||||
t.Fatalf("invalid name error = %v, want ErrInvalidDirectoryCreation", err)
|
||||
}
|
||||
if _, err := svc.CreateDirectory(context.Background(), parent, "existing"); !errors.Is(
|
||||
err,
|
||||
ErrDirectoryAlreadyExists,
|
||||
) {
|
||||
t.Fatalf("existing name error = %v, want ErrDirectoryAlreadyExists", err)
|
||||
}
|
||||
if entries, err := os.ReadDir(existing); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("existing directory changed: entries=%v error=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidDirectoryCreation = errors.New("invalid directory creation")
|
||||
ErrDirectoryAlreadyExists = errors.New("directory already exists")
|
||||
)
|
||||
|
||||
// CreateDirectory creates one child directory for the local folder navigator.
|
||||
func (s *Service) CreateDirectory(
|
||||
ctx context.Context,
|
||||
parentPath string,
|
||||
name string,
|
||||
) (DirectoryListing, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if err := validateLocalRepositoryName(name); err != nil {
|
||||
return DirectoryListing{}, fmt.Errorf("%w: name must be a single path segment", ErrInvalidDirectoryCreation)
|
||||
}
|
||||
parentPath, err := canonicalLocalRepositoryParent(parentPath)
|
||||
if err != nil {
|
||||
return DirectoryListing{}, fmt.Errorf("%w: parent directory cannot be accessed", ErrInvalidDirectoryCreation)
|
||||
}
|
||||
targetPath := filepath.Join(parentPath, name)
|
||||
if _, err := lstatLocalRepositoryPath(targetPath); err == nil {
|
||||
return DirectoryListing{}, ErrDirectoryAlreadyExists
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return DirectoryListing{}, fmt.Errorf("%w: target path cannot be inspected", ErrInvalidDirectoryCreation)
|
||||
}
|
||||
createdPath, err := createMissingLocalDirectories(parentPath, []string{name})
|
||||
if err != nil {
|
||||
return DirectoryListing{}, fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
return s.ListDirectory(ctx, createdPath)
|
||||
}
|
||||
|
||||
func ensureLocalRepositoryDirectory(path string) (string, error) {
|
||||
existingPath, missingSegments, err := nearestExistingLocalDirectory(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
canonicalPath, err := filepath.EvalSymlinks(existingPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalRepositoryParentDirectory(canonicalPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(missingSegments) == 0 {
|
||||
return canonicalPath, nil
|
||||
}
|
||||
createdPath, err := createMissingLocalDirectories(canonicalPath, missingSegments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(createdPath)
|
||||
}
|
||||
|
||||
func nearestExistingLocalDirectory(path string) (string, []string, error) {
|
||||
currentPath := filepath.Clean(path)
|
||||
missingSegments := make([]string, 0)
|
||||
for {
|
||||
resolvedPath, err := filepath.EvalSymlinks(currentPath)
|
||||
if err == nil {
|
||||
info, statErr := statLocalRepositoryPath(resolvedPath)
|
||||
if statErr != nil {
|
||||
return "", nil, statErr
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", nil, fmt.Errorf("not a directory: %s", currentPath)
|
||||
}
|
||||
slices.Reverse(missingSegments)
|
||||
return resolvedPath, missingSegments, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil, err
|
||||
}
|
||||
parentPath := filepath.Dir(currentPath)
|
||||
if parentPath == currentPath {
|
||||
return "", nil, err
|
||||
}
|
||||
missingSegments = append(missingSegments, filepath.Base(currentPath))
|
||||
currentPath = parentPath
|
||||
}
|
||||
}
|
||||
|
||||
func createMissingLocalDirectories(parentPath string, segments []string) (string, error) {
|
||||
currentRoot, err := os.OpenRoot(parentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = currentRoot.Close() }()
|
||||
|
||||
currentPath := parentPath
|
||||
for _, segment := range segments {
|
||||
if err := currentRoot.Mkdir(segment, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return "", err
|
||||
}
|
||||
info, err := currentRoot.Lstat(segment)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 ||
|
||||
!localRepositoryDirectoryOwnedByProcess(info) || localRepositoryParentSharedWritable(info) {
|
||||
return "", fmt.Errorf("created directory is not trusted")
|
||||
}
|
||||
nextRoot, err := currentRoot.OpenRoot(segment)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := currentRoot.Close(); err != nil {
|
||||
_ = nextRoot.Close()
|
||||
return "", err
|
||||
}
|
||||
currentRoot = nextRoot
|
||||
currentPath = filepath.Join(currentPath, segment)
|
||||
}
|
||||
return currentPath, nil
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidLocalRepositoryInitialization = errors.New("invalid local repository initialization")
|
||||
ErrLocalRepositoryTargetExists = errors.New("local repository target exists")
|
||||
errLocalRepositoryTargetChanged = errors.New("local repository target changed during initialization")
|
||||
)
|
||||
|
||||
type initializeGitRepositoryFunc func(context.Context, string, *os.File) error
|
||||
|
||||
const windowsGOOS = "windows"
|
||||
|
||||
type localRepositoryStaging struct {
|
||||
path string
|
||||
directory *os.File
|
||||
identity fs.FileInfo
|
||||
}
|
||||
|
||||
// InitializeLocalRepository creates an empty Git repository and registers it with a workspace.
|
||||
func (s *Service) InitializeLocalRepository(
|
||||
ctx context.Context,
|
||||
req *InitializeLocalRepositoryRequest,
|
||||
) (*models.Repository, error) {
|
||||
return s.initializeLocalRepository(ctx, req, initializeGitRepository)
|
||||
}
|
||||
|
||||
func (s *Service) initializeLocalRepository(
|
||||
ctx context.Context,
|
||||
req *InitializeLocalRepositoryRequest,
|
||||
initializeGit initializeGitRepositoryFunc,
|
||||
) (*models.Repository, error) {
|
||||
if _, err := s.workspaces.GetWorkspace(ctx, req.WorkspaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if err := validateLocalRepositoryName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentPath, err := canonicalLocalRepositoryParent(req.ParentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPath := filepath.Join(parentPath, name)
|
||||
if _, statErr := lstatLocalRepositoryPath(targetPath); statErr == nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrLocalRepositoryTargetExists, targetPath)
|
||||
} else if !errors.Is(statErr, fs.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%w: target path cannot be inspected", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
|
||||
staging, err := createLocalRepositoryStaging(parentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.closeLocalRepositoryStaging(staging)
|
||||
published := false
|
||||
cleanup := func() {
|
||||
cleanupPath := staging.path
|
||||
if published {
|
||||
cleanupPath = targetPath
|
||||
}
|
||||
s.cleanupInitializedLocalRepository(cleanupPath, staging.identity)
|
||||
}
|
||||
|
||||
if initErr := initializeGit(ctx, staging.path, staging.directory); initErr != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("initialize git repository: %w", initErr)
|
||||
}
|
||||
if !localRepositoryTargetMatches(staging.path, staging.identity) {
|
||||
cleanup()
|
||||
return nil, errLocalRepositoryTargetChanged
|
||||
}
|
||||
if publishErr := publishLocalRepository(staging.path, targetPath); publishErr != nil {
|
||||
cleanup()
|
||||
if errors.Is(publishErr, fs.ErrExist) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrLocalRepositoryTargetExists, targetPath)
|
||||
}
|
||||
return nil, fmt.Errorf("publish initialized local repository: %w", publishErr)
|
||||
}
|
||||
published = true
|
||||
if chmodErr := setPublishedLocalRepositoryPermissions(staging.directory); chmodErr != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("set initialized local repository permissions: %w", chmodErr)
|
||||
}
|
||||
if !localRepositoryTargetMatches(targetPath, staging.identity) {
|
||||
cleanup()
|
||||
return nil, errLocalRepositoryTargetChanged
|
||||
}
|
||||
|
||||
repository, err := s.createRepositoryWithCanonicalPath(ctx, &CreateRepositoryRequest{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: name,
|
||||
SourceType: sourceTypeLocal,
|
||||
LocalPath: targetPath,
|
||||
DefaultBranch: "main",
|
||||
})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("persist initialized local repository: %w", err)
|
||||
}
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
func createLocalRepositoryStaging(parentPath string) (*localRepositoryStaging, error) {
|
||||
filesystemRoot, parentRelativePath, err := openLocalRepositoryFilesystemRoot(parentPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
defer func() { _ = filesystemRoot.Close() }()
|
||||
parentRoot, err := filesystemRoot.OpenRoot(parentRelativePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
defer func() { _ = parentRoot.Close() }()
|
||||
|
||||
stagingName, stagingDirectory, err := createLocalRepositoryStagingDirectory(parentRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stagingPath := filepath.Join(parentPath, stagingName)
|
||||
stagingPathInfo, err := parentRoot.Lstat(stagingName)
|
||||
if err != nil || !stagingPathInfo.IsDir() || stagingPathInfo.Mode()&os.ModeSymlink != 0 {
|
||||
_ = stagingDirectory.Close()
|
||||
_ = parentRoot.Remove(stagingName)
|
||||
return nil, errLocalRepositoryTargetChanged
|
||||
}
|
||||
stagingIdentity, err := stagingDirectory.Stat()
|
||||
if err != nil || !os.SameFile(stagingPathInfo, stagingIdentity) ||
|
||||
!localRepositoryDirectoryOwnedByProcess(stagingIdentity) ||
|
||||
!localRepositoryStagingPermissionsPrivate(stagingIdentity) {
|
||||
_ = stagingDirectory.Close()
|
||||
_ = parentRoot.Remove(stagingName)
|
||||
return nil, errLocalRepositoryTargetChanged
|
||||
}
|
||||
if entries, readErr := stagingDirectory.ReadDir(1); len(entries) != 0 || !errors.Is(readErr, io.EOF) {
|
||||
_ = stagingDirectory.Close()
|
||||
_ = parentRoot.Remove(stagingName)
|
||||
return nil, errLocalRepositoryTargetChanged
|
||||
}
|
||||
return &localRepositoryStaging{
|
||||
path: stagingPath,
|
||||
directory: stagingDirectory,
|
||||
identity: stagingIdentity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createLocalRepositoryStagingDirectory(root *os.Root) (string, *os.File, error) {
|
||||
for range 8 {
|
||||
name, err := localRepositoryStagingName()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("create staging directory name: %w", err)
|
||||
}
|
||||
if err := root.Mkdir(name, 0o700); err != nil {
|
||||
if errors.Is(err, fs.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return "", nil, fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
directory, err := root.Open(name)
|
||||
if err != nil {
|
||||
_ = root.Remove(name)
|
||||
return "", nil, fmt.Errorf("open local repository staging directory: %w", err)
|
||||
}
|
||||
return name, directory, nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("%w: could not reserve staging directory", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
|
||||
func localRepositoryStagingName() (string, error) {
|
||||
var suffix [12]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ".kandev-repository-init-" + hex.EncodeToString(suffix[:]), nil
|
||||
}
|
||||
|
||||
func (s *Service) closeLocalRepositoryStaging(staging *localRepositoryStaging) {
|
||||
if err := staging.directory.Close(); err != nil {
|
||||
s.logger.Warn("failed to close local repository staging directory",
|
||||
zap.String("path", staging.path), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func initializeGitRepository(ctx context.Context, targetPath string, targetDirectory *os.File) error {
|
||||
gitTarget := targetPath
|
||||
var extraFiles []*os.File
|
||||
if inheritedPath := inheritedDirectoryPath(3); inheritedPath != "" {
|
||||
gitTarget = inheritedPath
|
||||
extraFiles = []*os.File{targetDirectory}
|
||||
}
|
||||
command := exec.CommandContext(ctx, "git", "init", "--initial-branch=main", gitTarget)
|
||||
command.ExtraFiles = extraFiles
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setPublishedLocalRepositoryPermissions(directory *os.File) error {
|
||||
if runtime.GOOS == windowsGOOS {
|
||||
return nil
|
||||
}
|
||||
return directory.Chmod(0o755)
|
||||
}
|
||||
|
||||
func (s *Service) cleanupInitializedLocalRepository(
|
||||
targetPath string,
|
||||
targetIdentity fs.FileInfo,
|
||||
) {
|
||||
filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(targetPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to open local repository root for cleanup",
|
||||
zap.String("path", targetPath), zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = filesystemRoot.Close() }()
|
||||
targetDirectory, err := filesystemRoot.OpenRoot(relativePath)
|
||||
if err != nil {
|
||||
s.logger.Warn("refusing to remove replaced local repository directory",
|
||||
zap.String("path", targetPath), zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer func() { _ = targetDirectory.Close() }()
|
||||
targetInfo, err := targetDirectory.Stat(".")
|
||||
if err != nil || !os.SameFile(targetIdentity, targetInfo) {
|
||||
s.logger.Warn("refusing to remove replaced local repository directory",
|
||||
zap.String("path", targetPath))
|
||||
return
|
||||
}
|
||||
if err := removeLocalRepositoryRootTree(targetDirectory, ".git"); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
s.logger.Warn("failed to clean up initialized Git metadata",
|
||||
zap.String("path", targetPath), zap.Error(err))
|
||||
}
|
||||
if err := targetDirectory.Close(); err != nil {
|
||||
s.logger.Warn("failed to close initialized local repository directory",
|
||||
zap.String("path", targetPath), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := filesystemRoot.Remove(relativePath); err != nil {
|
||||
s.logger.Warn("failed to remove initialized local repository directory",
|
||||
zap.String("path", targetPath), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func removeLocalRepositoryRootTree(root *os.Root, name string) error {
|
||||
directory, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, readErr := directory.ReadDir(-1)
|
||||
closeErr := directory.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryName := filepath.Join(name, entry.Name())
|
||||
if entry.IsDir() {
|
||||
if err := removeLocalRepositoryRootTree(root, entryName); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := root.Remove(entryName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return root.Remove(name)
|
||||
}
|
||||
|
||||
func inheritedDirectoryPath(fd int) string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return "/proc/self/fd/" + strconv.Itoa(fd)
|
||||
case "darwin", "freebsd", "openbsd", "netbsd":
|
||||
return "/dev/fd/" + strconv.Itoa(fd)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func localRepositoryTargetMatches(targetPath string, createdTarget fs.FileInfo) bool {
|
||||
currentTarget, err := lstatLocalRepositoryPath(targetPath)
|
||||
return err == nil && currentTarget.IsDir() && os.SameFile(createdTarget, currentTarget)
|
||||
}
|
||||
|
||||
func validateLocalRepositoryName(name string) error {
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, 0) {
|
||||
return fmt.Errorf("%w: name must be a single path segment", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalLocalRepositoryParent(parentPath string) (string, error) {
|
||||
if parentPath == "" || !filepath.IsAbs(parentPath) {
|
||||
return "", fmt.Errorf("%w: parent_path must be absolute", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
canonicalPath, err := ensureLocalRepositoryDirectory(filepath.Clean(parentPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: parent directory cannot be accessed", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
if err := validateLocalRepositoryParentDirectory(canonicalPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonicalPath, nil
|
||||
}
|
||||
|
||||
func validateLocalRepositoryParentDirectory(canonicalPath string) error {
|
||||
info, err := statLocalRepositoryPath(canonicalPath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("%w: parent_path must be an accessible directory", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
if !localRepositoryParentWritable(info) {
|
||||
return fmt.Errorf("%w: parent directory is not writable", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
if err := validateLocalRepositoryParentChain(canonicalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLocalRepositoryParentChain(parentPath string) error {
|
||||
filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(parentPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: parent directory ownership is not trusted", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
defer func() { _ = filesystemRoot.Close() }()
|
||||
for path := relativePath; ; path = filepath.Dir(path) {
|
||||
info, err := filesystemRoot.Lstat(path)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 ||
|
||||
!localRepositoryDirectoryOwnerTrusted(info) {
|
||||
return fmt.Errorf("%w: parent directory ownership is not trusted", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
if localRepositoryParentSharedWritable(info) {
|
||||
return fmt.Errorf("%w: parent directory must not be shared writable", ErrInvalidLocalRepositoryInitialization)
|
||||
}
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lstatLocalRepositoryPath(path string) (fs.FileInfo, error) {
|
||||
filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = filesystemRoot.Close() }()
|
||||
return filesystemRoot.Lstat(relativePath)
|
||||
}
|
||||
|
||||
func statLocalRepositoryPath(path string) (fs.FileInfo, error) {
|
||||
filesystemRoot, relativePath, err := openLocalRepositoryFilesystemRoot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = filesystemRoot.Close() }()
|
||||
return filesystemRoot.Stat(relativePath)
|
||||
}
|
||||
|
||||
func openLocalRepositoryFilesystemRoot(path string) (*os.Root, string, error) {
|
||||
rootPath, relativePath := splitAbsForRoot(path)
|
||||
filesystemRoot, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return filesystemRoot, relativePath, nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
)
|
||||
|
||||
func TestServiceInitializeLocalRepositoryCreatesCommitlessMainRepository(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
realParent := t.TempDir()
|
||||
parentLink := filepath.Join(t.TempDir(), "projects")
|
||||
if err := os.Symlink(realParent, parentLink); err != nil {
|
||||
t.Fatalf("Symlink parent: %v", err)
|
||||
}
|
||||
|
||||
created, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1",
|
||||
Name: " new-project ",
|
||||
ParentPath: parentLink,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InitializeLocalRepository: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(realParent, "new-project")
|
||||
if created.WorkspaceID != "ws-1" || created.Name != "new-project" || created.SourceType != sourceTypeLocal ||
|
||||
created.LocalPath != wantPath || created.DefaultBranch != "main" {
|
||||
t.Fatalf("created repository = %+v, want workspace ws-1 local repository at %q on main", created, wantPath)
|
||||
}
|
||||
entries, err := os.ReadDir(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir target: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != ".git" || !entries[0].IsDir() {
|
||||
t.Fatalf("target entries = %+v, want only .git directory", entries)
|
||||
}
|
||||
if branch := runGitOutput(t, wantPath, "symbolic-ref", "--short", "HEAD"); branch != "main" {
|
||||
t.Fatalf("unborn branch = %q, want main", branch)
|
||||
}
|
||||
if command := exec.Command("git", "-C", wantPath, "rev-parse", "--verify", "HEAD"); command.Run() == nil {
|
||||
t.Fatal("rev-parse HEAD succeeded, want repository with no commits")
|
||||
}
|
||||
stored, err := repo.GetRepository(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRepository: %v", err)
|
||||
}
|
||||
if stored.LocalPath != wantPath || stored.DefaultBranch != "main" {
|
||||
t.Fatalf("stored repository = %+v, want path %q on main", stored, wantPath)
|
||||
}
|
||||
eventsPublished := eventBus.GetPublishedEvents()
|
||||
if len(eventsPublished) != 1 || eventsPublished[0].Type != events.RepositoryCreated {
|
||||
t.Fatalf("published events = %+v, want one %q event", eventsPublished, events.RepositoryCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryCreatesMissingParentDirectories(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
parent := filepath.Join(t.TempDir(), "projects", "team")
|
||||
|
||||
created, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1",
|
||||
Name: "new-project",
|
||||
ParentPath: parent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InitializeLocalRepository: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(parent, "new-project")
|
||||
if created.LocalPath != wantPath {
|
||||
t.Fatalf("created path = %q, want %q", created.LocalPath, wantPath)
|
||||
}
|
||||
for _, path := range []string{parent, wantPath, filepath.Join(wantPath, ".git")} {
|
||||
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("directory %q: info=%v error=%v", path, info, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryRejectsInvalidInputWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repository string
|
||||
parent func(t *testing.T) string
|
||||
target func(parent string) string
|
||||
}{
|
||||
{name: "empty name", repository: " ", parent: func(t *testing.T) string { return t.TempDir() }},
|
||||
{name: "dot name", repository: ".", parent: func(t *testing.T) string { return t.TempDir() }},
|
||||
{name: "parent name", repository: "..", parent: func(t *testing.T) string { return t.TempDir() }},
|
||||
{name: "nested name", repository: "nested/name", parent: func(t *testing.T) string { return t.TempDir() }},
|
||||
{name: "windows nested name", repository: `nested\name`, parent: func(t *testing.T) string { return t.TempDir() }},
|
||||
{name: "relative parent", repository: "new-project", parent: func(t *testing.T) string { return "relative" }},
|
||||
{name: "file parent", repository: "new-project", parent: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(path, []byte("file"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
return path
|
||||
}},
|
||||
{name: "unwritable parent", repository: "new-project", parent: func(t *testing.T) string {
|
||||
path := t.TempDir()
|
||||
if err := os.Chmod(path, 0o555); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
return path
|
||||
}, target: func(parent string) string { return filepath.Join(parent, "new-project") }},
|
||||
{name: "shared writable parent", repository: "new-project", parent: func(t *testing.T) string {
|
||||
path := t.TempDir()
|
||||
if err := os.Chmod(path, 0o777); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
return path
|
||||
}, target: func(parent string) string { return filepath.Join(parent, "new-project") }},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
parent := test.parent(t)
|
||||
|
||||
_, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1",
|
||||
Name: test.repository,
|
||||
ParentPath: parent,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidLocalRepositoryInitialization) {
|
||||
t.Fatalf("error = %v, want ErrInvalidLocalRepositoryInitialization", err)
|
||||
}
|
||||
if test.target != nil {
|
||||
if _, statErr := os.Stat(test.target(parent)); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("target stat error = %v, want not exist", statErr)
|
||||
}
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalRepositoryDirectoryDoesNotFollowSymlink(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "staging-link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("Symlink is unavailable: %v", err)
|
||||
}
|
||||
|
||||
file, err := openLocalRepositoryDirectory(link)
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("openLocalRepositoryDirectory followed symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryRejectsExistingTargetWithoutMutation(t *testing.T) {
|
||||
for _, existingContent := range []string{"", "keep me"} {
|
||||
name := "empty"
|
||||
if existingContent != "" {
|
||||
name = "non-empty"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "existing")
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir target: %v", err)
|
||||
}
|
||||
if existingContent != "" {
|
||||
if err := os.WriteFile(filepath.Join(target, "marker"), []byte(existingContent), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1", Name: "existing", ParentPath: parent,
|
||||
})
|
||||
if !errors.Is(err, ErrLocalRepositoryTargetExists) {
|
||||
t.Fatalf("error = %v, want ErrLocalRepositoryTargetExists", err)
|
||||
}
|
||||
if existingContent != "" {
|
||||
content, readErr := os.ReadFile(filepath.Join(target, "marker"))
|
||||
if readErr != nil || string(content) != existingContent {
|
||||
t.Fatalf("marker = %q, error %v; existing target was modified", content, readErr)
|
||||
}
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryValidatesWorkspaceBeforeMutation(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "new-project")
|
||||
|
||||
_, err := svc.InitializeLocalRepository(context.Background(), &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "missing", Name: "new-project", ParentPath: parent,
|
||||
})
|
||||
if !errors.Is(err, repository.ErrWorkspaceNotFound) {
|
||||
t.Fatalf("error = %v, want ErrWorkspaceNotFound", err)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("target stat error = %v, want not exist", statErr)
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "missing")
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryCleansUpGitFailure(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
_, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "initialize git repository") {
|
||||
t.Fatalf("error = %v, want Git initialization error", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("target stat error = %v, want cleanup", statErr)
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryDoesNotRemoveReplacedTarget(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "new-project")
|
||||
movedTarget := filepath.Join(parent, "request-owned")
|
||||
replacementPath := ""
|
||||
wantErr := errors.New("git unavailable")
|
||||
|
||||
_, err := svc.initializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent,
|
||||
}, func(_ context.Context, stagingPath string, _ *os.File) error {
|
||||
replacementPath = stagingPath
|
||||
if renameErr := os.Rename(stagingPath, movedTarget); renameErr != nil {
|
||||
t.Fatalf("Rename target: %v", renameErr)
|
||||
}
|
||||
if mkdirErr := os.Mkdir(stagingPath, 0o755); mkdirErr != nil {
|
||||
t.Fatalf("Mkdir replacement: %v", mkdirErr)
|
||||
}
|
||||
if writeErr := os.WriteFile(filepath.Join(stagingPath, "keep"), []byte("replacement"), 0o644); writeErr != nil {
|
||||
t.Fatalf("Write replacement marker: %v", writeErr)
|
||||
}
|
||||
return wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if content, readErr := os.ReadFile(filepath.Join(replacementPath, "keep")); readErr != nil || string(content) != "replacement" {
|
||||
t.Fatalf("replacement marker = %q, error %v; cleanup removed or changed replacement", content, readErr)
|
||||
}
|
||||
if info, statErr := os.Stat(movedTarget); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("request-owned directory stat = %+v, error %v; want preserved directory", info, statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("published target stat error = %v, want not exist", statErr)
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryDoesNotPublishOverRacingTarget(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "new-project")
|
||||
marker := filepath.Join(target, "keep")
|
||||
|
||||
_, err := svc.initializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent,
|
||||
}, func(context.Context, string, *os.File) error {
|
||||
if mkdirErr := os.Mkdir(target, 0o755); mkdirErr != nil {
|
||||
t.Fatalf("Mkdir racing target: %v", mkdirErr)
|
||||
}
|
||||
if writeErr := os.WriteFile(marker, []byte("racing target"), 0o644); writeErr != nil {
|
||||
t.Fatalf("Write racing target marker: %v", writeErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, ErrLocalRepositoryTargetExists) {
|
||||
t.Fatalf("error = %v, want ErrLocalRepositoryTargetExists", err)
|
||||
}
|
||||
if content, readErr := os.ReadFile(marker); readErr != nil || string(content) != "racing target" {
|
||||
t.Fatalf("racing target marker = %q, error %v; publish modified existing target", content, readErr)
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
}
|
||||
|
||||
type failingCreateRepository struct {
|
||||
repository.RepositoryEntityRepository
|
||||
err error
|
||||
}
|
||||
|
||||
func (r failingCreateRepository) CreateRepository(context.Context, *models.Repository) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryCleansUpPersistenceFailure(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
wantErr := errors.New("database unavailable")
|
||||
svc.repoEntities = failingCreateRepository{RepositoryEntityRepository: repo, err: wantErr}
|
||||
parent := t.TempDir()
|
||||
|
||||
_, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent,
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("target stat error = %v, want cleanup", statErr)
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
}
|
||||
|
||||
func runGitOutput(t *testing.T, repositoryPath string, args ...string) string {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{"-C", repositoryPath}, args...)
|
||||
output, err := exec.Command("git", commandArgs...).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func assertNoInitializedRepositories(t *testing.T, repo repository.RepositoryEntityRepository, workspaceID string) {
|
||||
t.Helper()
|
||||
repositories, err := repo.ListRepositories(context.Background(), workspaceID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRepositories: %v", err)
|
||||
}
|
||||
if len(repositories) != 0 {
|
||||
t.Fatalf("persisted repositories = %+v, want none", repositories)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build !windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
func TestServiceInitializeLocalRepositoryRejectsForeignOwnedStickyParent(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("changing directory ownership requires root")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Chmod(parent, os.ModeSticky|0o777); err != nil {
|
||||
t.Fatalf("Chmod parent: %v", err)
|
||||
}
|
||||
if err := os.Chown(parent, 12345, 12345); err != nil {
|
||||
t.Fatalf("Chown parent: %v", err)
|
||||
}
|
||||
assertLocalRepositoryParentRejected(t, parent)
|
||||
}
|
||||
|
||||
func TestServiceInitializeLocalRepositoryRejectsForeignOwnedAncestor(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("changing directory ownership requires root")
|
||||
}
|
||||
ancestor := filepath.Join(t.TempDir(), "foreign")
|
||||
parent := filepath.Join(ancestor, "projects")
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll parent: %v", err)
|
||||
}
|
||||
if err := os.Chown(ancestor, 12345, 12345); err != nil {
|
||||
t.Fatalf("Chown ancestor: %v", err)
|
||||
}
|
||||
assertLocalRepositoryParentRejected(t, parent)
|
||||
}
|
||||
|
||||
func assertLocalRepositoryParentRejected(t *testing.T, parent string) {
|
||||
t.Helper()
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.InitializeLocalRepository(ctx, &InitializeLocalRepositoryRequest{
|
||||
WorkspaceID: "ws-1", Name: "new-project", ParentPath: parent,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidLocalRepositoryInitialization) {
|
||||
t.Fatalf("error = %v, want ErrInvalidLocalRepositoryInitialization", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(parent, "new-project")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("target stat error = %v, want not exist", statErr)
|
||||
}
|
||||
assertNoInitializedRepositories(t, repo, "ws-1")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build darwin
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func localRepositoryDirectoryOwnedByProcess(info fs.FileInfo) bool {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
return ok && stat.Uid == uint32(os.Geteuid())
|
||||
}
|
||||
|
||||
func localRepositoryDirectoryOwnerTrusted(info fs.FileInfo) bool {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
return ok && (stat.Uid == 0 || stat.Uid == uint32(os.Geteuid()))
|
||||
}
|
||||
|
||||
func localRepositoryParentWritable(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm()&0o222 != 0
|
||||
}
|
||||
|
||||
func localRepositoryParentSharedWritable(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm()&0o022 != 0 && info.Mode()&os.ModeSticky == 0
|
||||
}
|
||||
|
||||
func localRepositoryStagingPermissionsPrivate(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm() == 0o700
|
||||
}
|
||||
|
||||
func openLocalRepositoryDirectory(path string) (*os.File, error) {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), path)
|
||||
if file == nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, fmt.Errorf("wrap directory descriptor")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func publishLocalRepository(stagingPath, targetPath string) error {
|
||||
return unix.RenameatxNp(unix.AT_FDCWD, stagingPath, unix.AT_FDCWD, targetPath, unix.RENAME_EXCL)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build linux
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func localRepositoryDirectoryOwnedByProcess(info fs.FileInfo) bool {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
return ok && stat.Uid == uint32(os.Geteuid())
|
||||
}
|
||||
|
||||
func localRepositoryDirectoryOwnerTrusted(info fs.FileInfo) bool {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
return ok && (stat.Uid == 0 || stat.Uid == uint32(os.Geteuid()))
|
||||
}
|
||||
|
||||
func localRepositoryParentWritable(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm()&0o222 != 0
|
||||
}
|
||||
|
||||
func localRepositoryParentSharedWritable(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm()&0o022 != 0 && info.Mode()&os.ModeSticky == 0
|
||||
}
|
||||
|
||||
func localRepositoryStagingPermissionsPrivate(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm() == 0o700
|
||||
}
|
||||
|
||||
func openLocalRepositoryDirectory(path string) (*os.File, error) {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), path)
|
||||
if file == nil {
|
||||
_ = unix.Close(fd)
|
||||
return nil, fmt.Errorf("wrap directory descriptor")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func publishLocalRepository(stagingPath, targetPath string) error {
|
||||
return unix.Renameat2(unix.AT_FDCWD, stagingPath, unix.AT_FDCWD, targetPath, unix.RENAME_NOREPLACE)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
func localRepositoryDirectoryOwnedByProcess(fs.FileInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func localRepositoryDirectoryOwnerTrusted(fs.FileInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func localRepositoryParentWritable(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm()&0o222 != 0
|
||||
}
|
||||
|
||||
func localRepositoryParentSharedWritable(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm()&0o022 != 0 && info.Mode()&os.ModeSticky == 0
|
||||
}
|
||||
|
||||
func localRepositoryStagingPermissionsPrivate(info fs.FileInfo) bool {
|
||||
return info.Mode().Perm() == 0o700
|
||||
}
|
||||
|
||||
func openLocalRepositoryDirectory(path string) (*os.File, error) {
|
||||
pathInfo, err := os.Lstat(path)
|
||||
if err != nil || !pathInfo.IsDir() || pathInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("path is not a directory")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil || !os.SameFile(pathInfo, fileInfo) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("directory changed while opening")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func publishLocalRepository(_, _ string) error {
|
||||
return fmt.Errorf("exclusive repository publication is unsupported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func localRepositoryDirectoryOwnedByProcess(fs.FileInfo) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func localRepositoryDirectoryOwnerTrusted(fs.FileInfo) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func localRepositoryParentWritable(info fs.FileInfo) bool {
|
||||
// Windows reports directory permissions as 0777; MkdirTemp performs the
|
||||
// authoritative write check when the staging directory is created.
|
||||
return info.Mode().Perm()&0o222 != 0
|
||||
}
|
||||
|
||||
func localRepositoryParentSharedWritable(fs.FileInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func localRepositoryStagingPermissionsPrivate(fs.FileInfo) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func openLocalRepositoryDirectory(path string) (*os.File, error) {
|
||||
pathInfo, err := os.Lstat(path)
|
||||
if err != nil || !pathInfo.IsDir() || pathInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("path is not a directory")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil || !os.SameFile(pathInfo, fileInfo) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("directory changed while opening")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func publishLocalRepository(stagingPath, targetPath string) error {
|
||||
from, err := windows.UTF16PtrFromString(stagingPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := windows.UTF16PtrFromString(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.MoveFileEx(from, to, 0); err != nil {
|
||||
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) ||
|
||||
errors.Is(err, windows.ERROR_FILE_EXISTS) || localRepositoryTargetExists(targetPath) {
|
||||
return fs.ErrExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func localRepositoryTargetExists(targetPath string) bool {
|
||||
_, err := os.Lstat(targetPath)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublishLocalRepositoryDoesNotReplaceExistingWindowsTarget(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
stagingPath := filepath.Join(parent, "staging")
|
||||
targetPath := filepath.Join(parent, "target")
|
||||
if err := os.Mkdir(stagingPath, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir staging: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(targetPath, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir target: %v", err)
|
||||
}
|
||||
markerPath := filepath.Join(targetPath, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile marker: %v", err)
|
||||
}
|
||||
|
||||
err := publishLocalRepository(stagingPath, targetPath)
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
t.Fatalf("publishLocalRepository error = %v, want fs.ErrExist", err)
|
||||
}
|
||||
content, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil || string(content) != "keep" {
|
||||
t.Fatalf("target marker = %q, error %v; existing target was modified", content, readErr)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,13 @@ type CreateRepositoryRequest struct {
|
||||
CopyFiles string `json:"copy_files"`
|
||||
}
|
||||
|
||||
// InitializeLocalRepositoryRequest contains the data for creating and registering a new local repository.
|
||||
type InitializeLocalRepositoryRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
ParentPath string `json:"parent_path"`
|
||||
}
|
||||
|
||||
// UpdateRepositoryRequest contains the data for updating a repository
|
||||
type UpdateRepositoryRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
|
||||
@@ -608,6 +608,22 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.createRepository(ctx, req, localPath, true)
|
||||
}
|
||||
|
||||
func (s *Service) createRepositoryWithCanonicalPath(
|
||||
ctx context.Context,
|
||||
req *CreateRepositoryRequest,
|
||||
) (*models.Repository, error) {
|
||||
return s.createRepository(ctx, req, req.LocalPath, false)
|
||||
}
|
||||
|
||||
func (s *Service) createRepository(
|
||||
ctx context.Context,
|
||||
req *CreateRepositoryRequest,
|
||||
localPath string,
|
||||
resolveProvider bool,
|
||||
) (*models.Repository, error) {
|
||||
sourceType := req.SourceType
|
||||
if sourceType == "" {
|
||||
sourceType = sourceTypeLocal
|
||||
@@ -652,17 +668,8 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq
|
||||
CopyFiles: req.CopyFiles,
|
||||
}
|
||||
|
||||
// Auto-detect provider identity from the origin when it is available.
|
||||
if repository.LocalPath != "" && (repository.Provider == "" || repository.ProviderHost == "") {
|
||||
p, h, o, n := ResolveGitRemoteProviderIdentity(repository.LocalPath)
|
||||
if repository.Provider == "" {
|
||||
repository.Provider = p
|
||||
}
|
||||
if repository.Provider != "" && (strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://")) {
|
||||
repository.ProviderHost = h
|
||||
repository.ProviderOwner = o
|
||||
repository.ProviderName = n
|
||||
}
|
||||
if resolveProvider {
|
||||
resolveRepositoryProviderIdentity(repository)
|
||||
}
|
||||
|
||||
if err := s.repoEntities.CreateRepository(ctx, repository); err != nil {
|
||||
@@ -675,6 +682,22 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
// resolveRepositoryProviderIdentity fills missing provider metadata from a local repository origin.
|
||||
func resolveRepositoryProviderIdentity(repository *models.Repository) {
|
||||
if repository.LocalPath == "" || (repository.Provider != "" && repository.ProviderHost != "") {
|
||||
return
|
||||
}
|
||||
p, h, o, n := ResolveGitRemoteProviderIdentity(repository.LocalPath)
|
||||
if repository.Provider == "" {
|
||||
repository.Provider = p
|
||||
}
|
||||
if repository.Provider != "" && (strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://")) {
|
||||
repository.ProviderHost = h
|
||||
repository.ProviderOwner = o
|
||||
repository.ProviderName = n
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
|
||||
return s.repoEntities.GetRepository(ctx, id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { BranchRefreshButton } from "./branch-refresh-button";
|
||||
|
||||
function renderRefreshButton() {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<BranchRefreshButton
|
||||
onRefresh={onRefresh}
|
||||
label="repositories"
|
||||
testId="repo-refresh-button"
|
||||
touchTarget
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onRefresh;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("BranchRefreshButton", () => {
|
||||
it("renders a repository refresh target with the supplied test id and touch target", () => {
|
||||
renderRefreshButton();
|
||||
|
||||
const button = screen.getByTestId("repo-refresh-button");
|
||||
expect(button.getAttribute("aria-label")).toBe("Refresh repositories");
|
||||
expect(button.className).toContain("h-12");
|
||||
expect(button.className).toContain("w-12");
|
||||
});
|
||||
|
||||
it("refreshes on click", () => {
|
||||
const onRefresh = renderRefreshButton();
|
||||
const button = screen.getByTestId("repo-refresh-button");
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ type BranchRefreshButtonProps = {
|
||||
refreshing?: boolean;
|
||||
fetchedAt?: string;
|
||||
fetchError?: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
touchTarget?: boolean;
|
||||
};
|
||||
|
||||
export function BranchRefreshButton({
|
||||
@@ -16,20 +19,23 @@ export function BranchRefreshButton({
|
||||
refreshing,
|
||||
fetchedAt,
|
||||
fetchError,
|
||||
label = "branches",
|
||||
testId = "branch-refresh-button",
|
||||
touchTarget = false,
|
||||
}: BranchRefreshButtonProps) {
|
||||
// Controlled open so the tooltip only reacts to hover, not focus.
|
||||
// Radix Popover auto-focuses the first focusable child when it opens, which
|
||||
// would otherwise trigger this tooltip the moment the dropdown is opened.
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasError = Boolean(fetchError);
|
||||
const tooltip = formatRefreshTooltip(fetchedAt, refreshing, fetchError);
|
||||
const tooltip = formatRefreshTooltip(label, fetchedAt, refreshing, fetchError);
|
||||
return (
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Refresh branches"
|
||||
data-testid="branch-refresh-button"
|
||||
aria-label={`Refresh ${label}`}
|
||||
data-testid={testId}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -38,7 +44,7 @@ export function BranchRefreshButton({
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
disabled={refreshing}
|
||||
className={`inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-muted/40 ${
|
||||
className={`inline-flex ${touchTarget ? "h-12 w-12" : "h-6 w-6"} items-center justify-center rounded-md hover:bg-muted/40 ${
|
||||
hasError
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@@ -53,14 +59,19 @@ export function BranchRefreshButton({
|
||||
}
|
||||
|
||||
function formatRefreshTooltip(
|
||||
label: string,
|
||||
fetchedAt: string | undefined,
|
||||
refreshing: boolean | undefined,
|
||||
fetchError: string | undefined,
|
||||
) {
|
||||
if (refreshing) return "Refreshing branches...";
|
||||
if (refreshing) return `Refreshing ${label}...`;
|
||||
if (fetchError) return `Last refresh failed: ${fetchError}`;
|
||||
if (!fetchedAt) return "Refresh branches (git fetch)";
|
||||
if (!fetchedAt) return initialRefreshTooltip(label);
|
||||
const date = new Date(fetchedAt);
|
||||
if (Number.isNaN(date.getTime())) return "Refresh branches (git fetch)";
|
||||
return `Refresh branches (last fetched ${date.toLocaleTimeString()})`;
|
||||
if (Number.isNaN(date.getTime())) return initialRefreshTooltip(label);
|
||||
return `Refresh ${label} (last fetched ${date.toLocaleTimeString()})`;
|
||||
}
|
||||
|
||||
function initialRefreshTooltip(label: string) {
|
||||
return label === "branches" ? "Refresh branches (git fetch)" : `Refresh ${label}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
createDirectory: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
isMobile: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/workspace-api", () => ({
|
||||
initializeLocalRepository: mocks.initialize,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/fs-api", () => ({
|
||||
createDirectory: mocks.createDirectory,
|
||||
listDirectory: mocks.listDirectory,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => ({ isMobile: mocks.isMobile }),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildLocalRepositoryTargetPath,
|
||||
CreateLocalRepositorySurface,
|
||||
validateLocalRepositoryName,
|
||||
} from "./create-local-repository-surface";
|
||||
|
||||
const REPOSITORY_NAME = "alpha";
|
||||
const REPOSITORY_NAME_LABEL = "Repository name";
|
||||
const PARENT_DIRECTORY_LABEL = "Parent directory";
|
||||
const CREATE_BUTTON_NAME = "Create repository";
|
||||
const PROJECTS_PATH = "/work/projects";
|
||||
|
||||
const createdRepository = {
|
||||
id: "repo-new",
|
||||
workspace_id: "ws-1",
|
||||
name: REPOSITORY_NAME,
|
||||
source_type: "local",
|
||||
local_path: "/work/alpha",
|
||||
default_branch: "main",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
} as Repository;
|
||||
|
||||
const directLocalSelection = {
|
||||
executorId: "local",
|
||||
executorProfileId: "local-profile",
|
||||
executorProfileName: "This computer",
|
||||
requiresSwitch: true,
|
||||
};
|
||||
|
||||
function renderSurface(
|
||||
overrides: Partial<React.ComponentProps<typeof CreateLocalRepositorySurface>> = {},
|
||||
) {
|
||||
const props: React.ComponentProps<typeof CreateLocalRepositorySurface> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
workspaceId: "ws-1",
|
||||
executorSelection: directLocalSelection,
|
||||
onCreated: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<CreateLocalRepositorySurface {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.isMobile = false;
|
||||
mocks.initialize.mockReset();
|
||||
mocks.createDirectory.mockReset();
|
||||
mocks.listDirectory.mockReset();
|
||||
mocks.listDirectory.mockResolvedValue({ path: "/work", parent: "/", entries: [] });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("local repository form helpers", () => {
|
||||
it.each(["", ".", "..", "nested/name", "nested\\name", "name\0with-null"])(
|
||||
"rejects invalid repository name %j",
|
||||
(name) => {
|
||||
expect(validateLocalRepositoryName(name)).not.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("trims a valid name and derives its target path", () => {
|
||||
expect(validateLocalRepositoryName(` ${REPOSITORY_NAME} `)).toBeNull();
|
||||
expect(buildLocalRepositoryTargetPath("/work/projects/", ` ${REPOSITORY_NAME} `)).toBe(
|
||||
"/work/projects/alpha",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateLocalRepositorySurface", () => {
|
||||
it("shows the target and initializes the repository immediately", async () => {
|
||||
mocks.initialize.mockResolvedValue(createdRepository);
|
||||
const props = renderSurface();
|
||||
|
||||
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
|
||||
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
|
||||
expect(screen.getByText("/work/alpha")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.initialize).toHaveBeenCalledWith("ws-1", {
|
||||
name: REPOSITORY_NAME,
|
||||
parentPath: "/work",
|
||||
});
|
||||
});
|
||||
expect(props.onCreated).toHaveBeenCalledWith(createdRepository);
|
||||
});
|
||||
|
||||
it("submits a manually entered parent directory without browsing to it", async () => {
|
||||
mocks.initialize.mockResolvedValue(createdRepository);
|
||||
renderSurface();
|
||||
|
||||
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
|
||||
target: { value: REPOSITORY_NAME },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(PARENT_DIRECTORY_LABEL), {
|
||||
target: { value: "/work/manual-parent" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.initialize).toHaveBeenCalledWith("ws-1", {
|
||||
name: REPOSITORY_NAME,
|
||||
parentPath: "/work/manual-parent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the parent directory input when browsing folders", async () => {
|
||||
mocks.listDirectory.mockImplementation(async (path: string) => {
|
||||
if (path === "") {
|
||||
return {
|
||||
path: "/work",
|
||||
parent: "/",
|
||||
entries: [{ name: "projects", path: PROJECTS_PATH }],
|
||||
};
|
||||
}
|
||||
return { path, parent: "/work", entries: [] };
|
||||
});
|
||||
renderSurface();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("folder-picker-entry"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(PARENT_DIRECTORY_LABEL) as HTMLInputElement).value).toBe(
|
||||
PROJECTS_PATH,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a folder from the navigator and enters it", async () => {
|
||||
mocks.createDirectory.mockResolvedValue({ path: PROJECTS_PATH });
|
||||
mocks.listDirectory.mockImplementation(async (path: string) => ({
|
||||
path: path || "/work",
|
||||
parent: "/",
|
||||
entries: [],
|
||||
choosable: true,
|
||||
}));
|
||||
renderSurface();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "New folder" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "New folder name" }), {
|
||||
target: { value: "projects" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create folder" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createDirectory).toHaveBeenCalledWith("/work", "projects");
|
||||
expect((screen.getByLabelText(PARENT_DIRECTORY_LABEL) as HTMLInputElement).value).toBe(
|
||||
PROJECTS_PATH,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateLocalRepositorySurface submission", () => {
|
||||
it("does not submit the parent task form", async () => {
|
||||
mocks.initialize.mockResolvedValue(createdRepository);
|
||||
const parentSubmit = vi.fn((event: React.FormEvent) => event.preventDefault());
|
||||
const props: React.ComponentProps<typeof CreateLocalRepositorySurface> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
workspaceId: "ws-1",
|
||||
executorSelection: directLocalSelection,
|
||||
onCreated: vi.fn(),
|
||||
};
|
||||
|
||||
render(
|
||||
<form onSubmit={parentSubmit}>
|
||||
<CreateLocalRepositorySurface {...props} />
|
||||
</form>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
|
||||
target: { value: REPOSITORY_NAME },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
|
||||
await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository));
|
||||
expect(parentSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains entered values and allows retry after a conflict", async () => {
|
||||
mocks.initialize
|
||||
.mockRejectedValueOnce(new Error("A file or folder already exists at that path"))
|
||||
.mockResolvedValueOnce(createdRepository);
|
||||
renderSurface();
|
||||
|
||||
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
|
||||
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("already exists");
|
||||
expect((nameInput as HTMLInputElement).value).toBe(REPOSITORY_NAME);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
await waitFor(() => expect(mocks.initialize).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("clears a submission error when the user corrects the form", async () => {
|
||||
mocks.initialize.mockRejectedValue(new Error("parent directory cannot be accessed"));
|
||||
renderSurface();
|
||||
|
||||
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
|
||||
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(PARENT_DIRECTORY_LABEL), {
|
||||
target: { value: "/work/new-parent" },
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks initialization when no direct-local profile exists", async () => {
|
||||
renderSurface({ executorSelection: null });
|
||||
|
||||
const nameInput = await screen.findByLabelText(REPOSITORY_NAME_LABEL);
|
||||
fireEvent.change(nameInput, { target: { value: REPOSITORY_NAME } });
|
||||
|
||||
expect(screen.getByText(/direct local executor profile is required/i)).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: CREATE_BUTTON_NAME }) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(mocks.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a mobile drawer instead of the desktop dialog on phones", async () => {
|
||||
mocks.isMobile = true;
|
||||
renderSurface();
|
||||
|
||||
expect(await screen.findByTestId("create-local-repository-drawer")).toBeTruthy();
|
||||
expect(screen.queryByTestId("create-local-repository-dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconFolderPlus,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { DirectoryBrowserBody, useDirectoryListing } from "@/components/folder-picker";
|
||||
import type { DirectLocalExecutorSelection } from "@/components/task-create-dialog-handlers";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { initializeLocalRepository } from "@/lib/api/domains/workspace-api";
|
||||
import { createDirectory } from "@/lib/api/domains/fs-api";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
|
||||
export function validateLocalRepositoryName(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "Enter a repository name.";
|
||||
if (trimmed === "." || trimmed === "..") return "Choose a different repository name.";
|
||||
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
|
||||
return "The repository name must be one folder name.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildLocalRepositoryTargetPath(parentPath: string, name: string): string {
|
||||
const separator = parentPath.includes("\\") && !parentPath.includes("/") ? "\\" : "/";
|
||||
const parent = parentPath.replace(/[\\/]+$/, "");
|
||||
if (!parent && separator === "/") return `/${name.trim()}`;
|
||||
return `${parent}${separator}${name.trim()}`;
|
||||
}
|
||||
|
||||
export type CreateLocalRepositorySurfaceProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string | null;
|
||||
executorSelection: DirectLocalExecutorSelection | null;
|
||||
onCreated: (repository: Repository) => void;
|
||||
};
|
||||
|
||||
function executorNotice(selection: DirectLocalExecutorSelection | null): string {
|
||||
if (!selection) {
|
||||
return "A direct local executor profile is required to create and use an empty repository.";
|
||||
}
|
||||
if (selection.requiresSwitch) {
|
||||
return `Empty repositories run directly on this machine. This task will switch to “${selection.executorProfileName}”.`;
|
||||
}
|
||||
return `This empty repository will run with “${selection.executorProfileName}” on this machine.`;
|
||||
}
|
||||
|
||||
type RepositoryLocationFieldsProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
targetPath: string;
|
||||
onNameChange: (name: string) => void;
|
||||
onParentPathChange: (path: string) => void;
|
||||
onLoadTypedDirectory: () => void;
|
||||
};
|
||||
|
||||
function RepositoryLocationFields({
|
||||
name,
|
||||
parentPath,
|
||||
targetPath,
|
||||
onNameChange,
|
||||
onParentPathChange,
|
||||
onLoadTypedDirectory,
|
||||
}: RepositoryLocationFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<label className="block space-y-1.5 text-xs font-medium">
|
||||
<span>Repository name</span>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="new-project"
|
||||
autoFocus
|
||||
className="h-11 sm:h-8"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="local-repository-parent" className="text-xs font-medium">
|
||||
Parent directory
|
||||
</label>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Input
|
||||
id="local-repository-parent"
|
||||
value={parentPath}
|
||||
onChange={(event) => onParentPathChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
onLoadTypedDirectory();
|
||||
}}
|
||||
placeholder="/Users/you/Projects"
|
||||
className="h-11 min-w-0 flex-1 font-mono sm:h-8"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
className="size-11 sm:size-8"
|
||||
onClick={onLoadTypedDirectory}
|
||||
disabled={!parentPath}
|
||||
aria-label="Browse parent directory"
|
||||
title="Browse parent directory"
|
||||
>
|
||||
<IconFolderOpen />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 border-t border-border/70 pt-2 sm:col-span-2">
|
||||
<IconFolder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Destination</span>
|
||||
<span className="truncate font-mono text-xs" title={targetPath || parentPath}>
|
||||
{targetPath || parentPath || "Loading folder…"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type RepositoryFormDetailsProps = RepositoryLocationFieldsProps & {
|
||||
executorSelection: DirectLocalExecutorSelection | null;
|
||||
submitError: string | null;
|
||||
};
|
||||
|
||||
function RepositoryFormDetails({
|
||||
executorSelection,
|
||||
submitError,
|
||||
...locationFields
|
||||
}: RepositoryFormDetailsProps) {
|
||||
return (
|
||||
<div className="shrink-0 space-y-3 px-4 py-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.6fr)]">
|
||||
<RepositoryLocationFields {...locationFields} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
executorSelection
|
||||
? "flex items-start gap-2 text-xs text-muted-foreground"
|
||||
: "flex items-start gap-2 text-xs text-destructive"
|
||||
}
|
||||
>
|
||||
<IconInfoCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>{executorNotice(executorSelection)}</p>
|
||||
</div>
|
||||
{submitError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<IconAlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>{submitError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateRepositoryFooter({
|
||||
canSubmit,
|
||||
submitting,
|
||||
}: {
|
||||
canSubmit: boolean;
|
||||
submitting: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 justify-end border-t border-border px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full cursor-pointer sm:w-auto sm:min-w-40"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<IconFolderPlus className="h-4 w-4" />
|
||||
{submitting ? "Creating…" : "Create repository"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateRepositoryForm({
|
||||
open,
|
||||
workspaceId,
|
||||
executorSelection,
|
||||
onCreated,
|
||||
onDismiss,
|
||||
}: CreateLocalRepositorySurfaceProps & { onDismiss: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [parentPath, setParentPath] = useState("");
|
||||
const [editingParentPath, setEditingParentPath] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const { listing, loading, error: listingError, load } = useDirectoryListing(open, "");
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setParentPath("");
|
||||
setEditingParentPath(false);
|
||||
setSubmitError(null);
|
||||
return;
|
||||
}
|
||||
if (listing && !editingParentPath) setParentPath(listing.path);
|
||||
}, [editingParentPath, listing, open]);
|
||||
|
||||
const nameError = validateLocalRepositoryName(name);
|
||||
const targetPath =
|
||||
parentPath && !nameError ? buildLocalRepositoryTargetPath(parentPath, name) : "";
|
||||
const canSubmit = Boolean(
|
||||
workspaceId && executorSelection && parentPath && !nameError && !submitting,
|
||||
);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!canSubmit || !workspaceId || !executorSelection) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const repository = await initializeLocalRepository(workspaceId, {
|
||||
name: name.trim(),
|
||||
parentPath,
|
||||
});
|
||||
onCreated(repository);
|
||||
setName("");
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : "Failed to create repository");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectoryNavigation = (path: string) => {
|
||||
setSubmitError(null);
|
||||
setParentPath(path);
|
||||
setEditingParentPath(false);
|
||||
void load(path);
|
||||
};
|
||||
|
||||
const loadTypedDirectory = () => {
|
||||
if (parentPath) void load(parentPath);
|
||||
};
|
||||
|
||||
const handleParentPathChange = (path: string) => {
|
||||
setSubmitError(null);
|
||||
setParentPath(path);
|
||||
setEditingParentPath(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (nextName: string) => {
|
||||
setSubmitError(null);
|
||||
setName(nextName);
|
||||
};
|
||||
|
||||
const handleCreateDirectory = async (folderName: string) => {
|
||||
if (!listing) return;
|
||||
setSubmitError(null);
|
||||
const created = await createDirectory(listing.path, folderName);
|
||||
setParentPath(created.path);
|
||||
setEditingParentPath(false);
|
||||
await load(created.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void handleSubmit(event)} className="flex min-h-0 flex-1 flex-col">
|
||||
<RepositoryFormDetails
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
targetPath={targetPath}
|
||||
executorSelection={executorSelection}
|
||||
submitError={submitError}
|
||||
onNameChange={handleNameChange}
|
||||
onParentPathChange={handleParentPathChange}
|
||||
onLoadTypedDirectory={loadTypedDirectory}
|
||||
/>
|
||||
<DirectoryBrowserBody
|
||||
listing={listing}
|
||||
loading={loading}
|
||||
error={listingError}
|
||||
onNavigate={handleDirectoryNavigation}
|
||||
onCreateDirectory={handleCreateDirectory}
|
||||
touchRows
|
||||
fillAvailableHeight
|
||||
/>
|
||||
<CreateRepositoryFooter canSubmit={canSubmit} submitting={submitting} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateLocalRepositorySurface(props: CreateLocalRepositorySurfaceProps) {
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const handleOpenChange = (open: boolean) => props.onOpenChange(open);
|
||||
const form = <CreateRepositoryForm {...props} onDismiss={() => handleOpenChange(false)} />;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={props.open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent
|
||||
data-testid="create-local-repository-drawer"
|
||||
className="h-[88dvh] max-h-[88dvh] min-w-0 overflow-hidden pb-0"
|
||||
>
|
||||
<DrawerHeader className="shrink-0 border-b border-border text-left">
|
||||
<DrawerTitle>Create new repository</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Choose a folder or enter a new path on this machine.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{form}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
data-testid="create-local-repository-dialog"
|
||||
className="flex h-[min(640px,85dvh)] max-w-xl min-w-0 flex-col overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b border-border px-4 py-3 pr-12">
|
||||
<DialogTitle>Create new repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a folder or enter a new path on this machine.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{form}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { IconFolder, IconBox, IconChevronRight } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBox,
|
||||
IconCheck,
|
||||
IconChevronRight,
|
||||
IconFolder,
|
||||
IconFolderPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -64,12 +73,11 @@ export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps
|
||||
sideOffset={4}
|
||||
data-testid="folder-picker-popover"
|
||||
>
|
||||
<Breadcrumb path={listing?.path ?? ""} onNavigate={(p) => void load(p)} />
|
||||
<Entries
|
||||
<DirectoryBrowserBody
|
||||
listing={listing}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDescend={(p) => void load(p)}
|
||||
onNavigate={(p) => void load(p)}
|
||||
/>
|
||||
<Footer
|
||||
choosable={listing?.choosable === true}
|
||||
@@ -97,7 +105,7 @@ function leafName(path: string): string {
|
||||
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || "/";
|
||||
}
|
||||
|
||||
function useDirectoryListing(open: boolean, value: string) {
|
||||
export function useDirectoryListing(open: boolean, value: string) {
|
||||
const [listing, setListing] = useState<DirectoryListing | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -180,10 +188,18 @@ function appendPathSegments(
|
||||
}
|
||||
}
|
||||
|
||||
function Breadcrumb({ path, onNavigate }: { path: string; onNavigate: (p: string) => void }) {
|
||||
function Breadcrumb({
|
||||
path,
|
||||
onNavigate,
|
||||
touchRows = false,
|
||||
}: {
|
||||
path: string;
|
||||
onNavigate: (p: string) => void;
|
||||
touchRows?: boolean;
|
||||
}) {
|
||||
const segs = pathSegments(path);
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 border-b border-border bg-muted/30 px-2 py-1.5 overflow-x-auto">
|
||||
<div className="flex items-center gap-0.5 overflow-x-auto overflow-y-hidden border-b border-border bg-muted/30 px-2 py-1.5">
|
||||
{segs.length === 0 && (
|
||||
<span className="text-[11px] text-muted-foreground italic">Loading…</span>
|
||||
)}
|
||||
@@ -200,6 +216,7 @@ function Breadcrumb({ path, onNavigate }: { path: string; onNavigate: (p: string
|
||||
disabled={last}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[11px] font-mono whitespace-nowrap",
|
||||
touchRows && "min-h-12",
|
||||
last
|
||||
? "text-foreground cursor-default"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer",
|
||||
@@ -214,16 +231,163 @@ function Breadcrumb({ path, onNavigate }: { path: string; onNavigate: (p: string
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectoryBrowserBody({
|
||||
listing,
|
||||
loading,
|
||||
error,
|
||||
onNavigate,
|
||||
onCreateDirectory,
|
||||
touchRows = false,
|
||||
fillAvailableHeight = false,
|
||||
}: {
|
||||
listing: DirectoryListing | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onNavigate: (path: string) => void;
|
||||
onCreateDirectory?: (name: string) => Promise<void>;
|
||||
touchRows?: boolean;
|
||||
fillAvailableHeight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col", fillAvailableHeight && "flex-1")}>
|
||||
{onCreateDirectory ? (
|
||||
<DirectoryBrowserToolbar
|
||||
key={listing?.path}
|
||||
disabled={!listing || loading}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
touchRows={touchRows}
|
||||
/>
|
||||
) : null}
|
||||
<Breadcrumb path={listing?.path ?? ""} onNavigate={onNavigate} touchRows={touchRows} />
|
||||
<Entries
|
||||
listing={listing}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDescend={onNavigate}
|
||||
touchRows={touchRows}
|
||||
fillAvailableHeight={fillAvailableHeight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryBrowserToolbar({
|
||||
disabled,
|
||||
onCreateDirectory,
|
||||
touchRows,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onCreateDirectory: (name: string) => Promise<void>;
|
||||
touchRows: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createFolder = async () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || creating) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreateDirectory(trimmedName);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create folder");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-muted/10 px-3 py-2">
|
||||
{editing ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconFolderPlus className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="New folder name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void createFolder();
|
||||
} else if (event.key === "Escape") {
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
autoFocus
|
||||
className={cn("min-w-0 flex-1", touchRows && "h-10")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size={touchRows ? "icon-lg" : "icon"}
|
||||
className={touchRows ? "size-11" : undefined}
|
||||
onClick={() => void createFolder()}
|
||||
disabled={!name.trim() || creating}
|
||||
aria-label="Create folder"
|
||||
title="Create folder"
|
||||
>
|
||||
<IconCheck />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={touchRows ? "icon-lg" : "icon"}
|
||||
className={touchRows ? "size-11" : undefined}
|
||||
onClick={() => setEditing(false)}
|
||||
aria-label="Cancel new folder"
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX />
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="pl-6 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">Folders</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={touchRows ? "lg" : "sm"}
|
||||
className={touchRows ? "min-h-11" : undefined}
|
||||
onClick={() => setEditing(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconFolderPlus />
|
||||
New folder
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Entries({
|
||||
listing,
|
||||
loading,
|
||||
error,
|
||||
onDescend,
|
||||
touchRows,
|
||||
fillAvailableHeight,
|
||||
}: {
|
||||
listing: DirectoryListing | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onDescend: (path: string) => void;
|
||||
touchRows?: boolean;
|
||||
fillAvailableHeight?: boolean;
|
||||
}) {
|
||||
if (loading) return <EmptyRow text="Loading…" />;
|
||||
if (error) return <EmptyRow text={error} variant="error" testId="folder-picker-error" />;
|
||||
@@ -232,7 +396,10 @@ function Entries({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="max-h-[280px] overflow-y-auto overscroll-contain py-1"
|
||||
className={cn(
|
||||
"overflow-y-auto overscroll-contain py-1",
|
||||
fillAvailableHeight ? "min-h-0 flex-1" : "max-h-[280px]",
|
||||
)}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{listing.entries.map((entry) => (
|
||||
@@ -240,7 +407,10 @@ function Entries({
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => onDescend(entry.path)}
|
||||
className="group flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent cursor-pointer"
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-3 text-left text-xs hover:bg-accent cursor-pointer",
|
||||
touchRows ? "min-h-12 py-2" : "py-1.5",
|
||||
)}
|
||||
data-testid="folder-picker-entry"
|
||||
>
|
||||
<IconFolder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
|
||||
@@ -1,11 +1,129 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Executor, Repository } from "@/lib/types/http";
|
||||
import {
|
||||
applyCreatedLocalRepository,
|
||||
findDirectLocalExecutorProfile,
|
||||
queueTaskCreateLastUsedFromPayload,
|
||||
readQueuedTaskCreateLastUsedState,
|
||||
resetTaskCreateLastUsedSync,
|
||||
syncTaskCreateLastUsed,
|
||||
} from "./task-create-dialog-handlers";
|
||||
|
||||
const WORKTREE_PROFILE_ID = "worktree-profile";
|
||||
const LOCAL_PROFILE_ID = "local-profile";
|
||||
|
||||
function executor(
|
||||
id: string,
|
||||
type: Executor["type"],
|
||||
profiles: Array<{ id: string; name: string; executor_type?: Executor["type"] }>,
|
||||
): Executor {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
profiles: profiles.map((profile) => ({
|
||||
...profile,
|
||||
executor_id: id,
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
})),
|
||||
status: "active",
|
||||
is_system: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
function repository(id: string): Repository {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
name: "alpha",
|
||||
source_type: "local",
|
||||
local_path: "/work/alpha",
|
||||
default_branch: "main",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
} as Repository;
|
||||
}
|
||||
|
||||
describe("local repository creation selection", () => {
|
||||
it("keeps the selected profile when it already runs directly on the local host", () => {
|
||||
const selection = findDirectLocalExecutorProfile(
|
||||
[
|
||||
executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }]),
|
||||
executor("local", "local", [{ id: LOCAL_PROFILE_ID, name: "Local" }]),
|
||||
],
|
||||
LOCAL_PROFILE_ID,
|
||||
);
|
||||
|
||||
expect(selection).toEqual({
|
||||
executorId: "local",
|
||||
executorProfileId: LOCAL_PROFILE_ID,
|
||||
executorProfileName: "Local",
|
||||
requiresSwitch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("chooses a direct-local profile when the selected profile is incompatible", () => {
|
||||
const selection = findDirectLocalExecutorProfile(
|
||||
[
|
||||
executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }]),
|
||||
executor("local", "local_pc", [{ id: LOCAL_PROFILE_ID, name: "This computer" }]),
|
||||
],
|
||||
WORKTREE_PROFILE_ID,
|
||||
);
|
||||
|
||||
expect(selection).toMatchObject({
|
||||
executorId: "local",
|
||||
executorProfileId: LOCAL_PROFILE_ID,
|
||||
requiresSwitch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no direct-local profile exists", () => {
|
||||
expect(
|
||||
findDirectLocalExecutorProfile(
|
||||
[executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }])],
|
||||
WORKTREE_PROFILE_ID,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("patches only the originating row and switches executor state", () => {
|
||||
const updateRepository = vi.fn();
|
||||
const setExecutorId = vi.fn();
|
||||
const setExecutorProfileId = vi.fn();
|
||||
const upsertWorkspaceRepository = vi.fn();
|
||||
const created = repository("repo-new");
|
||||
|
||||
applyCreatedLocalRepository({
|
||||
fs: { updateRepository, setExecutorId, setExecutorProfileId },
|
||||
rowKey: "row-2",
|
||||
repository: created,
|
||||
workspaceId: "ws-1",
|
||||
upsertWorkspaceRepository,
|
||||
executorSelection: {
|
||||
executorId: "local",
|
||||
executorProfileId: LOCAL_PROFILE_ID,
|
||||
executorProfileName: "Local",
|
||||
requiresSwitch: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateRepository).toHaveBeenCalledWith("row-2", {
|
||||
repositoryId: "repo-new",
|
||||
localPath: undefined,
|
||||
branch: "main",
|
||||
});
|
||||
expect(setExecutorId).toHaveBeenCalledWith("local");
|
||||
expect(setExecutorProfileId).toHaveBeenCalledWith(LOCAL_PROFILE_ID);
|
||||
expect(upsertWorkspaceRepository).toHaveBeenCalledWith("ws-1", created);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncTaskCreateLastUsed", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type { Executor, Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
|
||||
import { createDebugLogger } from "@/lib/debug/log";
|
||||
import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types";
|
||||
@@ -24,6 +24,73 @@ type TaskCreateLastUsedPayload = {
|
||||
executor_profile_id?: string;
|
||||
};
|
||||
|
||||
export type DirectLocalExecutorSelection = {
|
||||
executorId: string;
|
||||
executorProfileId: string;
|
||||
executorProfileName: string;
|
||||
requiresSwitch: boolean;
|
||||
};
|
||||
|
||||
function isDirectLocalExecutorType(type: string | undefined): boolean {
|
||||
return type === "local" || type === "local_pc";
|
||||
}
|
||||
|
||||
export function findDirectLocalExecutorProfile(
|
||||
executors: Executor[],
|
||||
currentProfileId: string,
|
||||
): DirectLocalExecutorSelection | null {
|
||||
const candidates = executors.flatMap((executor) =>
|
||||
(executor.profiles ?? [])
|
||||
.filter((profile) => isDirectLocalExecutorType(profile.executor_type ?? executor.type))
|
||||
.map((profile) => ({
|
||||
executorId: executor.id,
|
||||
executorProfileId: profile.id,
|
||||
executorProfileName: profile.name,
|
||||
requiresSwitch: profile.id !== currentProfileId,
|
||||
})),
|
||||
);
|
||||
return (
|
||||
candidates.find((candidate) => candidate.executorProfileId === currentProfileId) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
type CreatedLocalRepositoryForm = Pick<
|
||||
DialogFormState,
|
||||
"updateRepository" | "setExecutorId" | "setExecutorProfileId"
|
||||
>;
|
||||
|
||||
export function applyCreatedLocalRepository({
|
||||
fs,
|
||||
rowKey,
|
||||
repository,
|
||||
workspaceId,
|
||||
upsertWorkspaceRepository,
|
||||
executorSelection,
|
||||
}: {
|
||||
fs: CreatedLocalRepositoryForm;
|
||||
rowKey: string;
|
||||
repository: Repository;
|
||||
workspaceId: string;
|
||||
upsertWorkspaceRepository: (workspaceId: string, repository: Repository) => void;
|
||||
executorSelection: DirectLocalExecutorSelection;
|
||||
}) {
|
||||
fs.updateRepository(rowKey, {
|
||||
repositoryId: repository.id,
|
||||
localPath: undefined,
|
||||
branch: "main",
|
||||
});
|
||||
fs.setExecutorId(executorSelection.executorId);
|
||||
fs.setExecutorProfileId(executorSelection.executorProfileId);
|
||||
upsertWorkspaceRepository(workspaceId, repository);
|
||||
syncTaskCreateLastUsed({
|
||||
repository_id: repository.id,
|
||||
branch: "main",
|
||||
executor_profile_id: executorSelection.executorProfileId,
|
||||
});
|
||||
}
|
||||
|
||||
let lastQueuedLastUsed: Partial<TaskCreateLastUsedState> = {};
|
||||
const lastUsedDebug = createDebugLogger("task-create:last-used");
|
||||
|
||||
@@ -292,13 +359,42 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) {
|
||||
export function useDialogHandlers(
|
||||
fs: DialogFormState,
|
||||
repositories: Repository[],
|
||||
context?: {
|
||||
workspaceId: string | null;
|
||||
executors: Executor[];
|
||||
upsertWorkspaceRepository: (workspaceId: string, repository: Repository) => void;
|
||||
},
|
||||
) {
|
||||
const repo = useRepositoryHandlers(fs, repositories);
|
||||
const profile = useProfileAndNameHandlers(fs);
|
||||
const gh = useGitHubAndFreshBranchHandlers(fs);
|
||||
const directLocalExecutorSelection = findDirectLocalExecutorProfile(
|
||||
context?.executors ?? [],
|
||||
fs.executorProfileId,
|
||||
);
|
||||
const handleLocalRepositoryCreated = useCallback(
|
||||
(rowKey: string, repository: Repository) => {
|
||||
if (!context?.workspaceId || !directLocalExecutorSelection) return;
|
||||
applyCreatedLocalRepository({
|
||||
fs,
|
||||
rowKey,
|
||||
repository,
|
||||
workspaceId: context.workspaceId,
|
||||
upsertWorkspaceRepository: context.upsertWorkspaceRepository,
|
||||
executorSelection: directLocalExecutorSelection,
|
||||
});
|
||||
clearFreshBranch(fs);
|
||||
},
|
||||
[context, directLocalExecutorSelection, fs],
|
||||
);
|
||||
return {
|
||||
...repo,
|
||||
...profile,
|
||||
...gh,
|
||||
directLocalExecutorSelection,
|
||||
handleLocalRepositoryCreated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
Pill,
|
||||
} from "./task-create-dialog-pill";
|
||||
|
||||
const CREATE_REPOSITORY = "Create new repository";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -195,4 +197,46 @@ describe("Pill popover", () => {
|
||||
expect(content).not.toBeNull();
|
||||
expect(screen.getByTestId("clipping-host").contains(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("activates an optional toolbar action with a pointer", () => {
|
||||
const onAction = vi.fn();
|
||||
render(
|
||||
<Pill
|
||||
icon={<span aria-hidden="true" />}
|
||||
value=""
|
||||
placeholder="repository"
|
||||
options={[]}
|
||||
onSelect={vi.fn()}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage="No repositories"
|
||||
action={{ label: CREATE_REPOSITORY, onSelect: onAction }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("repository"));
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_REPOSITORY }));
|
||||
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders an optional toolbar action outside the option list", () => {
|
||||
const onAction = vi.fn();
|
||||
render(
|
||||
<Pill
|
||||
icon={<span aria-hidden="true" />}
|
||||
value=""
|
||||
placeholder="repository"
|
||||
options={[]}
|
||||
onSelect={vi.fn()}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage="No repositories"
|
||||
action={{ label: CREATE_REPOSITORY, onSelect: onAction }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("repository"));
|
||||
expect(screen.getByRole("button", { name: CREATE_REPOSITORY })).toBeTruthy();
|
||||
expect(screen.queryByRole("option", { name: CREATE_REPOSITORY })).toBeNull();
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,12 @@ export type PillOption = {
|
||||
renderLabel?: () => React.ReactNode;
|
||||
};
|
||||
|
||||
export type PillAction = {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* `Pill` wraps cmdk's `Command` / `CommandInput` / `CommandList`. Its popover
|
||||
* body only supports cmdk children (`CommandItem`, etc.) — keyboard nav and
|
||||
@@ -49,6 +55,8 @@ type PillProps = {
|
||||
onRefresh?: () => void;
|
||||
/** Show the refresh icon as spinning + disabled while a refresh is in flight. */
|
||||
refreshing?: boolean;
|
||||
/** Accessible label used for the optional refresh action. */
|
||||
refreshLabel?: string;
|
||||
/**
|
||||
* Render without its own border/bg so the pill blends into a wrapping
|
||||
* grouped container (used by RepoChip to draw one rectangle around
|
||||
@@ -66,6 +74,8 @@ type PillProps = {
|
||||
* base) without depending on the user reading a tooltip.
|
||||
*/
|
||||
prefix?: string;
|
||||
/** Optional icon action rendered beside the search input. */
|
||||
action?: PillAction;
|
||||
};
|
||||
|
||||
/** Returns the active-state hover classes for the pill trigger button. */
|
||||
@@ -150,28 +160,65 @@ function PillPopoverContent({
|
||||
searchPlaceholder,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
refreshLabel,
|
||||
options,
|
||||
onSelect,
|
||||
setOpen,
|
||||
emptyMessage,
|
||||
portalContainer,
|
||||
action,
|
||||
}: {
|
||||
filter?: PillProps["filter"];
|
||||
searchPlaceholder: string;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
refreshLabel?: string;
|
||||
options: PillOption[];
|
||||
onSelect: (value: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
emptyMessage: string;
|
||||
portalContainer: HTMLElement | null;
|
||||
action?: PillAction;
|
||||
}) {
|
||||
return (
|
||||
<PopoverContent className="w-[360px] p-0" align="start" portalContainer={portalContainer}>
|
||||
<PopoverContent
|
||||
className="w-[min(480px,calc(100vw-2rem))] p-0"
|
||||
align="start"
|
||||
portalContainer={portalContainer}
|
||||
>
|
||||
<Command filter={filter}>
|
||||
<div className="flex items-center gap-1 px-2 pt-1">
|
||||
<CommandInput placeholder={searchPlaceholder} className="h-9 flex-1" />
|
||||
{onRefresh && <BranchRefreshButton onRefresh={onRefresh} refreshing={refreshing} />}
|
||||
<div className="flex min-h-11 items-center gap-1 px-2 pt-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CommandInput placeholder={searchPlaceholder} className="h-9 w-full" />
|
||||
</div>
|
||||
{onRefresh ? (
|
||||
<BranchRefreshButton
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
label={refreshLabel}
|
||||
testId={refreshLabel === "repositories" ? "repo-refresh-button" : undefined}
|
||||
touchTarget={refreshLabel === "repositories"}
|
||||
/>
|
||||
) : null}
|
||||
{action ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={action.label}
|
||||
data-testid="create-local-repository-button"
|
||||
onClick={() => {
|
||||
action.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<PillCommandList
|
||||
options={options}
|
||||
@@ -231,10 +278,12 @@ export function Pill({
|
||||
testId,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
refreshLabel,
|
||||
flat = false,
|
||||
filter,
|
||||
tooltip,
|
||||
prefix,
|
||||
action,
|
||||
}: PillProps) {
|
||||
const [open, setOpenState] = useState(false);
|
||||
const { tooltipOpenState, handleTooltipOpenChange, closeTooltip } = useTooltipMountGate();
|
||||
@@ -267,14 +316,13 @@ export function Pill({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const hasValue = !!value;
|
||||
const triggerButton = renderPillTriggerButton({
|
||||
icon,
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
flat,
|
||||
hasValue,
|
||||
hasValue: Boolean(value),
|
||||
testId,
|
||||
prefix,
|
||||
});
|
||||
@@ -302,11 +350,13 @@ export function Pill({
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
refreshLabel={refreshLabel}
|
||||
options={options}
|
||||
onSelect={onSelect}
|
||||
setOpen={setOpen}
|
||||
emptyMessage={emptyMessage}
|
||||
portalContainer={portalContainer}
|
||||
action={action}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,83 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeHasAllBranches } from "./task-create-dialog-prop-builders";
|
||||
import type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
import type { DialogFormState } from "./task-create-dialog-types";
|
||||
import {
|
||||
computeHasAllBranches,
|
||||
localRepositoryCreationEnabled,
|
||||
} from "./task-create-dialog-prop-builders";
|
||||
|
||||
const URL_AB = "github.com/a/b";
|
||||
|
||||
// Minimal fs stub for computeHasAllBranches. The function only reads
|
||||
// `noRepository`, `useRemote`, `remoteRepos`, and `repositories[].branch`,
|
||||
// so we cast a partial through `unknown` to avoid having to materialise the
|
||||
// full DialogFormState surface in tests.
|
||||
function fsStub(overrides: {
|
||||
noRepository?: boolean;
|
||||
useRemote?: boolean;
|
||||
remoteRepos?: Array<{ url?: string; branch?: string }>;
|
||||
repositories?: Array<{ branch?: string }>;
|
||||
}): DialogFormState {
|
||||
function formState(overrides: Partial<DialogFormState>): DialogFormState {
|
||||
return {
|
||||
noRepository: false,
|
||||
useRemote: false,
|
||||
remoteRepos: [],
|
||||
repositories: [],
|
||||
remoteRepos: [],
|
||||
...overrides,
|
||||
} as unknown as DialogFormState;
|
||||
} as DialogFormState;
|
||||
}
|
||||
|
||||
describe("computeHasAllBranches", () => {
|
||||
it("returns true when the task is in no-repository mode (short-circuits the rest)", () => {
|
||||
// noRepository mode doesn't need a branch — the backend skips the
|
||||
// worktree/clone step entirely, so it always satisfies hasAllBranches.
|
||||
expect(computeHasAllBranches(fsStub({ noRepository: true, repositories: [] }))).toBe(true);
|
||||
it("accepts no-repository tasks", () => {
|
||||
expect(computeHasAllBranches(formState({ noRepository: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("Remote mode: every non-empty row needs a branch", () => {
|
||||
// Empty rows (URL not yet filled in) are skipped — the user can leave a
|
||||
// half-filled chip without blocking submit, mirroring the workspace path.
|
||||
expect(computeHasAllBranches(fsStub({ useRemote: true, remoteRepos: [] }))).toBe(false);
|
||||
it("requires a branch on every populated remote row", () => {
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
fsStub({ useRemote: true, remoteRepos: [{ url: URL_AB, branch: "" }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
fsStub({ useRemote: true, remoteRepos: [{ url: URL_AB, branch: "main" }] }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
fsStub({
|
||||
formState({
|
||||
useRemote: true,
|
||||
remoteRepos: [
|
||||
{ url: URL_AB, branch: "main" },
|
||||
{ url: "", branch: "" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
fsStub({
|
||||
useRemote: true,
|
||||
remoteRepos: [
|
||||
{ url: URL_AB, branch: "main" },
|
||||
{ url: "github.com/c/d", branch: "" },
|
||||
{ key: "one", url: "https://example.com/one.git", branch: "main", source: "paste" },
|
||||
{ key: "two", url: "https://example.com/two.git", branch: "", source: "paste" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires every selected repository row to carry a branch", () => {
|
||||
expect(computeHasAllBranches(fsStub({ repositories: [] }))).toBe(false);
|
||||
expect(computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }] }))).toBe(true);
|
||||
expect(
|
||||
computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }, { branch: "" }] })),
|
||||
).toBe(false);
|
||||
it("rejects remote mode without a populated row", () => {
|
||||
expect(computeHasAllBranches(formState({ useRemote: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a branched remote row and ignores an empty trailing row", () => {
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
fsStub({ repositories: [{ branch: "main" }, { branch: "feature/x" }] }),
|
||||
formState({
|
||||
useRemote: true,
|
||||
remoteRepos: [
|
||||
{ key: "one", url: "https://example.com/one.git", branch: "main", source: "paste" },
|
||||
{ key: "two", url: "", branch: "", source: "paste" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects local mode without a repository row", () => {
|
||||
expect(computeHasAllBranches(formState({}))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a local repository row without a branch", () => {
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
formState({ repositories: [{ key: "one", repositoryId: "repo-one", branch: "" }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a branch on every local repository row", () => {
|
||||
expect(
|
||||
computeHasAllBranches(
|
||||
formState({
|
||||
repositories: [
|
||||
{ key: "one", repositoryId: "repo-one", branch: "main" },
|
||||
{ key: "two", repositoryId: "repo-two", branch: "develop" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localRepositoryCreationEnabled", () => {
|
||||
it("allows repository creation only for an unlocked new-task form", () => {
|
||||
expect(localRepositoryCreationEnabled(true, false)).toBe(true);
|
||||
expect(localRepositoryCreationEnabled(false, false)).toBe(false);
|
||||
expect(localRepositoryCreationEnabled(true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export function computeHasAllBranches(fs: DialogFormState): boolean {
|
||||
return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch);
|
||||
}
|
||||
|
||||
export function localRepositoryCreationEnabled(isCreateMode: boolean, repoLocked: boolean) {
|
||||
return isCreateMode && !repoLocked;
|
||||
}
|
||||
|
||||
export function buildDialogFormBodyProps(
|
||||
setup: ReturnType<typeof useTaskCreateDialogSetup>,
|
||||
props: TaskCreateDialogProps,
|
||||
@@ -59,9 +63,17 @@ export function buildDialogFormBodyProps(
|
||||
onToggleFreshBranch: handlers.handleToggleFreshBranch,
|
||||
onToggleNoRepository: repoLocked ? undefined : handlers.handleToggleNoRepository,
|
||||
onWorkspacePathChange: handlers.handleWorkspacePathChange,
|
||||
localRepositoryCreation: localRepositoryCreationEnabled(setup.isCreateMode, repoLocked)
|
||||
? {
|
||||
executorSelection: handlers.directLocalExecutorSelection,
|
||||
onCreated: handlers.handleLocalRepositoryCreated,
|
||||
}
|
||||
: undefined,
|
||||
enhance: setup.enhance,
|
||||
workflowAgentLocked: computed.workflowAgentLocked,
|
||||
repositories: setup.repositories,
|
||||
onRefreshRepositories: setup.refreshRepositories,
|
||||
repositoriesRefreshing: setup.repositoriesLoading,
|
||||
lastUsedBranch: setup.taskCreateLastUsed.branch,
|
||||
userSettingsLoaded: setup.userSettingsLoaded,
|
||||
freshBranchAvailable: setup.freshBranchAvailable,
|
||||
|
||||
@@ -26,7 +26,8 @@ export function useRepoBranchAutoselect({
|
||||
userSettingsLoaded?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!branchSource || branchesLoading || branches.length === 0 || rowBranch) return;
|
||||
const hasSelectableBranch = branches.length > 0 || !!preferredDefaultBranch;
|
||||
if (!branchSource || branchesLoading || !hasSelectableBranch || rowBranch) return;
|
||||
runAutoselect({
|
||||
branches,
|
||||
preferredDefaultBranch,
|
||||
|
||||
@@ -4,10 +4,8 @@ import type { Branch, Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
|
||||
// One mocked branches hook now — id-based and path-based rows go through
|
||||
// the same loader. Tests can override the return value when they need
|
||||
// branch-specific assertions; the mockBySource handle lets a test prove
|
||||
// which kind of source the chip passed in.
|
||||
// The unified branch mock lets tests override results and inspect whether
|
||||
// chips select an id-based or path-based source.
|
||||
const lastBranchSource = vi.hoisted((): { value: unknown } => ({ value: null }));
|
||||
const mockBranches = vi.hoisted((): { value: { branches: Branch[]; isLoading: boolean } } => ({
|
||||
value: { branches: [], isLoading: false },
|
||||
@@ -58,11 +56,9 @@ function row(overrides: Partial<TaskRepoRow> = {}): TaskRepoRow {
|
||||
}
|
||||
|
||||
function makeFs(overrides: Partial<DialogFormState>): DialogFormState {
|
||||
// Only the fields RepoChipsRow actually reads/sets need to be real.
|
||||
const remoteUrl = (overrides.remoteRepos?.[0]?.url ?? "") as string;
|
||||
const branchesByUrl = {
|
||||
branches: (url: string) =>
|
||||
url === remoteUrl
|
||||
url === (overrides.remoteRepos?.[0]?.url ?? "")
|
||||
? ((overrides as Partial<DialogFormState>).branchesByUrl?.branches(url) ?? [])
|
||||
: [],
|
||||
loading: () => false,
|
||||
@@ -200,6 +196,28 @@ describe("RepoChipsRow", () => {
|
||||
expect(onRowBranchChange).toHaveBeenCalledWith("r0", "feature/x");
|
||||
});
|
||||
|
||||
it("uses the symbolic current branch for an unborn local repository", () => {
|
||||
mockBranches.value = { branches: [], isLoading: false };
|
||||
const onRowBranchChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({
|
||||
repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })],
|
||||
currentLocalBranch: "main",
|
||||
currentLocalBranchLoading: false,
|
||||
})}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={onRowBranchChange}
|
||||
isLocalExecutor
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onRowBranchChange).toHaveBeenCalledWith("r0", "main");
|
||||
});
|
||||
|
||||
it("local-executor row shows the loading placeholder while resolving the current branch", () => {
|
||||
mockBranches.value = { branches: [], isLoading: false };
|
||||
renderInProvider(
|
||||
@@ -243,8 +261,7 @@ describe("RepoChipsRow", () => {
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByTestId("add-repository") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(false);
|
||||
expect((screen.getByTestId("add-repository") as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls fs.addRepository when the + button is clicked", () => {
|
||||
@@ -403,7 +420,6 @@ describe("RepoChipsRow", () => {
|
||||
fireEvent.click(screen.getByTestId("branch-chip-trigger"));
|
||||
expect(screen.getByText("main")).toBeTruthy();
|
||||
expect(screen.getByText("origin/main")).toBeTruthy();
|
||||
// Reset for sibling tests.
|
||||
mockBranches.value = { branches: [], isLoading: false };
|
||||
});
|
||||
});
|
||||
@@ -457,6 +473,78 @@ describe("WorkspaceRepoChips duplicate policy", () => {
|
||||
expect(screen.getByRole("option", { name: /^frontend/ })).toBeTruthy();
|
||||
expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not expose repository creation when the caller does not opt in", () => {
|
||||
renderWorkspaceChips(false);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
|
||||
|
||||
expect(screen.queryByText("Create new repository")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes repository creation to the only row", () => {
|
||||
const onCreateRepository = vi.fn();
|
||||
renderInProvider(
|
||||
<WorkspaceRepoChips
|
||||
rows={[workspaceRows[0]]}
|
||||
repositories={workspaceRepositories}
|
||||
workspaceId="ws-1"
|
||||
canAddMore
|
||||
onAdd={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
onCreateRepository={onCreateRepository}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
|
||||
fireEvent.click(screen.getByTestId("create-local-repository-button"));
|
||||
|
||||
expect(onCreateRepository).toHaveBeenCalledWith("r0");
|
||||
});
|
||||
|
||||
it("refreshes repositories from the selector toolbar", () => {
|
||||
const onRefreshRepositories = vi.fn();
|
||||
renderInProvider(
|
||||
<WorkspaceRepoChips
|
||||
rows={[workspaceRows[0]]}
|
||||
repositories={workspaceRepositories}
|
||||
workspaceId="ws-1"
|
||||
canAddMore
|
||||
onAdd={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER));
|
||||
fireEvent.click(screen.getByTestId("repo-refresh-button"));
|
||||
|
||||
expect(onRefreshRepositories).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not expose repository creation for multi-repository tasks", () => {
|
||||
renderInProvider(
|
||||
<WorkspaceRepoChips
|
||||
rows={workspaceRows}
|
||||
repositories={workspaceRepositories}
|
||||
workspaceId="ws-1"
|
||||
canAddMore
|
||||
onAdd={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
onCreateRepository={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
|
||||
|
||||
expect(screen.queryByText("Create new repository")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceRepoChips workspace markers", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { IconGitFork } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
@@ -9,6 +10,8 @@ import { RemoteRepoChipsRow } from "@/components/task-create-dialog-remote-repo-
|
||||
import { FolderPicker } from "@/components/folder-picker";
|
||||
import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode";
|
||||
import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips";
|
||||
import { CreateLocalRepositorySurface } from "@/components/create-local-repository-surface";
|
||||
import type { DirectLocalExecutorSelection } from "@/components/task-create-dialog-handlers";
|
||||
|
||||
type RepoChipsRowProps = {
|
||||
fs: DialogFormState;
|
||||
@@ -49,6 +52,12 @@ type RepoChipsRowProps = {
|
||||
onWorkspacePathChange?: (value: string) => void;
|
||||
lastUsedBranch?: string | null;
|
||||
userSettingsLoaded?: boolean;
|
||||
localRepositoryCreation?: {
|
||||
executorSelection: DirectLocalExecutorSelection | null;
|
||||
onCreated: (rowKey: string, repository: Repository) => void;
|
||||
};
|
||||
onRefreshRepositories?: () => void;
|
||||
repositoriesRefreshing?: boolean;
|
||||
};
|
||||
|
||||
export function RepoChipsRow({
|
||||
@@ -67,7 +76,23 @@ export function RepoChipsRow({
|
||||
onWorkspacePathChange,
|
||||
lastUsedBranch,
|
||||
userSettingsLoaded,
|
||||
localRepositoryCreation,
|
||||
onRefreshRepositories,
|
||||
repositoriesRefreshing,
|
||||
}: RepoChipsRowProps) {
|
||||
const chipRowRef = useRef<HTMLDivElement>(null);
|
||||
const [creatingForRowKey, setCreatingForRowKey] = useState<string | null>(null);
|
||||
const handleCreationOpenChange = (open: boolean) => {
|
||||
if (open || creatingForRowKey === null) return;
|
||||
const rowKey = creatingForRowKey;
|
||||
setCreatingForRowKey(null);
|
||||
requestAnimationFrame(() => {
|
||||
const row = Array.from(
|
||||
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
|
||||
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
|
||||
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
|
||||
});
|
||||
};
|
||||
// Local executor branch behavior:
|
||||
// - chip is clickable (user can switch to any existing branch on disk)
|
||||
// - row.branch seeds from the workspace's current branch (currentLocalBranch)
|
||||
@@ -95,7 +120,11 @@ export function RepoChipsRow({
|
||||
// modal doesn't jump when the user toggles between Repo / URL / None
|
||||
// (None renders a single pill, Repo can render chips + branch + add and
|
||||
// sometimes wraps when the segmented control crowds the row).
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-2" data-testid="repo-chips-row">
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className="flex min-h-9 flex-wrap items-center gap-2"
|
||||
data-testid="repo-chips-row"
|
||||
>
|
||||
<ModeBody
|
||||
fs={fs}
|
||||
repositories={repositories}
|
||||
@@ -112,6 +141,9 @@ export function RepoChipsRow({
|
||||
onWorkspacePathChange={onWorkspacePathChange}
|
||||
lastUsedBranch={lastUsedBranch}
|
||||
userSettingsLoaded={userSettingsLoaded}
|
||||
onCreateRepository={localRepositoryCreation ? setCreatingForRowKey : undefined}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
repositoriesRefreshing={repositoriesRefreshing}
|
||||
/>
|
||||
<SourceModeSwitch
|
||||
useRemote={fs.useRemote}
|
||||
@@ -119,6 +151,17 @@ export function RepoChipsRow({
|
||||
onToggleRemote={onToggleRemote}
|
||||
onToggleNoRepository={onToggleNoRepository}
|
||||
/>
|
||||
{localRepositoryCreation ? (
|
||||
<CreateLocalRepositorySurface
|
||||
open={creatingForRowKey !== null}
|
||||
onOpenChange={handleCreationOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
executorSelection={localRepositoryCreation.executorSelection}
|
||||
onCreated={(repository) => {
|
||||
if (creatingForRowKey) localRepositoryCreation.onCreated(creatingForRowKey, repository);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,6 +182,9 @@ function ModeBody({
|
||||
onWorkspacePathChange,
|
||||
lastUsedBranch,
|
||||
userSettingsLoaded,
|
||||
onCreateRepository,
|
||||
onRefreshRepositories,
|
||||
repositoriesRefreshing,
|
||||
}: {
|
||||
fs: DialogFormState;
|
||||
repositories: Repository[];
|
||||
@@ -155,6 +201,9 @@ function ModeBody({
|
||||
onWorkspacePathChange?: (value: string) => void;
|
||||
lastUsedBranch?: string | null;
|
||||
userSettingsLoaded?: boolean;
|
||||
onCreateRepository?: (key: string) => void;
|
||||
onRefreshRepositories?: () => void;
|
||||
repositoriesRefreshing?: boolean;
|
||||
}) {
|
||||
if (fs.noRepository) {
|
||||
return (
|
||||
@@ -195,6 +244,9 @@ function ModeBody({
|
||||
onRowBranchChange={onRowBranchChange}
|
||||
lastUsedBranch={lastUsedBranch}
|
||||
userSettingsLoaded={userSettingsLoaded}
|
||||
onCreateRepository={onCreateRepository}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
repositoriesRefreshing={repositoriesRefreshing}
|
||||
freshBranchToggle={
|
||||
// Multi-repo runs use worktrees, so the existing-vs-fork choice
|
||||
// is irrelevant — only surface the toggle for single-repo flows.
|
||||
|
||||
@@ -582,7 +582,11 @@ export function useTaskCreateDialogData(
|
||||
const taskCreateUserSettings = useEnsureUserSettings(open);
|
||||
|
||||
useSettingsData(open);
|
||||
const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open);
|
||||
const {
|
||||
repositories,
|
||||
isLoading: repositoriesLoading,
|
||||
refresh: refreshRepositories,
|
||||
} = useRepositories(workspaceId, open);
|
||||
// Per-repo branch loading lives in each chip now (RepoChipsRow). No
|
||||
// global branch query is needed here — the chip uses useRepositoryBranches
|
||||
// for its own row, and the store dedupes by repositoryId.
|
||||
@@ -613,6 +617,7 @@ export function useTaskCreateDialogData(
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
branchesLoading,
|
||||
taskCreateLastUsed: taskCreateUserSettings.userSettings.taskCreateLastUsed,
|
||||
userSettingsLoaded: taskCreateUserSettings.loaded,
|
||||
|
||||
@@ -433,10 +433,18 @@ export type DialogFormBodyProps = {
|
||||
onToggleFreshBranch: (enabled: boolean) => void;
|
||||
onToggleNoRepository?: () => void;
|
||||
onWorkspacePathChange: (value: string) => void;
|
||||
localRepositoryCreation?: {
|
||||
executorSelection:
|
||||
| import("@/components/task-create-dialog-handlers").DirectLocalExecutorSelection
|
||||
| null;
|
||||
onCreated: (rowKey: string, repository: Repository) => void;
|
||||
};
|
||||
enhance?: DialogPromptEnhance;
|
||||
workflowAgentLocked: boolean;
|
||||
/** Workspace repositories — driven into the chip row for repo + branch picks. */
|
||||
repositories: Repository[];
|
||||
onRefreshRepositories?: () => void;
|
||||
repositoriesRefreshing?: boolean;
|
||||
lastUsedBranch?: string | null;
|
||||
userSettingsLoaded?: boolean;
|
||||
/** Computed in the parent: single-row + local executor + not URL mode. */
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconPlus, IconX, IconCode, IconGitBranch, IconCheck } from "@tabler/icons-react";
|
||||
import {
|
||||
IconPlus,
|
||||
IconX,
|
||||
IconCode,
|
||||
IconGitBranch,
|
||||
IconFolderPlus,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
|
||||
@@ -15,6 +22,7 @@ import {
|
||||
sortBranches,
|
||||
branchToOption,
|
||||
computeBranchPlaceholder,
|
||||
type PillAction,
|
||||
type PillOption,
|
||||
} from "@/components/task-create-dialog-pill";
|
||||
import {
|
||||
@@ -24,6 +32,32 @@ import {
|
||||
} from "@/components/task-create-dialog-branch-utils";
|
||||
import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect";
|
||||
|
||||
type WorkspaceRepoChipsProps = {
|
||||
rows: TaskRepoRow[];
|
||||
repositories: Repository[];
|
||||
discoveredRepositories?: LocalRepository[];
|
||||
workspaceId: string | null;
|
||||
branchLocked?: boolean;
|
||||
isLocalExecutor?: boolean;
|
||||
currentLocalBranch?: string;
|
||||
currentLocalBranchLoading?: boolean;
|
||||
freshBranchEnabled?: boolean;
|
||||
canAddMore: boolean;
|
||||
addHint?: string;
|
||||
addLabel?: string;
|
||||
allowDuplicateRepositories?: boolean;
|
||||
freshBranchToggle?: React.ReactNode;
|
||||
onAdd: () => void;
|
||||
onRemove: (key: string) => void;
|
||||
onRowRepositoryChange: (key: string, value: string) => void;
|
||||
onRowBranchChange: (key: string, value: string) => void;
|
||||
onCreateRepository?: (key: string) => void;
|
||||
onRefreshRepositories?: () => void;
|
||||
repositoriesRefreshing?: boolean;
|
||||
lastUsedBranch?: string | null;
|
||||
userSettingsLoaded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the list of repo chips plus the trailing "+ add repository"
|
||||
* button. Extracted from RepoChipsRow so the parent stays under the
|
||||
@@ -48,30 +82,12 @@ export function WorkspaceRepoChips({
|
||||
onRemove,
|
||||
onRowRepositoryChange,
|
||||
onRowBranchChange,
|
||||
onCreateRepository,
|
||||
onRefreshRepositories,
|
||||
repositoriesRefreshing,
|
||||
lastUsedBranch,
|
||||
userSettingsLoaded,
|
||||
}: {
|
||||
rows: TaskRepoRow[];
|
||||
repositories: Repository[];
|
||||
discoveredRepositories?: LocalRepository[];
|
||||
workspaceId: string | null;
|
||||
branchLocked?: boolean;
|
||||
isLocalExecutor?: boolean;
|
||||
currentLocalBranch?: string;
|
||||
currentLocalBranchLoading?: boolean;
|
||||
freshBranchEnabled?: boolean;
|
||||
canAddMore: boolean;
|
||||
addHint?: string;
|
||||
addLabel?: string;
|
||||
allowDuplicateRepositories?: boolean;
|
||||
freshBranchToggle?: React.ReactNode;
|
||||
onAdd: () => void;
|
||||
onRemove: (key: string) => void;
|
||||
onRowRepositoryChange: (key: string, value: string) => void;
|
||||
onRowBranchChange: (key: string, value: string) => void;
|
||||
lastUsedBranch?: string | null;
|
||||
userSettingsLoaded?: boolean;
|
||||
}) {
|
||||
}: WorkspaceRepoChipsProps) {
|
||||
return (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
@@ -102,6 +118,11 @@ export function WorkspaceRepoChips({
|
||||
})}
|
||||
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
|
||||
onBranchChange={(value) => onRowBranchChange(row.key, value)}
|
||||
onCreateRepository={
|
||||
rows.length === 1 && onCreateRepository ? () => onCreateRepository(row.key) : undefined
|
||||
}
|
||||
onRefreshRepositories={rows.length === 1 ? onRefreshRepositories : undefined}
|
||||
repositoriesRefreshing={repositoriesRefreshing}
|
||||
onRemove={() => onRemove(row.key)}
|
||||
/>
|
||||
))}
|
||||
@@ -230,6 +251,9 @@ type RepoChipProps = {
|
||||
onRepositoryChange: (value: string) => void;
|
||||
onBranchChange: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
onCreateRepository?: () => void;
|
||||
onRefreshRepositories?: () => void;
|
||||
repositoriesRefreshing?: boolean;
|
||||
};
|
||||
|
||||
function useRepoChipData({
|
||||
@@ -348,6 +372,15 @@ function computeRepoChipDisplay(
|
||||
return { repoLabel, repoTooltip };
|
||||
}
|
||||
|
||||
function buildCreateRepositoryAction(onSelect?: () => void): PillAction | undefined {
|
||||
if (!onSelect) return undefined;
|
||||
return {
|
||||
label: "Create new repository",
|
||||
icon: <IconFolderPlus className="h-3.5 w-3.5" />,
|
||||
onSelect,
|
||||
};
|
||||
}
|
||||
|
||||
function RepoChip({
|
||||
row,
|
||||
workspaceId,
|
||||
@@ -364,6 +397,9 @@ function RepoChip({
|
||||
onRepositoryChange,
|
||||
onBranchChange,
|
||||
onRemove,
|
||||
onCreateRepository,
|
||||
onRefreshRepositories,
|
||||
repositoriesRefreshing,
|
||||
}: RepoChipProps) {
|
||||
const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({
|
||||
row,
|
||||
@@ -395,6 +431,7 @@ function RepoChip({
|
||||
className="inline-flex items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
|
||||
data-testid="repo-chip"
|
||||
data-repository-id={row.repositoryId || row.localPath || ""}
|
||||
data-repo-row-key={row.key}
|
||||
>
|
||||
<Pill
|
||||
icon={<IconCode className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
@@ -407,6 +444,10 @@ function RepoChip({
|
||||
testId="repo-chip-trigger"
|
||||
tooltip={repoTooltip}
|
||||
filter={scoreRepo}
|
||||
action={buildCreateRepositoryAction(onCreateRepository)}
|
||||
onRefresh={onRefreshRepositories}
|
||||
refreshing={repositoriesRefreshing}
|
||||
refreshLabel="repositories"
|
||||
flat
|
||||
/>
|
||||
<Pill
|
||||
|
||||
@@ -133,8 +133,11 @@ function CreateModeBody(props: DialogFormBodyProps) {
|
||||
onToggleFreshBranch,
|
||||
workflowAgentLocked,
|
||||
repositories,
|
||||
onRefreshRepositories,
|
||||
repositoriesRefreshing,
|
||||
freshBranchAvailable,
|
||||
isLocalExecutor,
|
||||
localRepositoryCreation,
|
||||
} = props;
|
||||
const showTaskName = shouldShowTaskTitleField(isCreateMode, isEditMode, isTaskStarted);
|
||||
const taskNameAutoFocus = !isEditMode && !fs.useRemote;
|
||||
@@ -156,6 +159,9 @@ function CreateModeBody(props: DialogFormBodyProps) {
|
||||
userSettingsLoaded={props.userSettingsLoaded}
|
||||
onToggleNoRepository={props.onToggleNoRepository}
|
||||
onWorkspacePathChange={props.onWorkspacePathChange}
|
||||
localRepositoryCreation={localRepositoryCreation}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
repositoriesRefreshing={repositoriesRefreshing}
|
||||
/>
|
||||
{showTaskName && (
|
||||
<InlineTaskName
|
||||
@@ -431,6 +437,7 @@ export function useTaskCreateDialogSetup(
|
||||
const isCreateMode = mode === "create";
|
||||
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
|
||||
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
|
||||
const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository);
|
||||
const { toast } = useToast();
|
||||
const sessionRepoName = useSessionRepoName(isSessionMode);
|
||||
const {
|
||||
@@ -440,6 +447,7 @@ export function useTaskCreateDialogSetup(
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
computed,
|
||||
@@ -467,7 +475,11 @@ export function useTaskCreateDialogSetup(
|
||||
preserveBranch: initialValues?.checkoutBranch || initialValues?.branch,
|
||||
});
|
||||
useLockedFieldSync(open, workflowId, initialValues, fs);
|
||||
const handlers = useDialogHandlers(fs, repositories);
|
||||
const handlers = useDialogHandlers(fs, repositories, {
|
||||
workspaceId,
|
||||
executors,
|
||||
upsertWorkspaceRepository,
|
||||
});
|
||||
const submitHandlers = useSubmitHandlersWiring({
|
||||
props,
|
||||
fs,
|
||||
@@ -502,6 +514,7 @@ export function useTaskCreateDialogSetup(
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
computed,
|
||||
handlers,
|
||||
submitHandlers,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
type PersistedRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
local_path: string;
|
||||
default_branch: string;
|
||||
source_type: string;
|
||||
};
|
||||
|
||||
async function listRepositories(
|
||||
apiClient: ApiClient,
|
||||
workspaceId: string,
|
||||
): Promise<PersistedRepository[]> {
|
||||
const response = await apiClient.rawRequest(
|
||||
"GET",
|
||||
`/api/v1/workspaces/${workspaceId}/repositories`,
|
||||
);
|
||||
expect(response.ok).toBe(true);
|
||||
return ((await response.json()) as { repositories: PersistedRepository[] }).repositories;
|
||||
}
|
||||
|
||||
async function openCreateTask(page: Page): Promise<void> {
|
||||
const kanban = new KanbanPage(page);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(page.getByTestId("create-task-dialog")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openRepositoryCreation(page: Page): Promise<void> {
|
||||
await page.getByTestId("repo-chip-trigger").click();
|
||||
const search = page.getByPlaceholder("Search repositories...");
|
||||
const refresh = page.getByTestId("repo-refresh-button");
|
||||
const action = page.getByTestId("create-local-repository-button");
|
||||
await expect(search).toBeVisible();
|
||||
await expect(refresh).toBeVisible();
|
||||
await expect(action).toBeVisible();
|
||||
await expect(refresh).toBeEnabled();
|
||||
await refresh.hover();
|
||||
const refreshTooltip = page
|
||||
.locator('[data-slot="tooltip-content"][data-state]')
|
||||
.getByText("Refresh repositories", { exact: true })
|
||||
.filter({ visible: true });
|
||||
await expect(refreshTooltip).toBeVisible();
|
||||
const [searchBox, refreshBox, actionBox] = await Promise.all([
|
||||
search.boundingBox(),
|
||||
refresh.boundingBox(),
|
||||
action.boundingBox(),
|
||||
]);
|
||||
expect(searchBox).not.toBeNull();
|
||||
expect(refreshBox).not.toBeNull();
|
||||
expect(actionBox).not.toBeNull();
|
||||
expect(searchBox!.width).toBeGreaterThanOrEqual(280);
|
||||
expect(searchBox!.x + searchBox!.width).toBeLessThanOrEqual(refreshBox!.x);
|
||||
expect(refreshBox!.x + refreshBox!.width).toBeLessThanOrEqual(actionBox!.x);
|
||||
await action.click();
|
||||
await expect(page.getByTestId("create-local-repository-dialog")).toBeVisible();
|
||||
}
|
||||
|
||||
async function createRepository(page: Page, name: string, targetPath: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Parent directory" }).fill(path.dirname(targetPath));
|
||||
await page.getByRole("textbox", { name: "Repository name" }).fill(name);
|
||||
await expect(page.getByTitle(targetPath)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create repository" }).click();
|
||||
await expect(page.getByTestId("create-local-repository-dialog")).not.toBeVisible();
|
||||
}
|
||||
|
||||
function expectUnbornMainRepository(repositoryPath: string): void {
|
||||
expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
|
||||
expect(
|
||||
execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
||||
cwd: repositoryPath,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe("main");
|
||||
const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
|
||||
expect(head.error).toBeUndefined();
|
||||
expect(head.status).not.toBe(0);
|
||||
}
|
||||
|
||||
function taskIdFromUrl(page: Page): string {
|
||||
const match = page.url().match(/\/t\/([^/?]+)/);
|
||||
if (!match) throw new Error(`Task route missing from ${page.url()}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test.describe("Create task with a new local repository", () => {
|
||||
test("initializes, registers, selects, and starts from an unborn main repository", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const repositoryName = "desktop-unborn-main";
|
||||
const repositoryPath = path.join(
|
||||
backend.tmpDir,
|
||||
"missing-manual-parent",
|
||||
"nested",
|
||||
repositoryName,
|
||||
);
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const directExecutor = executors.find((executor) =>
|
||||
["local", "local_pc"].includes(executor.type),
|
||||
);
|
||||
const directProfile = directExecutor?.profiles?.[0];
|
||||
expect(directExecutor, "a direct local executor is required by the fixture").toBeDefined();
|
||||
expect(
|
||||
directProfile,
|
||||
"a direct local executor profile is required by the fixture",
|
||||
).toBeDefined();
|
||||
|
||||
await openCreateTask(testPage);
|
||||
await testPage.getByTestId("task-title-input").fill("Task on a new local repository");
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
await testPage.getByTestId("executor-profile-selector").click();
|
||||
await testPage.getByRole("option", { name: /Worktree/i }).click();
|
||||
|
||||
await openRepositoryCreation(testPage);
|
||||
await expect(testPage.getByTestId("create-local-repository-dialog")).toContainText(
|
||||
/will switch to/i,
|
||||
);
|
||||
await createRepository(testPage, repositoryName, repositoryPath);
|
||||
expect(fs.statSync(path.dirname(repositoryPath)).isDirectory()).toBe(true);
|
||||
|
||||
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
|
||||
await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main");
|
||||
await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
|
||||
directExecutor!.name,
|
||||
);
|
||||
await expect(testPage.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
|
||||
await testPage.getByTestId("submit-start-agent").click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const persisted = (await listRepositories(apiClient, seedData.workspaceId)).find(
|
||||
(repository) => repository.local_path === repositoryPath,
|
||||
);
|
||||
expect(persisted).toMatchObject({
|
||||
name: repositoryName,
|
||||
local_path: repositoryPath,
|
||||
default_branch: "main",
|
||||
source_type: "local",
|
||||
});
|
||||
const taskId = taskIdFromUrl(testPage);
|
||||
const task = await apiClient.getTask(taskId);
|
||||
expect(task.repositories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ repository_id: persisted!.id, base_branch: "main" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => await apiClient.getTaskEnvironment(taskId), { timeout: 20_000 })
|
||||
.toMatchObject({
|
||||
executor_type: expect.stringMatching(/^(local|local_pc)$/),
|
||||
executor_profile_id: directProfile!.id,
|
||||
});
|
||||
expectUnbornMainRepository(repositoryPath);
|
||||
});
|
||||
|
||||
test("leaves a conflicting target untouched and allows retry with a new name", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const conflictName = "desktop-existing-target";
|
||||
const retryName = "desktop-conflict-retry";
|
||||
const conflictPath = path.join(backend.tmpDir, conflictName);
|
||||
const retryPath = path.join(backend.tmpDir, retryName);
|
||||
const sentinelPath = path.join(conflictPath, "keep.txt");
|
||||
fs.mkdirSync(conflictPath);
|
||||
fs.writeFileSync(sentinelPath, "do not modify\n");
|
||||
const repositoriesBefore = await listRepositories(apiClient, seedData.workspaceId);
|
||||
|
||||
await openCreateTask(testPage);
|
||||
await openRepositoryCreation(testPage);
|
||||
const nameInput = testPage.getByRole("textbox", { name: "Repository name" });
|
||||
await nameInput.fill(conflictName);
|
||||
await testPage.getByRole("button", { name: "Create repository" }).click();
|
||||
|
||||
await expect(testPage.getByTestId("create-local-repository-dialog")).toBeVisible();
|
||||
await expect(testPage.getByRole("alert")).toContainText(/exist|conflict/i);
|
||||
await expect(nameInput).toHaveValue(conflictName);
|
||||
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do not modify\n");
|
||||
expect(fs.existsSync(path.join(conflictPath, ".git"))).toBe(false);
|
||||
expect(await listRepositories(apiClient, seedData.workspaceId)).toHaveLength(
|
||||
repositoriesBefore.length,
|
||||
);
|
||||
|
||||
await nameInput.fill(retryName);
|
||||
await createRepository(testPage, retryName, retryPath);
|
||||
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(retryName);
|
||||
expectUnbornMainRepository(retryPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
type RepositoryRecord = { id: string; local_path: string; default_branch: string };
|
||||
|
||||
async function persistedRepository(
|
||||
apiClient: ApiClient,
|
||||
workspaceId: string,
|
||||
repositoryPath: string,
|
||||
): Promise<RepositoryRecord | undefined> {
|
||||
const response = await apiClient.rawRequest(
|
||||
"GET",
|
||||
`/api/v1/workspaces/${workspaceId}/repositories`,
|
||||
);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = (await response.json()) as { repositories: RepositoryRecord[] };
|
||||
return body.repositories.find((repository) => repository.local_path === repositoryPath);
|
||||
}
|
||||
|
||||
async function openCreateTask(page: Page): Promise<void> {
|
||||
const mobile = new MobileKanbanPage(page);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.click();
|
||||
await expect(page.getByTestId("create-task-dialog")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openCreationDrawer(page: Page): Promise<Locator> {
|
||||
const trigger = page.getByTestId("repo-chip-trigger");
|
||||
await trigger.click();
|
||||
const search = page.getByPlaceholder("Search repositories...");
|
||||
const refresh = page.getByTestId("repo-refresh-button");
|
||||
const action = page.getByTestId("create-local-repository-button");
|
||||
await expect(search).toBeVisible();
|
||||
await expect(refresh).toBeVisible();
|
||||
await expect(action).toBeVisible();
|
||||
const searchControl = search.locator("..");
|
||||
const [searchControlBox, refreshBox, actionBox] = await Promise.all([
|
||||
searchControl.boundingBox(),
|
||||
refresh.boundingBox(),
|
||||
action.boundingBox(),
|
||||
]);
|
||||
expect(searchControlBox).not.toBeNull();
|
||||
expect(refreshBox).not.toBeNull();
|
||||
expect(actionBox).not.toBeNull();
|
||||
expect(searchControlBox!.width).toBeGreaterThan(refreshBox!.width + actionBox!.width);
|
||||
const refreshGap = refreshBox!.x - (searchControlBox!.x + searchControlBox!.width);
|
||||
expect(refreshGap).toBeGreaterThanOrEqual(0);
|
||||
expect(refreshGap).toBeLessThanOrEqual(8);
|
||||
expect(refreshBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(actionBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await action.click();
|
||||
const drawer = page.getByTestId("create-local-repository-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
return drawer;
|
||||
}
|
||||
|
||||
async function expectDrawerGeometry(page: Page, drawer: Locator): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
expect(viewport).not.toBeNull();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await drawer.boundingBox();
|
||||
return !!box && box.y >= 0 && box.y + box.height <= viewport!.height;
|
||||
})
|
||||
.toBe(true);
|
||||
const drawerBox = await drawer.boundingBox();
|
||||
expect(drawerBox).not.toBeNull();
|
||||
expect(drawerBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(viewport!.width);
|
||||
expect(drawerBox!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(viewport!.height);
|
||||
|
||||
const scrollOwners = await drawer.locator("*").evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => ["auto", "scroll"].includes(getComputedStyle(element).overflowY))
|
||||
.map((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
})),
|
||||
);
|
||||
expect(scrollOwners).toHaveLength(1);
|
||||
expect(scrollOwners[0].scrollHeight).toBeGreaterThan(scrollOwners[0].clientHeight);
|
||||
|
||||
const entryBox = await drawer.getByTestId("folder-picker-entry").first().boundingBox();
|
||||
const createButton = drawer.getByRole("button", { name: "Create repository" });
|
||||
const [createBox, footerBox] = await Promise.all([
|
||||
createButton.boundingBox(),
|
||||
createButton.locator("..").boundingBox(),
|
||||
]);
|
||||
expect(entryBox).not.toBeNull();
|
||||
expect(createBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
expect(entryBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(createBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
function expectUnbornMainRepository(repositoryPath: string): void {
|
||||
const symbolicRef = spawnSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
||||
cwd: repositoryPath,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(symbolicRef.error).toBeUndefined();
|
||||
expect(symbolicRef.status).toBe(0);
|
||||
expect(String(symbolicRef.stdout).trim()).toBe("main");
|
||||
|
||||
const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repositoryPath });
|
||||
expect(head.error).toBeUndefined();
|
||||
expect(head.status).not.toBe(0);
|
||||
}
|
||||
|
||||
test.describe("Create task with a new local repository on mobile", () => {
|
||||
test("creates and selects the repository in a contained, scrollable drawer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const repositoryName = "mobile-unborn-main";
|
||||
const parentName = "mobile-created-parent";
|
||||
const parentPath = path.join(backend.tmpDir, parentName);
|
||||
const repositoryPath = path.join(parentPath, repositoryName);
|
||||
for (let index = 0; index < 18; index += 1) {
|
||||
fs.mkdirSync(path.join(backend.tmpDir, `mobile-parent-${String(index).padStart(2, "0")}`));
|
||||
}
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const directExecutor = executors.find((executor) =>
|
||||
["local", "local_pc"].includes(executor.type),
|
||||
);
|
||||
const directProfile = directExecutor?.profiles?.[0];
|
||||
expect(
|
||||
directProfile,
|
||||
"a direct local executor profile is required by the fixture",
|
||||
).toBeDefined();
|
||||
|
||||
await openCreateTask(testPage);
|
||||
await testPage.getByTestId("task-title-input").fill("Mobile task on a new repository");
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
let drawer = await openCreationDrawer(testPage);
|
||||
await expect(testPage.getByRole("textbox", { name: "Repository name" })).toBeFocused();
|
||||
await testPage.getByRole("textbox", { name: "Repository name" }).fill("dismissed-name");
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect(testPage.getByTestId("repo-chip-trigger")).toBeFocused();
|
||||
expect(fs.existsSync(path.join(backend.tmpDir, "dismissed-name"))).toBe(false);
|
||||
await expect(testPage.getByTestId("task-title-input")).toHaveValue(
|
||||
"Mobile task on a new repository",
|
||||
);
|
||||
|
||||
drawer = await openCreationDrawer(testPage);
|
||||
await expectDrawerGeometry(testPage, drawer);
|
||||
const parentInput = drawer.getByRole("textbox", { name: "Parent directory" });
|
||||
await parentInput.fill(backend.tmpDir);
|
||||
await parentInput.press("Enter");
|
||||
await drawer.getByRole("button", { name: "New folder" }).click();
|
||||
await drawer.getByRole("textbox", { name: "New folder name" }).fill(parentName);
|
||||
await drawer.getByRole("button", { name: "Create folder" }).click();
|
||||
await expect(parentInput).toHaveValue(parentPath);
|
||||
const nameInput = testPage.getByRole("textbox", { name: "Repository name" });
|
||||
await nameInput.fill(repositoryName);
|
||||
await expect(testPage.getByTitle(repositoryPath)).toBeVisible();
|
||||
await drawer.getByRole("button", { name: "Create repository" }).click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
|
||||
await expect(testPage.getByTestId("repo-chip-trigger")).toContainText(repositoryName);
|
||||
await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main");
|
||||
await expect(testPage.getByTestId("executor-profile-selector")).toContainText(
|
||||
directExecutor!.name,
|
||||
);
|
||||
await expect(testPage.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 });
|
||||
const taskResponsePromise = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST",
|
||||
);
|
||||
await testPage.getByTestId("submit-start-agent").click();
|
||||
const taskResponse = await taskResponsePromise;
|
||||
expect(taskResponse.ok()).toBe(true);
|
||||
const createdTask = (await taskResponse.json()) as { id: string };
|
||||
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible();
|
||||
|
||||
const repository = await persistedRepository(apiClient, seedData.workspaceId, repositoryPath);
|
||||
expect(repository).toMatchObject({ local_path: repositoryPath, default_branch: "main" });
|
||||
const taskId = createdTask.id;
|
||||
expect((await apiClient.getTask(taskId)).repositories).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ repository_id: repository!.id })]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => await apiClient.getTaskEnvironment(taskId), { timeout: 20_000 })
|
||||
.toMatchObject({
|
||||
executor_type: expect.stringMatching(/^(local|local_pc)$/),
|
||||
executor_profile_id: directProfile!.id,
|
||||
});
|
||||
expect(fs.statSync(path.join(repositoryPath, ".git")).isDirectory()).toBe(true);
|
||||
expectUnbornMainRepository(repositoryPath);
|
||||
});
|
||||
});
|
||||
@@ -75,4 +75,26 @@ describe("useRepositories", () => {
|
||||
await Promise.resolve();
|
||||
expect(mockListRepositories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes an already-loaded workspace when requested", async () => {
|
||||
setup(/* loaded */ true);
|
||||
const { result } = renderHook(() => useRepositories("ws-1"));
|
||||
|
||||
await result.current.refresh();
|
||||
|
||||
expect(mockListRepositories).toHaveBeenCalledWith("ws-1", undefined, { cache: "no-store" });
|
||||
expect(mockSetRepositories).toHaveBeenCalledWith("ws-1", [{ id: "r1", name: "Repo One" }]);
|
||||
});
|
||||
|
||||
it("keeps cached repositories when a manual refresh fails", async () => {
|
||||
setup(/* loaded */ true);
|
||||
mockListRepositories.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
const { result } = renderHook(() => useRepositories("ws-1"));
|
||||
|
||||
await expect(result.current.refresh()).resolves.toBeUndefined();
|
||||
|
||||
expect(mockSetRepositories).not.toHaveBeenCalled();
|
||||
expect(mockSetRepositoriesLoading).toHaveBeenNthCalledWith(1, "ws-1", true);
|
||||
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { listRepositories } from "@/lib/api";
|
||||
@@ -33,6 +33,19 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
|
||||
// on workspace switch, and forcedRef/isLoaded gate re-fetches after success.
|
||||
const forcedRef = useRef<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !workspaceId) return;
|
||||
setRepositoriesLoading(workspaceId, true);
|
||||
try {
|
||||
const response = await listRepositories(workspaceId, undefined, { cache: "no-store" });
|
||||
setRepositories(workspaceId, response.repositories);
|
||||
} catch {
|
||||
// Keep the existing cached repositories when a manual refresh fails.
|
||||
} finally {
|
||||
setRepositoriesLoading(workspaceId, false);
|
||||
}
|
||||
}, [enabled, setRepositories, setRepositoriesLoading, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspaceId) return;
|
||||
if (isLoaded && isLoading) {
|
||||
@@ -88,5 +101,5 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
|
||||
};
|
||||
}, [enabled, forceRefresh, isLoaded, setRepositories, setRepositoriesLoading, workspaceId]);
|
||||
|
||||
return { repositories, isLoading };
|
||||
return { repositories, isLoading, refresh };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDirectory } from "./fs-api";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("createDirectory", () => {
|
||||
it("posts the folder name and parent path", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
path: "/work/projects",
|
||||
parent: "/work",
|
||||
entries: [],
|
||||
choosable: true,
|
||||
}),
|
||||
{ status: 201, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
global.fetch = fetchSpy as typeof global.fetch;
|
||||
|
||||
const listing = await createDirectory("/work", "projects");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/api/v1/fs/create-dir",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parent_path: "/work", name: "projects" }),
|
||||
}),
|
||||
);
|
||||
expect(listing).toMatchObject({ path: "/work/projects", choosable: true });
|
||||
});
|
||||
});
|
||||
@@ -21,3 +21,18 @@ export async function listDirectory(path: string, options?: ApiRequestOptions) {
|
||||
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return fetchJson<DirectoryListing>(`/api/v1/fs/list-dir${qs}`, options);
|
||||
}
|
||||
|
||||
export async function createDirectory(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<DirectoryListing>("/api/v1/fs/create-dir", {
|
||||
...options,
|
||||
init: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parent_path: parentPath, name }),
|
||||
...(options?.init ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { initializeLocalRepository } from "./workspace-api";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("initializeLocalRepository", () => {
|
||||
it("posts the backend's snake-case initialization payload", async () => {
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: "repo-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "alpha",
|
||||
source_type: "local",
|
||||
local_path: "/work/alpha",
|
||||
default_branch: "main",
|
||||
}),
|
||||
{ status: 201, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
global.fetch = fetchSpy as typeof global.fetch;
|
||||
|
||||
const repository = await initializeLocalRepository("ws-1", {
|
||||
name: "alpha",
|
||||
parentPath: "/work",
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/api/v1/workspaces/ws-1/repositories/initialize-local",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "alpha", parent_path: "/work" }),
|
||||
}),
|
||||
);
|
||||
expect(repository).toMatchObject({ id: "repo-1", default_branch: "main" });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
RepositoryBranchesResponse,
|
||||
ListRepositoryScriptsResponse,
|
||||
Workspace,
|
||||
Repository,
|
||||
} from "@/lib/types/http";
|
||||
|
||||
// Workspace operations
|
||||
@@ -37,6 +38,21 @@ export async function listRepositories(
|
||||
return fetchJson<ListRepositoriesResponse>(url, options);
|
||||
}
|
||||
|
||||
export async function initializeLocalRepository(
|
||||
workspaceId: string,
|
||||
payload: { name: string; parentPath: string },
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<Repository>(`/api/v1/workspaces/${workspaceId}/repositories/initialize-local`, {
|
||||
...options,
|
||||
init: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: payload.name, parent_path: payload.parentPath }),
|
||||
...(options?.init ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists git branches for a workspace repo. Pass exactly one of `repositoryId`
|
||||
* (an imported workspace repo) or `path` (an on-machine folder discovered
|
||||
|
||||
@@ -50,6 +50,7 @@ export type WorkspaceSliceActions = {
|
||||
setActiveWorkspace: (workspaceId: string | null) => void;
|
||||
setWorkspaces: (workspaces: WorkspaceState["items"]) => void;
|
||||
setRepositories: (workspaceId: string, repositories: Repository[]) => void;
|
||||
upsertRepository: (workspaceId: string, repository: Repository) => void;
|
||||
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
|
||||
setRepositoryBranches: (
|
||||
repositoryId: string,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { createWorkspaceSlice } from "./workspace-slice";
|
||||
import type { WorkspaceSlice } from "./types";
|
||||
import type { Branch } from "@/lib/types/http";
|
||||
import type { Branch, Repository } from "@/lib/types/http";
|
||||
|
||||
function makeStore() {
|
||||
return create<WorkspaceSlice>()(
|
||||
@@ -13,9 +13,44 @@ function makeStore() {
|
||||
}
|
||||
|
||||
const REPO = "repo-1";
|
||||
const CREATED_REPOSITORY = "repo-created";
|
||||
const BRANCHES: Branch[] = [{ name: "main", type: "local" }];
|
||||
const FETCHED_AT = "2026-04-30T10:00:00Z";
|
||||
|
||||
function repository(id: string): Repository {
|
||||
return { id, name: id, workspace_id: "ws-1" } as Repository;
|
||||
}
|
||||
|
||||
describe("upsertRepository", () => {
|
||||
it("preserves an unloaded cache so a later fetch includes existing repositories", () => {
|
||||
const s = makeStore();
|
||||
|
||||
s.getState().upsertRepository("ws-1", repository(CREATED_REPOSITORY));
|
||||
|
||||
expect(s.getState().repositories.loadedByWorkspaceId["ws-1"]).toBeUndefined();
|
||||
expect(s.getState().repositories.itemsByWorkspaceId["ws-1"]).toEqual([
|
||||
repository(CREATED_REPOSITORY),
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges against the current store state without dropping concurrent repositories", () => {
|
||||
const s = makeStore();
|
||||
s.getState().setRepositories("ws-1", [repository("repo-before")]);
|
||||
s.getState().setRepositories("ws-1", [
|
||||
repository("repo-before"),
|
||||
repository("repo-concurrent"),
|
||||
]);
|
||||
|
||||
s.getState().upsertRepository("ws-1", repository(CREATED_REPOSITORY));
|
||||
|
||||
expect(s.getState().repositories.itemsByWorkspaceId["ws-1"].map((repo) => repo.id)).toEqual([
|
||||
"repo-before",
|
||||
"repo-concurrent",
|
||||
CREATED_REPOSITORY,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRepositoryBranches", () => {
|
||||
it("stores branches and marks loaded without meta", () => {
|
||||
const s = makeStore();
|
||||
|
||||
@@ -46,6 +46,21 @@ export const createWorkspaceSlice: StateCreator<
|
||||
draft.repositories.loadingByWorkspaceId[workspaceId] = false;
|
||||
draft.repositories.loadedByWorkspaceId[workspaceId] = true;
|
||||
}),
|
||||
upsertRepository: (workspaceId, repository) =>
|
||||
set((draft) => {
|
||||
const repositories = draft.repositories.itemsByWorkspaceId[workspaceId] ?? [];
|
||||
const loaded = draft.repositories.loadedByWorkspaceId[workspaceId];
|
||||
const existingIndex = repositories.findIndex((candidate) => candidate.id === repository.id);
|
||||
if (existingIndex === -1) {
|
||||
repositories.push(repository);
|
||||
} else {
|
||||
repositories[existingIndex] = repository;
|
||||
}
|
||||
draft.repositories.itemsByWorkspaceId[workspaceId] = repositories;
|
||||
if (loaded !== undefined) {
|
||||
draft.repositories.loadedByWorkspaceId[workspaceId] = loaded;
|
||||
}
|
||||
}),
|
||||
setRepositoriesLoading: (workspaceId, loading) =>
|
||||
set((draft) => {
|
||||
draft.repositories.loadingByWorkspaceId[workspaceId] = loading;
|
||||
|
||||
@@ -326,6 +326,7 @@ export type AppState = {
|
||||
appendInstallOutput: (agentName: string, chunk: string) => void;
|
||||
clearInstallJob: (agentName: string) => void;
|
||||
setRepositories: (workspaceId: string, repositories: Repository[]) => void;
|
||||
upsertRepository: (workspaceId: string, repository: Repository) => void;
|
||||
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
|
||||
setRepositoryBranches: (
|
||||
repositoryId: string,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
spec: docs/specs/create-local-repository/spec.md
|
||||
created: 2026-07-21
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Implementation Plan: Create a Local Repository During Task Creation
|
||||
|
||||
## Overview
|
||||
|
||||
Add an explicit backend operation that owns directory creation, commitless Git initialization, and
|
||||
workspace repository persistence. Then add a selector action that opens a shared directory-browser
|
||||
form, merges the returned repository into workspace state, selects it in the originating task row,
|
||||
and moves the first task to a compatible direct local executor. Desktop and mobile use the same
|
||||
mutation/state logic with purpose-built Dialog and Drawer shells.
|
||||
|
||||
## Backend
|
||||
|
||||
### Initialization service
|
||||
|
||||
- Add `InitializeLocalRepositoryRequest` and `Service.InitializeLocalRepository` in
|
||||
`apps/backend/internal/task/service/service_requests.go` and a focused new
|
||||
`apps/backend/internal/task/service/local_repository_initialization.go`.
|
||||
- Validate that `Name` is a single non-empty host path segment and `ParentPath` is an existing,
|
||||
writable absolute directory. Canonicalize the parent before joining the target, atomically create
|
||||
the absent target, and never modify an existing path.
|
||||
- Run `git init --initial-branch=main` without creating files, configuring an author, or committing.
|
||||
- Register the canonical target through the existing `CreateRepository` path with `source_type=local`
|
||||
and `default_branch=main`, preserving validation and `repository.created` publication.
|
||||
- On a partial failure, leave no repository row and remove only the target created by this request
|
||||
when cleanup is safe; log cleanup failure without masking the primary error.
|
||||
|
||||
### HTTP contract
|
||||
|
||||
- Register `POST /api/v1/workspaces/:id/repositories/initialize-local` in
|
||||
`apps/backend/internal/task/handlers/repository_handlers.go`.
|
||||
- Bind `{name,parent_path}`, verify workspace ownership/existence before filesystem mutation, map
|
||||
validation to `400`, target existence to `409`, and return the existing repository DTO with `201`.
|
||||
- Add handler coverage in `apps/backend/internal/task/handlers/repository_handlers_test.go`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### API and shared directory browser
|
||||
|
||||
- Add `initializeLocalRepository(workspaceId, { name, parentPath })` to
|
||||
`apps/web/lib/api/domains/workspace-api.ts`, returning `Repository`.
|
||||
- Refactor `apps/web/components/folder-picker.tsx` so its directory-listing, breadcrumb, loading,
|
||||
error, navigation, and current-folder selection are exported as a reusable browser body. Keep the
|
||||
existing **None** mode trigger behavior unchanged.
|
||||
- Add focused tests for target-path derivation, validation, error retention, and successful API
|
||||
response handling in `apps/web/components/create-local-repository-surface.test.tsx`.
|
||||
|
||||
### Repository selector and creation surface
|
||||
|
||||
- Extend the cmdk-only `Pill` contract in `apps/web/components/task-create-dialog-pill.tsx` with an
|
||||
optional command action rendered as a `CommandItem`; do not insert arbitrary interactive markup
|
||||
into `Command`. Cover keyboard and pointer activation in
|
||||
`apps/web/components/task-create-dialog-pill.test.tsx`.
|
||||
- Add `apps/web/components/create-local-repository-surface.tsx`. It owns shared form state and renders
|
||||
a desktop `Dialog` or mobile `Drawer` selected with `useResponsiveBreakpoint`.
|
||||
- Thread a task-create-only **Create new repository** action through
|
||||
`apps/web/components/task-create-dialog-repo-chips.tsx` and
|
||||
`apps/web/components/task-create-dialog-workspace-repo-chips.tsx`. Expose it only for a single
|
||||
repository row. Quick Chat callers of `WorkspaceRepoChips` do not receive the opt-in action.
|
||||
- Add a repository-created handler in `apps/web/components/task-create-dialog-handlers.ts` and its
|
||||
prop/type wiring in `apps/web/components/task-create-dialog-types.ts`,
|
||||
`apps/web/components/task-create-dialog-prop-builders.ts`, and
|
||||
`apps/web/components/task-create-dialog.tsx`. It directly patches the originating row with the new
|
||||
repository ID and `main`, selects a compatible direct local executor/profile, updates task-create
|
||||
last-used state, and leaves sibling rows untouched. Surface the automatic executor change in the
|
||||
form; block confirmation without filesystem mutation when no direct local profile is available.
|
||||
- Merge the returned DTO into `repositories.itemsByWorkspaceId[workspaceId]` through the existing
|
||||
workspace slice without waiting for an asynchronous refetch; deduplicate by repository ID.
|
||||
- Extend `apps/web/components/task-create-dialog-repo-chips.test.tsx` and
|
||||
`apps/web/components/task-create-dialog-handlers.test.ts` for action visibility, row targeting,
|
||||
cache merge, success, conflict, retry, and cancel behavior.
|
||||
|
||||
## Mobile Design Contract
|
||||
|
||||
- Desktop keeps the repository search popover and opens a compact creation Dialog from its visible
|
||||
command action.
|
||||
- Mobile keeps the repository selector entry point but opens an inset bottom Drawer modeled on
|
||||
`apps/web/components/task/mobile/mobile-picker-sheet.tsx`.
|
||||
- The drawer has fixed name/target context, one internally scrolling directory list, and a fixed
|
||||
safe-area-aware primary footer. Directory and action rows are at least 44px high.
|
||||
- Shared form state, validation, listing requests, creation request, cache merge, row selection, and
|
||||
direct-local executor selection are viewport-independent. Dismissal creates nothing and returns
|
||||
focus to the originating selector.
|
||||
- The mobile E2E proves creation and selection, internal scrolling/viewport containment, touch target
|
||||
size, safe-area footer visibility, and absence of document horizontal overflow.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Initialization success:**
|
||||
`apps/backend/internal/task/service/local_repository_initialization_test.go` uses `t.TempDir()` and
|
||||
real Git to assert canonical path, unborn `main`, the absence of `HEAD` commits, persisted workspace
|
||||
record, and repository-created event.
|
||||
- **Input and conflict safety:** the same service test table covers invalid names, relative/missing/
|
||||
non-directory parents, existing empty/non-empty targets, and no mutation or persistence.
|
||||
- **Partial failure cleanup:** service tests inject or induce Git/persistence failures and assert no
|
||||
repository row plus request-owned target cleanup behavior.
|
||||
- **HTTP mapping:** `apps/backend/internal/task/handlers/repository_handlers_test.go` covers `201`,
|
||||
`400`, `404`, and `409`, including no filesystem mutation for an unknown workspace.
|
||||
- **Frontend form:** `apps/web/components/create-local-repository-surface.test.tsx` covers validation,
|
||||
target preview, in-flight disablement, error retention/retry, success callback, and responsive shell.
|
||||
- **Selector wiring:** existing pill/repo-chip/handler tests prove the action is task-create-only, a
|
||||
returned repository updates only the originating row and active-workspace cache, a worktree
|
||||
selection changes to a direct local profile, missing local profiles prevent the mutation, and the
|
||||
action is absent from multi-repository tasks.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- Desktop: add `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`. Open **New Task**,
|
||||
create a repository under the isolated backend home, assert it is selected with `main`, submit the
|
||||
task, and verify the task is bound to the persisted repository, uses a direct local executor, and
|
||||
the filesystem has no commit. Add a target-conflict case that proves the existing directory remains
|
||||
unchanged.
|
||||
- Mobile: add `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`. Enter through
|
||||
`MobileKanbanPage.mobileFab`, complete the same create-and-submit outcome, and assert Drawer
|
||||
containment, a scrollable internal list, 44px action rows, visible safe-area footer, focus/dismiss
|
||||
behavior, and zero document horizontal overflow.
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [task-01-backend-initialization](task-01-backend-initialization.md) - done
|
||||
|
||||
Wave 2 (after Wave 1):
|
||||
|
||||
- [x] [task-02-task-create-selector](task-02-task-create-selector.md) - done
|
||||
|
||||
Wave 3 (after integrated backend and frontend):
|
||||
|
||||
- [x] [task-03-e2e-and-verification](task-03-e2e-and-verification.md) - done
|
||||
|
||||
The frontend task is sequential because the shared picker, task-create handlers, and state wiring are
|
||||
one behavior and edit overlapping files. E2E follows the integrated product path.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make typecheck
|
||||
(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)
|
||||
(cd apps && pnpm --filter @kandev/web test -- --run \
|
||||
components/create-local-repository-surface.test.tsx \
|
||||
components/task-create-dialog-pill.test.tsx \
|
||||
components/task-create-dialog-repo-chips.test.tsx \
|
||||
components/task-create-dialog-handlers.test.ts)
|
||||
(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome \
|
||||
tests/task/mobile-create-task-new-local-repository.spec.ts)
|
||||
make test
|
||||
make lint
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Filesystem and database persistence cannot share a transaction. The service must narrowly own and
|
||||
test partial-failure cleanup without ever deleting a pre-existing target.
|
||||
- An unborn Git repository cannot support the existing branch/worktree flow. The form must switch
|
||||
the first task to a direct local executor and must not silently fall back to worktree, container, or
|
||||
remote execution.
|
||||
- The repository list may already be hydrated while the event arrives asynchronously. Selection must
|
||||
use the returned DTO directly and cache insertion must be idempotent.
|
||||
- Nested overlays inside the full-height mobile task dialog can break scroll and focus ownership. The
|
||||
directory browser must render inside the creation Drawer rather than opening another phone popover.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: "01-backend-initialization"
|
||||
title: "Backend local repository initialization"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/create-local-repository/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Backend Local Repository Initialization
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The workspace-scoped endpoint creates an absent `<parent>/<name>` repository with unborn branch
|
||||
`main`, no files, and no commits, then returns its persisted repository DTO.
|
||||
- Invalid names/parents, unknown workspaces, and existing targets fail with the specified status and
|
||||
do not mutate the target or persist a repository.
|
||||
- Git or persistence failure leaves no repository row and applies the request-owned cleanup contract.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make -C apps/backend fmt
|
||||
(cd apps/backend && go test ./internal/task/service ./internal/task/handlers)
|
||||
make -C apps/backend lint
|
||||
```
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/backend/internal/task/service/service_requests.go`
|
||||
- `apps/backend/internal/task/service/local_repository_initialization.go`
|
||||
- `apps/backend/internal/task/service/local_repository_initialization_test.go`
|
||||
- `apps/backend/internal/task/handlers/repository_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/repository_handlers_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: What, API Surface, Permissions, Failure Modes, and Persistence Guarantees.
|
||||
- Existing exact-path validation: `apps/backend/internal/task/service/repository_discovery.go`.
|
||||
- Existing persistence/event path: `Service.CreateRepository` in
|
||||
`apps/backend/internal/task/service/service_resources.go`.
|
||||
- Security boundary: `docs/decisions/2026-07-20-explicit-local-repository-trust.md`.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report endpoint/service behavior, rollback strategy, tests run, files touched, blockers, and residual
|
||||
filesystem race risks. Mark this file `done` and update the plan entry only after targeted tests pass.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: "02-task-create-selector"
|
||||
title: "Task-create local repository selector"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-initialization"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/create-local-repository/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Task-Create Local Repository Selector
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A task-create repository picker exposes **Create new repository**, and success selects the returned
|
||||
repository with `main` only in the originating row, moves the first task to a compatible direct
|
||||
local executor, explains that change, and preserves the rest of the task draft.
|
||||
- Multi-repository tasks do not expose the action because they require worktree execution.
|
||||
- The shared directory browser drives name/parent validation, target preview, retryable errors, and
|
||||
idempotent active-workspace cache insertion without changing the **None** mode picker.
|
||||
- Desktop Dialog and mobile Drawer satisfy the spec's focus, scroll, safe-area, touch-target, and
|
||||
no-hover mobile contract; Quick Chat does not gain the action, and no compatible direct local
|
||||
profile blocks creation before the API call.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make typecheck
|
||||
(cd apps && pnpm --filter @kandev/web test -- --run \
|
||||
components/create-local-repository-surface.test.tsx \
|
||||
components/task-create-dialog-pill.test.tsx \
|
||||
components/task-create-dialog-repo-chips.test.tsx \
|
||||
components/task-create-dialog-handlers.test.ts)
|
||||
(cd apps/web && pnpm run lint)
|
||||
```
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/lib/api/domains/workspace-api.ts`
|
||||
- `apps/web/components/folder-picker.tsx`
|
||||
- `apps/web/components/create-local-repository-surface.tsx`
|
||||
- `apps/web/components/create-local-repository-surface.test.tsx`
|
||||
- `apps/web/components/task-create-dialog-pill.tsx`
|
||||
- `apps/web/components/task-create-dialog-pill.test.tsx`
|
||||
- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`
|
||||
- `apps/web/components/task-create-dialog-repo-chips.tsx`
|
||||
- `apps/web/components/task-create-dialog-repo-chips.test.tsx`
|
||||
- `apps/web/components/task-create-dialog-handlers.ts`
|
||||
- `apps/web/components/task-create-dialog-handlers.test.ts`
|
||||
- `apps/web/components/task-create-dialog-types.ts`
|
||||
- `apps/web/components/task-create-dialog-prop-builders.ts`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01's endpoint and response/error contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: What, API Surface, Mobile Design Contract, Scenarios.
|
||||
- Shared browser pattern: `apps/web/components/folder-picker.tsx`.
|
||||
- Mobile geometry exemplar: `apps/web/components/task/mobile/mobile-picker-sheet.tsx`.
|
||||
- Selector constraint: the cmdk-only note in `apps/web/components/task-create-dialog-pill.tsx`.
|
||||
- Repository cache: `apps/web/lib/state/slices/workspace/workspace-slice.ts` and
|
||||
`apps/web/hooks/domains/workspace/use-repositories.ts`.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report desktop/mobile behavior, state and focus handling, tests run, files touched, blockers, and
|
||||
remaining visual risks. Mark this file `done` and update the plan entry only after targeted tests pass.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "03-e2e-and-verification"
|
||||
title: "Local repository creation E2E verification"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["01-backend-initialization", "02-task-create-selector"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/create-local-repository/spec.md"
|
||||
---
|
||||
|
||||
# Task 03: Local Repository Creation E2E Verification
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop Playwright proves commitless repository initialization, automatic row/branch and direct
|
||||
local executor selection, task binding, durable filesystem/DB results, and non-destructive conflict
|
||||
handling through the real backend.
|
||||
- Mobile Playwright proves the same user outcome plus Drawer containment, internal scroll ownership,
|
||||
44px actions, safe-area footer visibility, focus/dismiss behavior, and no horizontal overflow.
|
||||
- Repository-wide format, typecheck, test, and lint checks pass, or exact unrelated blockers are
|
||||
recorded.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make typecheck
|
||||
(cd apps/web && pnpm e2e:run tests/task/create-task-new-local-repository.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome \
|
||||
tests/task/mobile-create-task-new-local-repository.spec.ts)
|
||||
make test
|
||||
make lint
|
||||
```
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/e2e/tests/task/create-task-new-local-repository.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-create-task-new-local-repository.spec.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts` only if a typed read/assertion helper is missing.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 01 and 02 must be integrated and their targeted tests green.
|
||||
|
||||
## Inputs
|
||||
|
||||
- All spec Scenarios and Mobile Design Contract.
|
||||
- `apps/web/e2e/tests/task/create-task.spec.ts` for desktop task creation.
|
||||
- `apps/web/e2e/tests/task/mobile-create-task-remote-repo.spec.ts` for mobile picker geometry.
|
||||
- `apps/web/e2e/fixtures/test-base.ts` for isolated `HOME`, real Git, and backend paths.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report scenario coverage, exact commands and results, screenshots/geometry observations, files
|
||||
touched, blockers, and residual risks. Mark this file `done` and update the plan only after E2E and
|
||||
required verification complete.
|
||||
|
||||
## Results
|
||||
|
||||
- Backend service and handler tests: `720` passed across both packages.
|
||||
- Focused frontend tests: `82` passed across seven files.
|
||||
- Desktop Playwright: `2` passed, covering creation/task binding and non-destructive conflict retry.
|
||||
- Mobile Playwright: `1` passed, covering creation/task binding plus drawer layout, scrolling, focus,
|
||||
touch targets, and viewport containment.
|
||||
- Repository-wide `make fmt`, `make typecheck`, `make test`, and `make lint`: passed.
|
||||
- Public documentation tests and validation: passed for all `40` published pages.
|
||||
- Code and security reviews: no actionable blockers remain.
|
||||
@@ -27,7 +27,7 @@ A new workspace created by a user does not automatically receive a workflow.
|
||||
|
||||
1. Open **Settings → Workspaces** and select **Add Workspace**.
|
||||
2. Enter the required workspace name.
|
||||
3. Open the workspace's **Repositories** page and add the local repositories the workspace needs. Remote URLs are not registered on this page; enter them through **New Task → Remote**.
|
||||
3. Open the workspace's **Repositories** page and add existing local repositories the workspace needs. You can also initialize a new empty repository while creating a task. Remote URLs are not registered on this page; enter them through **New Task → Remote**.
|
||||
4. Open its **Workflows** page and create, import, or synchronize a workflow.
|
||||
5. On **Workspace Settings**, optionally choose a **Default Executor** and **Default Agent Profile**. Both default to **No default** unless configured.
|
||||
|
||||
@@ -51,7 +51,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
|
||||
|
||||
| Source | Use it for | Important behavior |
|
||||
|---|---|---|
|
||||
| **Repo** | A configured or discovered local repository | Select a base branch for each repository row. Add more rows for a multi-repository task. |
|
||||
| **Repo** | A configured, discovered, or new local repository | Select a base branch for each repository row. For a single-row new task, **Create new repository** initializes an empty `main` repository in a parent folder you choose. Add more rows for a multi-repository task. |
|
||||
| **Remote** | A remote repository | Search configured GitHub, GitLab, or Azure DevOps repositories, or paste a supported URL; then select the branch. Clone and fetch require valid credentials. |
|
||||
| **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. |
|
||||
|
||||
@@ -68,6 +68,8 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
|
||||
|
||||
Kandev remembers draft or recently used repository, branch, executor, and profile choices. Review the restored values before submitting, especially after changing workspace.
|
||||
|
||||
Creating a repository is available only in an unlocked, single-repository **New Task** form. Kandev rejects an existing target path, creates no initial files or commit, registers the repository in the workspace, and switches the task to a direct **Local** executor profile. If no direct Local profile is available, repository creation stays disabled. Add more repository rows only after selecting existing repositories; empty multi-repository worktrees are not supported.
|
||||
|
||||
### Choose the profile for tasks created by agents
|
||||
|
||||
Open **Settings → General → Task Actions → Profile for Tasks Created by Agents** to choose which agent profile Kandev assigns when an agent calls a Kandev MCP tool that creates a task without choosing `agent_profile_id`. The profile determines the agent, model, and setup used when the new task starts:
|
||||
|
||||
@@ -83,14 +83,14 @@ Remote repository and issue/PR URLs are not added from this settings page. Use t
|
||||
|
||||
Detected capabilities come from the installed CLI and can change after an agent upgrade or login. A model or mode shown in documentation is not guaranteed for every account. If no built-in adapter fits, **Add TUI Agent** creates a passthrough integration; passthrough has a different resume, usage, and MCP contract from an ACP-capable agent.
|
||||
|
||||
For the first task, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
|
||||
For the first task on an existing repository, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. A repository initialized from **New Task** is the exception: it has no commit to support a worktree, so Kandev switches that task to a direct Local profile. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
|
||||
|
||||
## Create and start the first task
|
||||
|
||||
1. Select **New Task** from the sidebar or task board.
|
||||
2. Enter a specific title and a description with the expected outcome, constraints, and validation. A non-empty description enables the normal start and create-only actions.
|
||||
3. Under **Repo**, select the workspace repository and base branch. Select **None** only for work that genuinely needs a plain workspace directory.
|
||||
4. Choose the Development workflow, an agent profile, and the Worktree executor profile. Kandev remembers compatible recent choices, so re-check them after changing repository, agent, or trust boundary.
|
||||
3. Under **Repo**, select the workspace repository and base branch. To start a new project, open the repository selector, choose **Create new repository**, enter one folder name, and select its parent folder. Kandev creates an empty `main` repository with no initial commit. Select **None** only for work that genuinely needs a plain workspace directory.
|
||||
4. Choose the Development workflow and an agent profile. For an existing repository, choose the Worktree executor profile. For a newly initialized repository, keep the Local profile that Kandev selects automatically. Kandev remembers compatible recent choices, so re-check them after changing repository, agent, or trust boundary.
|
||||
5. Select **Start task**. Its menu also offers **Start task in plan mode** and **Create without starting agent**. On an empty description, the primary action is **Start Plan Mode**.
|
||||
|
||||
Starting creates the task and its initial session. Workflow step configuration determines whether entry actions or later transitions start another session, inject a prompt, or stop at a human gate. With a structured ACP profile, create-only prepares the session/workspace without starting an agent turn. A passthrough/TUI profile is the exception: the backend upgrades prepare-only to a full launch because the native PTY must exist.
|
||||
@@ -98,6 +98,7 @@ Starting creates the task and its initial session. Workflow step configuration d
|
||||
The dialog also supports multiple repositories, remote GitHub rows, and a single-repository **Fork a new branch** option when the Local executor is selected. Important boundaries:
|
||||
|
||||
- Multi-repository tasks require the Worktree executor in the current task-create path.
|
||||
- Creating an empty repository is available only for a single repository row and switches the task to a direct Local profile.
|
||||
- No-repository tasks cannot use the Worktree executor.
|
||||
- The local fork option is off by default and requires explicit consent before Kandev discards dirty source-checkout changes.
|
||||
- Agent and executor choices can be disabled when their remote credential requirements are incompatible.
|
||||
|
||||
@@ -142,6 +142,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
|
||||
|
||||
| Spec | Status |
|
||||
|---|---|
|
||||
| [create-local-repository](create-local-repository/spec.md) | shipped |
|
||||
| [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building |
|
||||
| [improve-kandev](improve-kandev/spec.md) | draft |
|
||||
| [homebrew-core](homebrew-core/spec.md) | draft |
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-07-21
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Create a Local Repository During Task Creation
|
||||
|
||||
## Why
|
||||
|
||||
Users can create tasks from an existing local or remote repository, but a new project requires
|
||||
leaving the task flow to initialize and register a Git repository first. Users need to create that
|
||||
repository on the machine running Kandev, select it, and continue composing the same task.
|
||||
|
||||
## What
|
||||
|
||||
- The local repository selector in **Create New Task** exposes a visible **Create new repository**
|
||||
action while the task has exactly one repository row.
|
||||
- The creation form asks for a repository name and an absolute parent path. It shows the resulting
|
||||
target path before confirmation. The name is one platform-native path segment, not a path.
|
||||
- A manually entered parent path may contain missing trailing directories. Confirmation creates
|
||||
those directories beneath the nearest accessible, trusted ancestor before initializing the
|
||||
repository.
|
||||
- The parent-folder browser lists the filesystem of the machine running Kandev and shares the same
|
||||
directory browsing behavior as the **None** source mode's starting-folder picker. It also lets
|
||||
the user create and enter one new child folder in the currently displayed directory.
|
||||
- Confirmation creates `<parent>/<name>` only when that target does not already exist, initializes a
|
||||
local Git repository with unborn default branch `main`, and does not create files or commits.
|
||||
- A successfully initialized repository is persisted as a local repository in the active workspace,
|
||||
added to the in-memory workspace repository list, selected in the originating row, and displays
|
||||
`main` as that row's branch. Other task fields and repository rows are unchanged.
|
||||
- A commitless repository can run only with a direct local executor. Confirmation automatically
|
||||
selects a compatible `local` or `local_pc` executor profile for the first task and explains the
|
||||
change. If no direct local profile is available, confirmation is blocked before filesystem
|
||||
mutation. After the repository has a commit, later tasks may use any otherwise compatible
|
||||
executor through the normal task flow.
|
||||
- Multi-repository task creation does not expose the action because those tasks require the worktree
|
||||
executor. Users can add the repository to a multi-repository task after it has its first commit.
|
||||
- Initialization is an explicit local-path trust grant for the exact canonical repository path. It
|
||||
does not widen automatic discovery roots or grant access to sibling paths.
|
||||
- The form remains open with the entered name and parent folder when initialization fails, surfaces
|
||||
an actionable error, and does not select a repository. Editing either input or navigating to
|
||||
another folder clears the stale submission error immediately.
|
||||
- Closing the creation form before confirmation does not create a directory or repository record.
|
||||
- Desktop and mobile provide the same capability. Mobile uses a touch-native picker surface with no
|
||||
hover-only action and keeps repository name, target path, errors, and the primary action reachable.
|
||||
|
||||
The explicit local repository trust contract remains defined by
|
||||
[Local Workspace Repositories](../workspaces/local-repositories.md) and
|
||||
[ADR-2026-07-20](../../decisions/2026-07-20-explicit-local-repository-trust.md).
|
||||
|
||||
## Data Model
|
||||
|
||||
No new database entity is introduced. A successful initialization creates one existing
|
||||
`repositories` record:
|
||||
|
||||
| Field | Contract |
|
||||
| --- | --- |
|
||||
| `workspace_id` | The active task-creation workspace. |
|
||||
| `name` | The user-supplied repository name after trimming. |
|
||||
| `source_type` | `local`. |
|
||||
| `local_path` | The canonical absolute `<parent>/<name>` path. |
|
||||
| `default_branch` | `main`. |
|
||||
|
||||
The filesystem repository and the `repositories` row are created as one product operation when the
|
||||
repository form is confirmed, before the task is submitted. A successful response means both exist;
|
||||
a failed response must not leave a repository row. The filesystem cleanup behavior for a partial
|
||||
failure is described under Failure modes.
|
||||
|
||||
## API Surface
|
||||
|
||||
`POST /api/v1/workspaces/:id/repositories/initialize-local`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "new-project",
|
||||
"parent_path": "/home/user/projects"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `201 Created` with the existing `Repository` DTO. The response has `source_type: "local"`,
|
||||
the canonical `local_path`, and `default_branch: "main"`.
|
||||
|
||||
The backend, not the browser, is authoritative for validation and filesystem changes:
|
||||
|
||||
- `400 Bad Request`: missing/invalid name, relative parent path, inaccessible parent, parent is not a
|
||||
directory, or invalid request body.
|
||||
- `404 Not Found`: workspace does not exist.
|
||||
- `409 Conflict`: `<parent>/<name>` already exists. Existing directories are never converted or
|
||||
overwritten by this operation.
|
||||
- `500 Internal Server Error`: Git initialization or repository persistence fails.
|
||||
|
||||
`POST /api/v1/fs/create-dir`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"parent_path": "/home/user/projects",
|
||||
"name": "team"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `201 Created` with the same directory-listing shape as `GET /api/v1/fs/list-dir`, rooted
|
||||
at the new folder. Invalid names or inaccessible parents return `400 Bad Request`; an existing child
|
||||
returns `409 Conflict`.
|
||||
|
||||
## Permissions
|
||||
|
||||
This follows Kandev's trusted-local-user model. The user may initialize a repository under any
|
||||
explicitly selected parent path whose nearest existing ancestor the Kandev process can trust and
|
||||
write. Missing trailing directories are created as process-owned directories. The grant is scoped
|
||||
to the created canonical target path and the active workspace.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Condition | Observable behavior |
|
||||
| --- | --- |
|
||||
| Name is empty, `.`/`..`, or contains a host path separator | Confirmation is disabled client-side and the backend rejects a forged request. |
|
||||
| The nearest existing parent ancestor cannot be trusted, read, or written | The form stays open, shows an error, and no repository is selected or persisted. |
|
||||
| Target already exists, including an empty directory | The request returns conflict and does not modify the target. |
|
||||
| `git` is unavailable or initialization fails | The request fails, no repository row is persisted, and Kandev removes only the target directory created by this request when cleanup is safe. A cleanup failure is logged and the error remains visible. |
|
||||
| Repository persistence fails after Git initialization | The request fails and performs the same best-effort cleanup of the request-owned target; no repository row is returned. |
|
||||
| Frontend state refresh fails after a successful response | The returned repository is still selected directly and merged into the active workspace cache without waiting for a second list request. |
|
||||
| No direct local executor profile is available | The form explains that an empty repository requires local execution and does not call the initialization endpoint. |
|
||||
|
||||
## Persistence Guarantees
|
||||
|
||||
The initialized Git repository is ordinary user-owned filesystem content and is never tied to task
|
||||
deletion. Its existing `repositories` record survives backend restart. Canceling, archiving, or
|
||||
deleting the task does not delete the repository or its workspace registration.
|
||||
|
||||
## Mobile Design Contract
|
||||
|
||||
- **Entry and outcome:** the repository chip opens the same local repository picker as desktop; a
|
||||
visible, at-least-44px **Create new repository** row opens the creation flow and returns the new
|
||||
selection to the originating chip.
|
||||
- **Nearest exemplar:** `MobilePickerSheet` supplies the inset bottom-drawer geometry, fixed header,
|
||||
internal scrolling, and predictable dismissal used by task-workbench pickers.
|
||||
- **Hierarchy and primary action:** repository name and current target path stay above the directory
|
||||
list; **Create repository** is the single primary action in the safe-area-aware footer.
|
||||
- **Presentation rationale:** repository creation is a short temporary choice inside task creation,
|
||||
so mobile uses an inset bottom drawer rather than a new route or a compressed desktop dialog.
|
||||
- **Geometry:** the drawer uses one `min-h-0` vertical scroll owner, dynamic viewport sizing, bottom
|
||||
safe-area clearance, no document horizontal overflow, and 44px directory/action rows. Keyboard
|
||||
focus remains visible and dismissal returns focus to the repository selector.
|
||||
- **Shared logic:** validation, target-path derivation, directory listing, API mutation, workspace
|
||||
cache update, row selection, and compatible local-executor selection are shared. Only
|
||||
Dialog-versus-Drawer composition is responsive.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** the task dialog has a local repository row, **WHEN** the user chooses **Create new
|
||||
repository**, selects `/work/projects`, enters `alpha`, and confirms, **THEN**
|
||||
`/work/projects/alpha` contains a commitless Git repository whose unborn branch is `main`, the
|
||||
workspace has a matching local repository record, the originating task row selects `alpha` /
|
||||
`main`, and the first task uses a direct local executor.
|
||||
- **GIVEN** a worktree or container executor profile is selected and a direct local profile is
|
||||
available, **WHEN** repository initialization succeeds, **THEN** the form selects the compatible
|
||||
direct local profile and tells the user why the executor changed.
|
||||
- **GIVEN** no direct local executor profile is available, **WHEN** the user opens the creation form,
|
||||
**THEN** the primary action explains the requirement and no directory or repository is created.
|
||||
- **GIVEN** a task with multiple repository rows, **WHEN** the user opens any repository picker,
|
||||
**THEN** **Create new repository** is not offered because an unborn repository cannot use the
|
||||
required worktree executor.
|
||||
- **GIVEN** `/work/projects/alpha` already exists, **WHEN** the user tries to create `alpha` under
|
||||
`/work/projects`, **THEN** the UI reports the conflict and Kandev does not modify or register the
|
||||
existing path.
|
||||
- **GIVEN** `/work` exists but `/work/team/projects` does not, **WHEN** the user enters
|
||||
`/work/team/projects` as the parent and creates `alpha`, **THEN** Kandev creates both missing
|
||||
parent directories and initializes `/work/team/projects/alpha`.
|
||||
- **GIVEN** the folder navigator displays `/work`, **WHEN** the user creates a child named `team`,
|
||||
**THEN** `/work/team` is created and the navigator enters that folder on desktop and mobile.
|
||||
- **GIVEN** an initialization error is visible, **WHEN** the user edits the name or parent path or
|
||||
navigates to another folder, **THEN** the previous error disappears before the next submission.
|
||||
- **GIVEN** an invalid name or unwritable parent, **WHEN** the user confirms, **THEN** the form stays
|
||||
open with its inputs intact and no repository is selected.
|
||||
- **GIVEN** the creation form is open, **WHEN** the user dismisses it without confirming, **THEN** no
|
||||
filesystem or database change occurs and their task draft remains intact.
|
||||
- **GIVEN** a repository was initialized and selected, **WHEN** the user later cancels or deletes the
|
||||
task, **THEN** the repository directory and workspace repository record remain.
|
||||
- **GIVEN** a Pixel 5 viewport, **WHEN** the user completes the same flow, **THEN** all actions are
|
||||
reachable by touch, the directory list scrolls inside the drawer, the footer clears the safe area,
|
||||
and the page has no horizontal overflow.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Creating or publishing a remote Git hosting repository.
|
||||
- Cloning, importing, or converting an existing directory; existing paths continue through the
|
||||
existing local repository selection and settings flows.
|
||||
- README, license, `.gitignore`, language template, remote, visibility, or hosting-provider setup.
|
||||
- Choosing the initial branch name or creating an initial commit.
|
||||
- Worktree, container, or remote execution before the repository has its first commit.
|
||||
- Creating an unborn repository as part of a multi-repository task.
|
||||
- Adding the creation action to Quick Chat, repository settings, project repository pickers, or MCP
|
||||
tools in this iteration.
|
||||
- Automatically deleting a successfully created repository when its originating task is canceled or
|
||||
deleted.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
See [Create Local Repository plan](../../plans/create-local-repository/plan.md).
|
||||
Reference in New Issue
Block a user