diff --git a/api/api.go b/api/api.go index d37f8f0..af99706 100644 --- a/api/api.go +++ b/api/api.go @@ -80,7 +80,7 @@ func Init(ctx context.Context, cfg *utils.AppConfig, assets embed.FS, info *AppI registerHealthRoutes(app, humaAPI) registerProjectRoutes(v1) - registerWorktreeRoutes(v1) + registerWorktreeRoutes(v1, cfg) registerBranchRoutes(v1) registerTaskRoutes(v1) registerNotePadRoutes(v1) diff --git a/api/system.go b/api/system.go index b2e9c78..67b0be0 100644 --- a/api/system.go +++ b/api/system.go @@ -4,6 +4,8 @@ import ( "context" "errors" "net/http" + "path/filepath" + "strings" "github.com/danielgtaylor/huma/v2" @@ -159,11 +161,12 @@ func registerSystemRoutes(group *huma.Group, cfg *utils.AppConfig, terminalManag huma.Post(group, "/system/ai-assistant-status/update", func(ctx context.Context, input *struct { Body utils.AIAssistantStatusConfig `json:"body"` }) (*h.MessageResponse, error) { - // 更新内存中的配置 - cfg.Terminal.AIAssistantStatus = input.Body - - // 写回配置文件 - utils.WriteConfig(cfg) + // 原子更新:在锁内完成修改+写盘 + if err := utils.UpdateConfig(cfg, func(c *utils.AppConfig) { + c.Terminal.AIAssistantStatus = input.Body + }); err != nil { + return nil, huma.Error500InternalServerError("failed to save configuration") + } // 热重载:更新所有现有终端的配置 if terminalManager != nil { @@ -194,8 +197,12 @@ func registerSystemRoutes(group *huma.Group, cfg *utils.AppConfig, terminalManag huma.Post(group, "/system/developer-config/update", func(ctx context.Context, input *struct { Body utils.DeveloperConfig `json:"body"` }) (*h.MessageResponse, error) { - cfg.Developer = input.Body - utils.WriteConfig(cfg) + // 原子更新:在锁内完成修改+写盘 + if err := utils.UpdateConfig(cfg, func(c *utils.AppConfig) { + c.Developer = input.Body + }); err != nil { + return nil, huma.Error500InternalServerError("failed to save configuration") + } if terminalManager != nil { terminalManager.UpdateScrollbackEnabled(input.Body.EnableTerminalScrollback) @@ -231,25 +238,29 @@ func registerSystemRoutes(group *huma.Group, cfg *utils.AppConfig, terminalManag Shell string `json:"shell" doc:"Shell命令,空值表示使用自动选择"` } `json:"body"` }) (*h.MessageResponse, error) { - // Validate the shell command if provided + // 验证 Shell 命令有效性 if err := utils.ValidateShellCommand(input.Body.Shell); err != nil { return nil, huma.Error400BadRequest("Invalid shell command: " + err.Error()) } - // Update config based on current platform - switch utils.GetAvailableShells(cfg.Terminal.Shell).Platform { - case "windows": - cfg.Terminal.Shell.Windows = input.Body.Shell - case "darwin": - cfg.Terminal.Shell.Darwin = input.Body.Shell - default: - cfg.Terminal.Shell.Linux = input.Body.Shell + // 获取当前平台以便更新对应配置 + platform := utils.GetAvailableShells(cfg.Terminal.Shell).Platform + + // 原子更新:在锁内完成修改+写盘 + if err := utils.UpdateConfig(cfg, func(c *utils.AppConfig) { + switch platform { + case "windows": + c.Terminal.Shell.Windows = input.Body.Shell + case "darwin": + c.Terminal.Shell.Darwin = input.Body.Shell + default: + c.Terminal.Shell.Linux = input.Body.Shell + } + }); err != nil { + return nil, huma.Error500InternalServerError("failed to save configuration") } - // Persist to config file - utils.WriteConfig(cfg) - - // Hot-reload: update terminal manager's shell config for new sessions + // 热重载:更新终端管理器的 Shell 配置,新会话生效 if terminalManager != nil { terminalManager.UpdateShellConfig(cfg.Terminal.Shell) } @@ -295,6 +306,50 @@ func registerSystemRoutes(group *huma.Group, cfg *utils.AppConfig, terminalManag op.Description = "检查指定的Shell命令是否有效可用" op.Tags = []string{systemTag} }) + + huma.Get(group, "/system/worktree-settings", func(ctx context.Context, input *struct{}) (*h.ItemResponse[utils.WorktreeConfig], error) { + resp := h.NewItemResponse(cfg.Worktree) + resp.Status = http.StatusOK + return resp, nil + }, func(op *huma.Operation) { + op.OperationID = "system-worktree-settings-get" + op.Summary = "获取 Worktree 全局设置" + op.Tags = []string{systemTag} + }) + + huma.Post(group, "/system/worktree-settings/update", func(ctx context.Context, input *struct { + Body utils.WorktreeConfig `json:"body"` + }) (*h.ItemResponse[utils.WorktreeConfig], error) { + globalBaseDir := strings.TrimSpace(input.Body.GlobalBaseDir) + pattern := strings.TrimSpace(input.Body.GlobalDirNamePattern) + if globalBaseDir != "" && !filepath.IsAbs(globalBaseDir) { + return nil, huma.Error400BadRequest("globalBaseDir must be an absolute path") + } + if pattern == "" { + return nil, huma.Error400BadRequest("globalDirNamePattern is required") + } + + // 安全检查:全局基础目录不能是敏感系统目录 + if globalBaseDir != "" && utils.IsSensitiveSystemDir(globalBaseDir) { + return nil, huma.Error400BadRequest("globalBaseDir cannot be a system directory") + } + + // 原子更新:在锁内完成修改+写盘 + if err := utils.UpdateConfig(cfg, func(c *utils.AppConfig) { + c.Worktree.GlobalBaseDir = globalBaseDir + c.Worktree.GlobalDirNamePattern = pattern + }); err != nil { + return nil, huma.Error500InternalServerError("failed to save configuration") + } + + resp := h.NewItemResponse(cfg.Worktree) + resp.Status = http.StatusOK + return resp, nil + }, func(op *huma.Operation) { + op.OperationID = "system-worktree-settings-update" + op.Summary = "更新 Worktree 全局设置" + op.Tags = []string{systemTag} + }) } func mapSystemError(err error) error { diff --git a/api/worktree.go b/api/worktree.go index 6ba41c4..e5186ab 100644 --- a/api/worktree.go +++ b/api/worktree.go @@ -18,9 +18,11 @@ const worktreeTag = "worktree-工作树" type createWorktreeInput struct { Body struct { - BranchName string `json:"branchName" doc:"分支名称" required:"true"` - BaseBranch string `json:"baseBranch" doc:"基础分支" default:""` - CreateBranch bool `json:"createBranch" doc:"是否创建新分支" default:"true"` + BranchName string `json:"branchName" doc:"分支名称" required:"true"` + BaseBranch string `json:"baseBranch" doc:"基础分支" default:""` + CreateBranch bool `json:"createBranch" doc:"是否创建新分支" default:"true"` + Location string `json:"location,omitempty" doc:"创建位置(project/global),为空表示使用项目默认"` + GlobalBaseDirOverride string `json:"globalBaseDirOverride,omitempty" doc:"全局 Worktree 基础目录(仅本次创建,优先级高于全局配置)"` } `json:"body"` } @@ -30,7 +32,7 @@ type commitWorktreeInput struct { } `json:"body"` } -func registerWorktreeRoutes(group *huma.Group) { +func registerWorktreeRoutes(group *huma.Group, cfg *utils.AppConfig) { worktreeSvc := service.NewWorktreeService() huma.Post(group, "/projects/{projectId}/worktrees/create", func( @@ -44,8 +46,14 @@ func registerWorktreeRoutes(group *huma.Group) { ctx, input.ProjectID, input.Body.BranchName, - input.Body.BaseBranch, - input.Body.CreateBranch, + service.CreateWorktreeOptions{ + BaseBranch: input.Body.BaseBranch, + CreateBranch: input.Body.CreateBranch, + Location: input.Body.Location, + GlobalBaseDirOverride: input.Body.GlobalBaseDirOverride, + GlobalBaseDir: cfg.Worktree.GlobalBaseDir, + GlobalDirNamePattern: cfg.Worktree.GlobalDirNamePattern, + }, ) if err != nil { return nil, mapWorktreeError(err) diff --git a/model/db_gen.go b/model/db_gen.go index 42db34c..4e5c094 100644 --- a/model/db_gen.go +++ b/model/db_gen.go @@ -54,6 +54,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.projectUpdateStmt, err = db.PrepareContext(ctx, projectUpdate); err != nil { return nil, fmt.Errorf("error preparing query ProjectUpdate: %w", err) } + if q.projectUpdateWorktreeBasePathStmt, err = db.PrepareContext(ctx, projectUpdateWorktreeBasePath); err != nil { + return nil, fmt.Errorf("error preparing query ProjectUpdateWorktreeBasePath: %w", err) + } if q.projectUpdatePriorityStmt, err = db.PrepareContext(ctx, projectUpdatePriority); err != nil { return nil, fmt.Errorf("error preparing query ProjectUpdatePriority: %w", err) } @@ -160,6 +163,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing projectUpdateStmt: %w", cerr) } } + if q.projectUpdateWorktreeBasePathStmt != nil { + if cerr := q.projectUpdateWorktreeBasePathStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing projectUpdateWorktreeBasePathStmt: %w", cerr) + } + } if q.projectUpdatePriorityStmt != nil { if cerr := q.projectUpdatePriorityStmt.Close(); cerr != nil { err = fmt.Errorf("error closing projectUpdatePriorityStmt: %w", cerr) @@ -282,67 +290,69 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - accessTokenCreateStmt *sql.Stmt - accessTokenDeleteAllByUserIdStmt *sql.Stmt - accessTokenGetByIdStmt *sql.Stmt - accessTokenRefreshStmt *sql.Stmt - getOneStmt *sql.Stmt - projectCreateStmt *sql.Stmt - projectGetByIDStmt *sql.Stmt - projectListStmt *sql.Stmt - projectSoftDeleteStmt *sql.Stmt - projectUpdateStmt *sql.Stmt - projectUpdatePriorityStmt *sql.Stmt - taskCountByWorktreeStmt *sql.Stmt - userCreateStmt *sql.Stmt - userDeleteStmt *sql.Stmt - userDisableStmt *sql.Stmt - userGetByIdStmt *sql.Stmt - userGetByUsernameStmt *sql.Stmt - userListStmt *sql.Stmt - userListCountStmt *sql.Stmt - userUpdateInfoStmt *sql.Stmt - userUpdatePasswordStmt *sql.Stmt - worktreeCreateStmt *sql.Stmt - worktreeGetByIDStmt *sql.Stmt - worktreeListByProjectStmt *sql.Stmt - worktreeSoftDeleteStmt *sql.Stmt - worktreeUpdateMetadataStmt *sql.Stmt - worktreeUpdateStatusStmt *sql.Stmt + db DBTX + tx *sql.Tx + accessTokenCreateStmt *sql.Stmt + accessTokenDeleteAllByUserIdStmt *sql.Stmt + accessTokenGetByIdStmt *sql.Stmt + accessTokenRefreshStmt *sql.Stmt + getOneStmt *sql.Stmt + projectCreateStmt *sql.Stmt + projectGetByIDStmt *sql.Stmt + projectListStmt *sql.Stmt + projectSoftDeleteStmt *sql.Stmt + projectUpdateStmt *sql.Stmt + projectUpdateWorktreeBasePathStmt *sql.Stmt + projectUpdatePriorityStmt *sql.Stmt + taskCountByWorktreeStmt *sql.Stmt + userCreateStmt *sql.Stmt + userDeleteStmt *sql.Stmt + userDisableStmt *sql.Stmt + userGetByIdStmt *sql.Stmt + userGetByUsernameStmt *sql.Stmt + userListStmt *sql.Stmt + userListCountStmt *sql.Stmt + userUpdateInfoStmt *sql.Stmt + userUpdatePasswordStmt *sql.Stmt + worktreeCreateStmt *sql.Stmt + worktreeGetByIDStmt *sql.Stmt + worktreeListByProjectStmt *sql.Stmt + worktreeSoftDeleteStmt *sql.Stmt + worktreeUpdateMetadataStmt *sql.Stmt + worktreeUpdateStatusStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - accessTokenCreateStmt: q.accessTokenCreateStmt, - accessTokenDeleteAllByUserIdStmt: q.accessTokenDeleteAllByUserIdStmt, - accessTokenGetByIdStmt: q.accessTokenGetByIdStmt, - accessTokenRefreshStmt: q.accessTokenRefreshStmt, - getOneStmt: q.getOneStmt, - projectCreateStmt: q.projectCreateStmt, - projectGetByIDStmt: q.projectGetByIDStmt, - projectListStmt: q.projectListStmt, - projectSoftDeleteStmt: q.projectSoftDeleteStmt, - projectUpdateStmt: q.projectUpdateStmt, - projectUpdatePriorityStmt: q.projectUpdatePriorityStmt, - taskCountByWorktreeStmt: q.taskCountByWorktreeStmt, - userCreateStmt: q.userCreateStmt, - userDeleteStmt: q.userDeleteStmt, - userDisableStmt: q.userDisableStmt, - userGetByIdStmt: q.userGetByIdStmt, - userGetByUsernameStmt: q.userGetByUsernameStmt, - userListStmt: q.userListStmt, - userListCountStmt: q.userListCountStmt, - userUpdateInfoStmt: q.userUpdateInfoStmt, - userUpdatePasswordStmt: q.userUpdatePasswordStmt, - worktreeCreateStmt: q.worktreeCreateStmt, - worktreeGetByIDStmt: q.worktreeGetByIDStmt, - worktreeListByProjectStmt: q.worktreeListByProjectStmt, - worktreeSoftDeleteStmt: q.worktreeSoftDeleteStmt, - worktreeUpdateMetadataStmt: q.worktreeUpdateMetadataStmt, - worktreeUpdateStatusStmt: q.worktreeUpdateStatusStmt, + db: tx, + tx: tx, + accessTokenCreateStmt: q.accessTokenCreateStmt, + accessTokenDeleteAllByUserIdStmt: q.accessTokenDeleteAllByUserIdStmt, + accessTokenGetByIdStmt: q.accessTokenGetByIdStmt, + accessTokenRefreshStmt: q.accessTokenRefreshStmt, + getOneStmt: q.getOneStmt, + projectCreateStmt: q.projectCreateStmt, + projectGetByIDStmt: q.projectGetByIDStmt, + projectListStmt: q.projectListStmt, + projectSoftDeleteStmt: q.projectSoftDeleteStmt, + projectUpdateStmt: q.projectUpdateStmt, + projectUpdateWorktreeBasePathStmt: q.projectUpdateWorktreeBasePathStmt, + projectUpdatePriorityStmt: q.projectUpdatePriorityStmt, + taskCountByWorktreeStmt: q.taskCountByWorktreeStmt, + userCreateStmt: q.userCreateStmt, + userDeleteStmt: q.userDeleteStmt, + userDisableStmt: q.userDisableStmt, + userGetByIdStmt: q.userGetByIdStmt, + userGetByUsernameStmt: q.userGetByUsernameStmt, + userListStmt: q.userListStmt, + userListCountStmt: q.userListCountStmt, + userUpdateInfoStmt: q.userUpdateInfoStmt, + userUpdatePasswordStmt: q.userUpdatePasswordStmt, + worktreeCreateStmt: q.worktreeCreateStmt, + worktreeGetByIDStmt: q.worktreeGetByIDStmt, + worktreeListByProjectStmt: q.worktreeListByProjectStmt, + worktreeSoftDeleteStmt: q.worktreeSoftDeleteStmt, + worktreeUpdateMetadataStmt: q.worktreeUpdateMetadataStmt, + worktreeUpdateStatusStmt: q.worktreeUpdateStatusStmt, } } diff --git a/model/project.sql_gen.go b/model/project.sql_gen.go index 43ec5a0..fa33631 100644 --- a/model/project.sql_gen.go +++ b/model/project.sql_gen.go @@ -230,6 +230,43 @@ func (q *Queries) ProjectUpdate(ctx context.Context, arg *ProjectUpdateParams) ( return &i, err } +const projectUpdateWorktreeBasePath = `-- name: ProjectUpdateWorktreeBasePath :one +UPDATE projects +SET + updated_at = ?1, + worktree_base_path = CAST(?2 AS TEXT) +WHERE id = ?3 + AND deleted_at IS NULL +RETURNING id, created_at, updated_at, deleted_at, name, path, description, default_branch, worktree_base_path, remote_url, last_sync_at, hide_path, priority +` + +type ProjectUpdateWorktreeBasePathParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` + WorktreeBasePath *string `db:"worktree_base_path" json:"worktreeBasePath"` + Id string `db:"id" json:"id"` +} + +func (q *Queries) ProjectUpdateWorktreeBasePath(ctx context.Context, arg *ProjectUpdateWorktreeBasePathParams) (*Project, error) { + row := q.queryRow(ctx, q.projectUpdateWorktreeBasePathStmt, projectUpdateWorktreeBasePath, arg.UpdatedAt, arg.WorktreeBasePath, arg.Id) + var i Project + err := row.Scan( + &i.Id, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Name, + &i.Path, + &i.Description, + &i.DefaultBranch, + &i.WorktreeBasePath, + &i.RemoteUrl, + &i.LastSyncAt, + &i.HidePath, + &i.Priority, + ) + return &i, err +} + const projectUpdatePriority = `-- name: ProjectUpdatePriority :one UPDATE projects SET diff --git a/model/queries/project.sql b/model/queries/project.sql index fc8825e..50cf0b7 100644 --- a/model/queries/project.sql +++ b/model/queries/project.sql @@ -50,6 +50,15 @@ WHERE id = @id AND deleted_at IS NULL RETURNING *; +-- name: ProjectUpdateWorktreeBasePath :one +UPDATE projects +SET + updated_at = @updated_at, + worktree_base_path = CAST(@worktree_base_path AS TEXT) +WHERE id = @id + AND deleted_at IS NULL +RETURNING *; + -- name: ProjectSoftDelete :execrows UPDATE projects SET diff --git a/service/branch_service.go b/service/branch_service.go index c80725f..5aafa55 100644 --- a/service/branch_service.go +++ b/service/branch_service.go @@ -129,7 +129,10 @@ func (s *BranchService) CreateBranch(ctx context.Context, projectID, name, base if createWorktree { worktreeService := NewWorktreeService() - if _, err := worktreeService.CreateWorktree(ctx, projectID, branchName, baseBranch, false); err != nil { + if _, err := worktreeService.CreateWorktree(ctx, projectID, branchName, CreateWorktreeOptions{ + BaseBranch: baseBranch, + CreateBranch: false, + }); err != nil { logger.Error("create worktree for branch failed", zap.Error(err), zap.String("projectId", projectID), diff --git a/service/worktree_service.go b/service/worktree_service.go index cb63d9a..de05693 100644 --- a/service/worktree_service.go +++ b/service/worktree_service.go @@ -17,19 +17,29 @@ import ( "go.uber.org/zap" ) -// WorktreeService coordinates CRUD operations between git worktrees and the database. +// WorktreeService 协调 git worktree 与数据库之间的 CRUD 操作。 type WorktreeService struct { asyncStatusRefresh bool } -// NewWorktreeService builds a WorktreeService with async status refresh enabled. +// CreateWorktreeOptions 创建 Worktree 时的选项参数。 +type CreateWorktreeOptions struct { + BaseBranch string // 基础分支(新建分支时的起始点) + CreateBranch bool // 是否创建新分支 + Location string // 创建位置:"project"(项目目录)或 "global"(全局目录) + GlobalBaseDirOverride string // 全局目录覆盖(仅本次生效,不持久化) + GlobalBaseDir string // 全局 Worktree 基础目录(来自配置) + GlobalDirNamePattern string // 全局目录命名模式(如 {projectName}-{branch}) +} + +// NewWorktreeService 创建一个启用异步状态刷新的 WorktreeService 实例。 func NewWorktreeService() *WorktreeService { return &WorktreeService{ asyncStatusRefresh: true, } } -// AsyncRefresh toggles async status refresh behaviour (useful for tests). +// AsyncRefresh 切换异步状态刷新行为(用于测试)。 func (s *WorktreeService) AsyncRefresh(enabled bool) { if s == nil { return @@ -37,13 +47,12 @@ func (s *WorktreeService) AsyncRefresh(enabled bool) { s.asyncStatusRefresh = enabled } -// CreateWorktree provisions a new git worktree and persists its metadata. +// CreateWorktree 创建一个新的 git worktree 并持久化其元数据。 func (s *WorktreeService) CreateWorktree( ctx context.Context, projectID string, branchName string, - baseBranch string, - createBranch bool, + opts CreateWorktreeOptions, ) (*model.Worktree, error) { if ctx == nil { ctx = context.Background() @@ -74,8 +83,8 @@ func (s *WorktreeService) CreateWorktree( } targetBranch := strings.TrimSpace(branchName) - if createBranch { - refBranch := strings.TrimSpace(baseBranch) + if opts.CreateBranch { + refBranch := strings.TrimSpace(opts.BaseBranch) if refBranch == "" { if project.DefaultBranch != nil && *project.DefaultBranch != "" { refBranch = *project.DefaultBranch @@ -88,7 +97,7 @@ func (s *WorktreeService) CreateWorktree( } } - worktreePath, err := s.resolveWorktreePath(project, targetBranch) + worktreePath, baseDirToPersist, persistRequested, err := s.resolveWorktreePath(project, targetBranch, opts) if err != nil { return nil, err } @@ -123,6 +132,29 @@ func (s *WorktreeService) CreateWorktree( return nil, err } + if persistRequested { + updatedAt := time.Now() + var basePathParam *string + if strings.TrimSpace(baseDirToPersist) != "" { + cleaned := filepath.Clean(baseDirToPersist) + basePathParam = &cleaned + } + + if _, err := q.ProjectUpdateWorktreeBasePath(ctx, &model.ProjectUpdateWorktreeBasePathParams{ + UpdatedAt: updatedAt, + WorktreeBasePath: basePathParam, + Id: projectID, + }); err != nil { + _ = gitRepo.RemoveWorktree(worktreePath, true) + _, _ = q.WorktreeSoftDelete(ctx, &model.WorktreeSoftDeleteParams{ + DeletedAt: &updatedAt, + UpdatedAt: updatedAt, + Id: worktree.Id, + }) + return nil, err + } + } + // 同步刷新状态,确保返回的 worktree 包含最新的 git 状态信息 refreshed, err := s.RefreshWorktreeStatus(ctx, worktree.Id) if err != nil { @@ -137,7 +169,7 @@ func (s *WorktreeService) CreateWorktree( return refreshed, nil } -// ListWorktrees returns worktrees for a project ordered by main flag then creation. +// ListWorktrees 返回项目的所有 worktree,按主 worktree 标志和创建时间排序。 func (s *WorktreeService) ListWorktrees(ctx context.Context, projectID string) ([]*model.Worktree, error) { if ctx == nil { ctx = context.Background() @@ -151,7 +183,7 @@ func (s *WorktreeService) ListWorktrees(ctx context.Context, projectID string) ( return q.WorktreeListByProject(ctx, projectID) } -// GetWorktree fetches a worktree by identifier. +// GetWorktree 根据 ID 获取 worktree 记录。 func (s *WorktreeService) GetWorktree(ctx context.Context, id string) (*model.Worktree, error) { if ctx == nil { ctx = context.Background() @@ -172,7 +204,7 @@ func (s *WorktreeService) GetWorktree(ctx context.Context, id string) (*model.Wo return wt, nil } -// DeleteWorktree removes a worktree from git and the database. +// DeleteWorktree 从 git 和数据库中删除 worktree。 func (s *WorktreeService) DeleteWorktree(ctx context.Context, id string, force, deleteBranch bool) error { if ctx == nil { ctx = context.Background() @@ -208,16 +240,16 @@ func (s *WorktreeService) DeleteWorktree(ctx context.Context, id string, force, return err } - // Check if project path exists + // 检查项目路径是否存在 var gitRepo *git.GitRepo if _, err := os.Stat(project.Path); os.IsNotExist(err) { utils.Logger().Warn("project path does not exist, skipping git operations", zap.String("projectPath", project.Path), zap.String("worktreeId", id), ) - // Skip git operations and proceed to database cleanup + // 跳过 git 操作,继续进行数据库清理 } else { - // Project exists, try git operations + // 项目存在,尝试 git 操作 gitRepo, err = git.DetectRepository(project.Path) if err != nil { utils.Logger().Warn("failed to detect git repository, skipping git removal", @@ -226,17 +258,17 @@ func (s *WorktreeService) DeleteWorktree(ctx context.Context, id string, force, zap.String("worktreeId", id), ) } else { - // Try to remove the worktree from git + // 尝试从 git 中移除 worktree if err := gitRepo.RemoveWorktree(worktree.Path, force); err != nil { - // If the worktree path doesn't exist anymore, we can still proceed - // Check if the error is because the worktree doesn't exist + // 如果 worktree 路径已不存在,可以继续处理 + // 检查错误是否因为 worktree 不存在 if _, statErr := os.Stat(worktree.Path); os.IsNotExist(statErr) { utils.Logger().Warn("worktree path does not exist, skipping git removal", zap.String("path", worktree.Path), zap.String("worktreeId", id), ) } else { - // For other errors, return them + // 其他错误则返回 return err } } @@ -262,7 +294,7 @@ func (s *WorktreeService) DeleteWorktree(ctx context.Context, id string, force, return err } -// RefreshWorktreeStatus updates cached status fields for a worktree and returns the refreshed record. +// RefreshWorktreeStatus 更新 worktree 的缓存状态字段并返回刷新后的记录。 func (s *WorktreeService) RefreshWorktreeStatus(ctx context.Context, id string) (*model.Worktree, error) { if ctx == nil { ctx = context.Background() @@ -328,7 +360,7 @@ func (s *WorktreeService) RefreshWorktreeStatus(ctx context.Context, id string) return updated, nil } -// RefreshAllWorktrees refreshes status for every worktree belonging to a project. +// RefreshAllWorktrees 刷新项目下所有 worktree 的状态。 func (s *WorktreeService) RefreshAllWorktrees(ctx context.Context, projectID string) (updated, failed int, err error) { if ctx == nil { ctx = context.Background() @@ -354,7 +386,7 @@ func (s *WorktreeService) RefreshAllWorktrees(ctx context.Context, projectID str return updated, failed, nil } -// RefreshWorktreeCommitInfo refreshes commit/status metadata for all worktrees and returns the updated list. +// RefreshWorktreeCommitInfo 刷新所有 worktree 的提交/状态元数据并返回更新后的列表。 func (s *WorktreeService) RefreshWorktreeCommitInfo(ctx context.Context, projectID string) ([]*model.Worktree, error) { if ctx == nil { ctx = context.Background() @@ -365,7 +397,7 @@ func (s *WorktreeService) RefreshWorktreeCommitInfo(ctx context.Context, project return s.ListWorktrees(ctx, projectID) } -// SyncWorktrees ensures git worktrees and the database remain aligned. +// SyncWorktrees 确保 git worktree 与数据库保持同步。 func (s *WorktreeService) SyncWorktrees(ctx context.Context, projectID string) error { if ctx == nil { ctx = context.Background() @@ -486,7 +518,7 @@ func (s *WorktreeService) SyncWorktrees(ctx context.Context, projectID string) e return nil } -// CommitWorktree stages all changes within the worktree and creates a commit with the provided message. +// CommitWorktree 暂存 worktree 中的所有更改并使用指定消息创建提交。 func (s *WorktreeService) CommitWorktree(ctx context.Context, id, message string) (*model.Worktree, error) { if ctx == nil { ctx = context.Background() @@ -545,25 +577,123 @@ func (s *WorktreeService) CommitWorktree(ctx context.Context, id, message string return updated, nil } -func (s *WorktreeService) resolveWorktreePath(project *model.Project, branchName string) (string, error) { - basePath := "" - if project.WorktreeBasePath != nil && strings.TrimSpace(*project.WorktreeBasePath) != "" { - basePath = *project.WorktreeBasePath - } else { - basePath = filepath.Join(project.Path, ".worktrees") +// resolveWorktreePath 根据选项解析 worktree 的完整路径。 +// 返回值: +// - worktreePath: 最终的 worktree 目录路径 +// - baseDirToPersist: 需要持久化到项目的基础目录(仅当使用全局配置时) +// - persistRequested: 是否需要持久化基础目录到项目 +// - err: 错误信息 +func (s *WorktreeService) resolveWorktreePath(project *model.Project, branchName string, opts CreateWorktreeOptions) (worktreePath string, baseDirToPersist string, persistRequested bool, err error) { + if project == nil { + return "", "", false, fmt.Errorf("project is required") + } + + location := strings.TrimSpace(opts.Location) + if location != "" && location != "project" && location != "global" { + return "", "", false, fmt.Errorf("invalid location: %s", location) + } + + pattern := strings.TrimSpace(opts.GlobalDirNamePattern) + if pattern == "" { + pattern = "{projectName}-{branch}" + } + + baseDir := "" + globalMode := false + persistRequested = location != "" + + switch location { + case "project": + baseDir = filepath.Join(project.Path, ".worktrees") + globalMode = false + baseDirToPersist = "" + case "global": + // 优先检查覆盖参数(仅本次生效,不持久化) + overrideDir := strings.TrimSpace(opts.GlobalBaseDirOverride) + configDir := strings.TrimSpace(opts.GlobalBaseDir) + + if overrideDir != "" { + baseDir = overrideDir + // 覆盖参数仅本次生效,不持久化到项目 + baseDirToPersist = "" + persistRequested = false + } else if configDir != "" { + baseDir = configDir + // 使用全局配置,持久化到项目以便后续使用 + baseDirToPersist = filepath.Clean(configDir) + } else { + return "", "", false, fmt.Errorf("global base dir is not configured") + } + + if !filepath.IsAbs(baseDir) { + return "", "", false, fmt.Errorf("global base dir must be an absolute path") + } + // 安全检查:全局基础目录不能是敏感系统目录 + if utils.IsSensitiveSystemDir(baseDir) { + return "", "", false, fmt.Errorf("global base dir cannot be a system directory") + } + globalMode = true + default: + if project.WorktreeBasePath != nil && strings.TrimSpace(*project.WorktreeBasePath) != "" { + baseDir = strings.TrimSpace(*project.WorktreeBasePath) + } else { + baseDir = filepath.Join(project.Path, ".worktrees") + } + + if !filepath.IsAbs(baseDir) { + baseDir = filepath.Join(project.Path, baseDir) + } + + // 安全检查:确保 baseDir 不会通过 ".." 逃逸出项目目录 + absBase := filepath.Clean(baseDir) + absProject := filepath.Clean(project.Path) + rel, relErr := filepath.Rel(absProject, absBase) + if relErr == nil && strings.HasPrefix(rel, "..") { + // baseDir 逃逸出项目目录 - 仅当是绝对路径时允许 + // 对于包含 ".." 的相对路径,拒绝作为安全风险 + if project.WorktreeBasePath != nil && !filepath.IsAbs(*project.WorktreeBasePath) { + return "", "", false, fmt.Errorf("worktree base path escapes project directory") + } + } + + globalMode = isGlobalWorktreeBaseDir(project.Path, baseDir) + baseDirToPersist = "" } - if !filepath.IsAbs(basePath) { - basePath = filepath.Join(project.Path, basePath) + + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return "", "", false, err } - if err := os.MkdirAll(basePath, 0o755); err != nil { - return "", err + + dirName := "" + if globalMode { + dirName, err = expandWorktreeDirNamePattern(pattern, project, branchName) + if err != nil { + return "", "", false, err + } + } else { + dirName = sanitizeBranchName(branchName) } - dirName := sanitizeBranchName(branchName) - return filepath.Join(basePath, dirName), nil + // 最终安全校验:确保解析后的路径在 baseDir 内 + finalPath := filepath.Join(baseDir, dirName) + cleanFinal := filepath.Clean(finalPath) + cleanBase := filepath.Clean(baseDir) + if !strings.HasPrefix(cleanFinal, cleanBase+string(filepath.Separator)) && cleanFinal != cleanBase { + return "", "", false, fmt.Errorf("worktree path escapes base directory") + } + + return finalPath, baseDirToPersist, persistRequested, nil } +// sanitizeBranchName 将分支名称转换为安全的目录名称。 +// 替换路径分隔符和特殊字符,防止路径遍历攻击。 func sanitizeBranchName(branch string) string { + clean := strings.TrimSpace(branch) + // 拒绝可能导致路径遍历的危险目录名 + if clean == "" || clean == "." || clean == ".." { + return "_invalid_branch_" + } + replacer := strings.NewReplacer( "/", "__", "\\", "__", @@ -574,5 +704,75 @@ func sanitizeBranchName(branch string) string { ">", "_", "|", "_", ) - return replacer.Replace(strings.TrimSpace(branch)) + result := replacer.Replace(clean) + + // 二次校验:如果结果仍包含 ".." 则拒绝 + if strings.Contains(result, "..") { + return "_invalid_branch_" + } + return result +} + +// isGlobalWorktreeBaseDir 判断 worktree 基础目录是否在项目目录外(即全局模式)。 +func isGlobalWorktreeBaseDir(projectPath, baseDir string) bool { + projectAbs, err := filepath.Abs(projectPath) + if err != nil { + return false + } + baseAbs, err := filepath.Abs(baseDir) + if err != nil { + return false + } + rel, err := filepath.Rel(projectAbs, baseAbs) + if err != nil { + return false + } + if rel == "." { + return false + } + return strings.HasPrefix(rel, "..") +} + +// sanitizePathSegment 清理路径片段中的特殊字符。 +func sanitizePathSegment(input string) string { + trimmed := strings.TrimSpace(input) + replacer := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "<", "_", + ">", "_", + "|", "_", + ) + return replacer.Replace(trimmed) +} + +// expandWorktreeDirNamePattern 展开 worktree 目录名模式。 +// 支持的变量:{projectName}、{projectId}、{branch} +func expandWorktreeDirNamePattern(pattern string, project *model.Project, branchName string) (string, error) { + rawProjectName := "" + if project != nil { + rawProjectName = project.Name + } + + // 使用固定顺序替换以避免非确定性行为 + expanded := pattern + expanded = strings.ReplaceAll(expanded, "{projectName}", sanitizePathSegment(rawProjectName)) + expanded = strings.ReplaceAll(expanded, "{projectId}", sanitizePathSegment(project.Id)) + expanded = strings.ReplaceAll(expanded, "{branch}", sanitizeBranchName(branchName)) + + expanded = strings.TrimSpace(expanded) + if expanded == "" { + return "", fmt.Errorf("worktree dir name is empty after pattern expansion") + } + if strings.Contains(expanded, "..") { + return "", fmt.Errorf("invalid worktree dir name: %s", expanded) + } + if strings.ContainsAny(expanded, "/\\") { + return "", fmt.Errorf("invalid worktree dir name: %s", expanded) + } + + return expanded, nil } diff --git a/service/worktree_service_test.go b/service/worktree_service_test.go index 91b87bb..8634bd3 100644 --- a/service/worktree_service_test.go +++ b/service/worktree_service_test.go @@ -33,7 +33,10 @@ func TestWorktreeServiceCreateAndRefresh(t *testing.T) { svc.AsyncRefresh(false) ctx := context.Background() - worktree, err := svc.CreateWorktree(ctx, project.Id, "feature/testing", "main", true) + worktree, err := svc.CreateWorktree(ctx, project.Id, "feature/testing", CreateWorktreeOptions{ + BaseBranch: "main", + CreateBranch: true, + }) if err != nil { t.Fatalf("CreateWorktree returned error: %v", err) } @@ -87,7 +90,10 @@ func TestWorktreeServiceDeleteAndSync(t *testing.T) { svc.AsyncRefresh(false) ctx := context.Background() - worktree, err := svc.CreateWorktree(ctx, project.Id, "feature/delete", "main", true) + worktree, err := svc.CreateWorktree(ctx, project.Id, "feature/delete", CreateWorktreeOptions{ + BaseBranch: "main", + CreateBranch: true, + }) if err != nil { t.Fatalf("CreateWorktree returned error: %v", err) } @@ -140,7 +146,10 @@ func TestWorktreeServiceRefreshAll(t *testing.T) { svc.AsyncRefresh(false) ctx := context.Background() - if _, err := svc.CreateWorktree(ctx, project.Id, "feature/all", "main", true); err != nil { + if _, err := svc.CreateWorktree(ctx, project.Id, "feature/all", CreateWorktreeOptions{ + BaseBranch: "main", + CreateBranch: true, + }); err != nil { t.Fatalf("CreateWorktree returned error: %v", err) } @@ -180,7 +189,10 @@ func TestWorktreeServiceCommit(t *testing.T) { svc.AsyncRefresh(false) ctx := context.Background() - worktree, err := svc.CreateWorktree(ctx, project.Id, "feature/commit", "main", true) + worktree, err := svc.CreateWorktree(ctx, project.Id, "feature/commit", CreateWorktreeOptions{ + BaseBranch: "main", + CreateBranch: true, + }) if err != nil { t.Fatalf("CreateWorktree returned error: %v", err) } @@ -203,6 +215,65 @@ func TestWorktreeServiceCommit(t *testing.T) { } } +func TestWorktreeServiceCreateWorktree_PersistWorktreeBasePath(t *testing.T) { + cleanup := initTestDB(t) + defer cleanup() + + repoPath := createProjectTestRepo(t) + projectService := &model.ProjectService{} + project, err := projectService.CreateProject(context.Background(), model.CreateProjectParams{ + Name: "Persist Project", + Path: repoPath, + }) + if err != nil { + t.Fatalf("create project failed: %v", err) + } + + q, err := model.ResolveQueries(nil) + if err != nil { + t.Fatalf("resolve queries failed: %v", err) + } + + svc := NewWorktreeService() + svc.AsyncRefresh(false) + ctx := context.Background() + + globalBaseDir := t.TempDir() + if _, err := svc.CreateWorktree(ctx, project.Id, "feature/global", CreateWorktreeOptions{ + BaseBranch: "main", + CreateBranch: true, + Location: "global", + GlobalBaseDir: globalBaseDir, + GlobalDirNamePattern: "{projectName}-{branch}", + }); err != nil { + t.Fatalf("CreateWorktree(global) returned error: %v", err) + } + + updated, err := q.ProjectGetByID(ctx, project.Id) + if err != nil { + t.Fatalf("reload project failed: %v", err) + } + if updated.WorktreeBasePath == nil || filepath.Clean(*updated.WorktreeBasePath) != filepath.Clean(globalBaseDir) { + t.Fatalf("expected worktreeBasePath to be persisted to %s, got %v", filepath.Clean(globalBaseDir), updated.WorktreeBasePath) + } + + if _, err := svc.CreateWorktree(ctx, project.Id, "feature/project", CreateWorktreeOptions{ + BaseBranch: "main", + CreateBranch: true, + Location: "project", + }); err != nil { + t.Fatalf("CreateWorktree(project) returned error: %v", err) + } + + cleared, err := q.ProjectGetByID(ctx, project.Id) + if err != nil { + t.Fatalf("reload project failed: %v", err) + } + if cleared.WorktreeBasePath != nil && strings.TrimSpace(*cleared.WorktreeBasePath) != "" { + t.Fatalf("expected worktreeBasePath to be cleared, got %v", cleared.WorktreeBasePath) + } +} + func initTestDB(t *testing.T) func() { t.Helper() dsn := "file:" + t.Name() + "?mode=memory&cache=shared" @@ -258,3 +329,68 @@ func runGitCommand(t *testing.T, dir string, args ...string) { t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output) } } + +// TestSanitizeBranchName_Security tests that sanitizeBranchName correctly handles +// potentially dangerous branch names that could lead to path traversal. +func TestSanitizeBranchName_Security(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty", "", "_invalid_branch_"}, + {"dot", ".", "_invalid_branch_"}, + {"dotdot", "..", "_invalid_branch_"}, + {"normal branch", "feature/test", "feature__test"}, + {"with backslash", "feature\\test", "feature__test"}, + {"dotdot in name", "feature..test", "_invalid_branch_"}, + {"trailing dots", "feature..", "_invalid_branch_"}, + {"leading dots", "..feature", "_invalid_branch_"}, + {"triple dot", "...", "_invalid_branch_"}, + {"valid dotfile", ".gitignore", ".gitignore"}, + {"spaces only", " ", "_invalid_branch_"}, + {"mixed special chars", "feat:test*?<>|", "feat_test_____"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeBranchName(tt.input) + if result != tt.expected { + t.Errorf("sanitizeBranchName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestExpandWorktreeDirNamePattern_Security tests that pattern expansion +// correctly rejects potentially dangerous patterns. +func TestExpandWorktreeDirNamePattern_Security(t *testing.T) { + project := &model.Project{ + Id: "test-id", + Name: "Test Project", + } + + tests := []struct { + name string + pattern string + branchName string + shouldError bool + }{ + {"normal pattern", "{projectName}-{branch}", "feature/test", false}, + {"dotdot branch", "{projectName}-{branch}", "..", false}, // sanitizeBranchName will handle this + {"path separator in result", "{projectName}/{branch}", "test", true}, + {"backslash in result", "{projectName}\\{branch}", "test", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := expandWorktreeDirNamePattern(tt.pattern, project, tt.branchName) + if tt.shouldError && err == nil { + t.Errorf("expected error for pattern %q with branch %q, got nil", tt.pattern, tt.branchName) + } + if !tt.shouldError && err != nil { + t.Errorf("unexpected error for pattern %q with branch %q: %v", tt.pattern, tt.branchName, err) + } + }) + } +} diff --git a/static/index.html b/static/index.html index a2ca349..74140d2 100644 --- a/static/index.html +++ b/static/index.html @@ -7,8 +7,8 @@ Code Kanban - - + + diff --git a/ui/components.d.ts b/ui/components.d.ts index 9146d09..62b6ae2 100644 --- a/ui/components.d.ts +++ b/ui/components.d.ts @@ -32,6 +32,7 @@ declare module 'vue' { KanbanColumn: typeof import('./src/components/kanban/KanbanColumn.vue')['default'] LanguageSwitcher: typeof import('./src/components/common/LanguageSwitcher.vue')['default'] NAlert: typeof import('naive-ui')['NAlert'] + NAvatar: typeof import('naive-ui')['NAvatar'] NBadge: typeof import('naive-ui')['NBadge'] NBreadcrumb: typeof import('naive-ui')['NBreadcrumb'] NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem'] @@ -39,6 +40,10 @@ declare module 'vue' { NButtonGroup: typeof import('naive-ui')['NButtonGroup'] NCard: typeof import('naive-ui')['NCard'] NCheckbox: typeof import('naive-ui')['NCheckbox'] + NCollapse: typeof import('naive-ui')['NCollapse'] + NCollapseItem: typeof import('naive-ui')['NCollapseItem'] + NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] + NColorPicker: typeof import('naive-ui')['NColorPicker'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NDatePicker: typeof import('naive-ui')['NDatePicker'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] @@ -46,18 +51,24 @@ declare module 'vue' { NDrawer: typeof import('naive-ui')['NDrawer'] NDrawerContent: typeof import('naive-ui')['NDrawerContent'] NDropdown: typeof import('naive-ui')['NDropdown'] + NDynamicInput: typeof import('naive-ui')['NDynamicInput'] NDynamicTags: typeof import('naive-ui')['NDynamicTags'] NEllipsis: typeof import('naive-ui')['NEllipsis'] NEmpty: typeof import('naive-ui')['NEmpty'] NForm: typeof import('naive-ui')['NForm'] NFormItem: typeof import('naive-ui')['NFormItem'] + NGi: typeof import('naive-ui')['NGi'] NGlobalStyle: typeof import('naive-ui')['NGlobalStyle'] + NGrid: typeof import('naive-ui')['NGrid'] + NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] NInput: typeof import('naive-ui')['NInput'] NInputGroup: typeof import('naive-ui')['NInputGroup'] + NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutContent: typeof import('naive-ui')['NLayoutContent'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] + NLi: typeof import('naive-ui')['NLi'] NList: typeof import('naive-ui')['NList'] NListItem: typeof import('naive-ui')['NListItem'] NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider'] @@ -65,21 +76,29 @@ declare module 'vue' { NModal: typeof import('naive-ui')['NModal'] NModalProvider: typeof import('naive-ui')['NModalProvider'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] + NOl: typeof import('naive-ui')['NOl'] NotePad: typeof import('./src/components/notepad/NotePad.vue')['default'] NPageHeader: typeof import('naive-ui')['NPageHeader'] NPopover: typeof import('naive-ui')['NPopover'] NRadio: typeof import('naive-ui')['NRadio'] + NRadioButton: typeof import('naive-ui')['NRadioButton'] NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NScrollbar: typeof import('naive-ui')['NScrollbar'] NSelect: typeof import('naive-ui')['NSelect'] + NSlider: typeof import('naive-ui')['NSlider'] NSpace: typeof import('naive-ui')['NSpace'] NSpin: typeof import('naive-ui')['NSpin'] + NStatistic: typeof import('naive-ui')['NStatistic'] + NStep: typeof import('naive-ui')['NStep'] + NSteps: typeof import('naive-ui')['NSteps'] NSwitch: typeof import('naive-ui')['NSwitch'] NTabPane: typeof import('naive-ui')['NTabPane'] NTabs: typeof import('naive-ui')['NTabs'] NTag: typeof import('naive-ui')['NTag'] NText: typeof import('naive-ui')['NText'] NTooltip: typeof import('naive-ui')['NTooltip'] + NUl: typeof import('naive-ui')['NUl'] + NVirtualList: typeof import('naive-ui')['NVirtualList'] ProjectCreateDialog: typeof import('./src/components/project/ProjectCreateDialog.vue')['default'] ProjectEditDialog: typeof import('./src/components/project/ProjectEditDialog.vue')['default'] RecentProjects: typeof import('./src/components/project/RecentProjects.vue')['default'] diff --git a/ui/src/api/project.ts b/ui/src/api/project.ts index 32a29b2..5639f20 100644 --- a/ui/src/api/project.ts +++ b/ui/src/api/project.ts @@ -80,12 +80,20 @@ export const worktreeApi = { async create( projectId: string, - data: { branchName: string; baseBranch?: string; createBranch?: boolean }, + data: { + branchName: string; + baseBranch?: string; + createBranch?: boolean; + location?: 'project' | 'global'; + globalBaseDirOverride?: string; + }, ): Promise { const payload = { branchName: data.branchName, baseBranch: data.baseBranch ?? '', createBranch: data.createBranch ?? true, + location: data.location, + globalBaseDirOverride: data.globalBaseDirOverride, }; const body = (await http.Post>( diff --git a/ui/src/components/worktree/WorktreeCreateDialog.vue b/ui/src/components/worktree/WorktreeCreateDialog.vue index 5e0a019..2c52614 100644 --- a/ui/src/components/worktree/WorktreeCreateDialog.vue +++ b/ui/src/components/worktree/WorktreeCreateDialog.vue @@ -9,6 +9,25 @@ @positive-click="handleCreate" > + + + + {{ t('worktree.locationProject') }} + {{ t('worktree.locationGlobal') }} + + + + + + + @@ -49,6 +68,8 @@ const visible = computed({ const formRef = ref(null); const loading = ref(false); const formData = ref({ + location: 'project' as 'project' | 'global', + globalBaseDirOverride: '', branchName: '', baseBranch: '', createBranch: true, @@ -58,12 +79,62 @@ const rules: FormRules = { branchName: [{ required: true, message: t('validation.branchNameRequired'), trigger: ['blur', 'input'] }], }; +/** + * 判断路径是否看起来像绝对路径(跨平台) + */ +function looksLikeAbsPath(path: string) { + const trimmed = path.trim(); + // Unix 风格:以 / 开头 + if (trimmed.startsWith('/')) { + return true; + } + // Windows 风格:盘符 + 冒号 + 斜杠(如 C:\ 或 C:/) + return /^[a-zA-Z]:[\\/]/.test(trimmed); +} + +/** + * 规范化路径:统一使用正斜杠,移除多余斜杠和尾部斜杠 + */ +function normalizePath(path: string) { + return path.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); +} + +/** + * 判断 worktree 基础路径是否为全局路径(不在项目目录内) + */ +function isGlobalWorktreeBasePath(projectPath: string, worktreeBasePath: string) { + if (!looksLikeAbsPath(worktreeBasePath)) { + return false; + } + const projectNorm = normalizePath(projectPath); + const baseNorm = normalizePath(worktreeBasePath); + return !baseNorm.startsWith(projectNorm + '/'); +} + watch(visible, newVal => { if (newVal) { + // 根据项目的 worktree 基础路径判断默认位置 + const current = projectStore.currentProject; + if (current?.path && current.worktreeBasePath) { + formData.value.location = isGlobalWorktreeBasePath(current.path, current.worktreeBasePath) + ? 'global' + : 'project'; + } else { + formData.value.location = 'project'; + } + + // 使用项目默认分支作为基础分支 formData.value.baseBranch = projectStore.currentProject?.defaultBranch ?? formData.value.baseBranch ?? 'main'; } else { - formData.value = { branchName: '', baseBranch: '', createBranch: true }; + // 对话框关闭时重置表单 + formData.value = { + location: 'project', + globalBaseDirOverride: '', + branchName: '', + baseBranch: '', + createBranch: true, + }; } }); diff --git a/ui/src/i18n/locales/en-US.ts b/ui/src/i18n/locales/en-US.ts index 556675a..7d686c0 100644 --- a/ui/src/i18n/locales/en-US.ts +++ b/ui/src/i18n/locales/en-US.ts @@ -277,6 +277,11 @@ export default { title: 'Worktrees', new: 'New', create: 'Create Worktree', + createLocation: 'Create Location (saved as project default)', + locationProject: 'Project subdirectory (.worktrees)', + locationGlobal: 'Global directory', + globalBaseDirOverride: 'Global base dir (optional, one-time)', + globalBaseDirOverridePlaceholder: 'Leave empty to use the global settings', branches: 'Branches', refresh: 'Refresh', refreshStatus: 'Refresh Status', @@ -560,6 +565,13 @@ export default { resetTheme: 'Reset Default Theme', themeSettings: 'Theme Settings', projectAndTerminal: 'Project & Terminal', + worktreeSettings: 'Worktree Settings', + worktreeGlobalBaseDir: 'Global Worktree Directory', + worktreeGlobalBaseDirPlaceholder: 'e.g. D:/worktrees or /Users/you/worktrees (empty to disable)', + worktreeGlobalBaseDirTip: 'Used to create Worktrees in a centralized place. Must be an absolute path. Empty means not configured.', + worktreeGlobalDirNamePattern: 'Global directory naming pattern', + worktreeGlobalDirNamePatternTip: + 'Placeholders: {projectName}, {projectId}, {branch}. Default: {projectName}-{branch}. Recommend including {projectId} to avoid collisions.', recentProjectsLimit: 'Recent Projects Limit', recentProjectsLimitTip: 'Control the number of recent projects displayed', terminalLimit: 'Terminal Limit Per Project', @@ -708,6 +720,7 @@ export default { projectPathRequired: 'Please enter project directory', branchNameRequired: 'Please enter branch name', taskTitleRequired: 'Please enter task title', + mustBeAbsolutePath: 'Must be an absolute path (e.g., /path/to/dir or C:\\path\\to\\dir)', }, update: { newVersionAvailable: 'New Version Available', diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index aac8e73..5949291 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -277,6 +277,11 @@ export default { title: 'Worktrees', new: '新建', create: '创建 Worktree', + createLocation: '创建位置(会保存为项目默认)', + locationProject: '项目子目录(.worktrees)', + locationGlobal: '全局目录', + globalBaseDirOverride: '全局目录(可选,仅本次)', + globalBaseDirOverridePlaceholder: '留空使用“总设置”中的全局目录配置', branches: '分支', refresh: '刷新', refreshStatus: '刷新状态', @@ -560,6 +565,13 @@ export default { resetTheme: '恢复默认主题', themeSettings: '主题设置', projectAndTerminal: '项目与终端', + worktreeSettings: 'Worktree 设置', + worktreeGlobalBaseDir: '全局 Worktree 目录', + worktreeGlobalBaseDirPlaceholder: '例如:D:/worktrees 或 /Users/you/worktrees(留空禁用)', + worktreeGlobalBaseDirTip: '用于集中创建 Worktree;需为绝对路径。留空表示不配置全局目录。', + worktreeGlobalDirNamePattern: '全局目录命名规则', + worktreeGlobalDirNamePatternTip: + '支持占位符:{projectName}、{projectId}、{branch}。默认:{projectName}-{branch};建议包含 {projectId} 以避免冲突。', recentProjectsLimit: '最近项目数量', recentProjectsLimitTip: '控制"最近项目"列表展示的数量', terminalLimit: '单项目终端上限', @@ -708,6 +720,7 @@ export default { projectPathRequired: '请输入项目目录', branchNameRequired: '请输入分支名称', taskTitleRequired: '请输入任务标题', + mustBeAbsolutePath: '必须是绝对路径(如:/path/to/dir 或 C:\\path\\to\\dir)', }, update: { newVersionAvailable: '发现新版本', diff --git a/ui/src/stores/project.ts b/ui/src/stores/project.ts index 7302a7e..99f5ec3 100644 --- a/ui/src/stores/project.ts +++ b/ui/src/stores/project.ts @@ -156,11 +156,18 @@ export const useProjectStore = defineStore('project', () => { async function createWorktree( projectId: string, - payload: { branchName: string; baseBranch?: string; createBranch?: boolean }, + payload: { + branchName: string; + baseBranch?: string; + createBranch?: boolean; + location?: 'project' | 'global'; + globalBaseDirOverride?: string; + }, ) { const worktree = await worktreeApi.create(projectId, payload); // 创建成功后立即刷新列表,确保 UI 能及时更新 await fetchWorktrees(projectId); + await fetchProject(projectId); return worktree; } diff --git a/ui/src/types/models.ts b/ui/src/types/models.ts index b5a8789..40d5061 100644 --- a/ui/src/types/models.ts +++ b/ui/src/types/models.ts @@ -210,6 +210,11 @@ export interface DeveloperConfig { autoCreateTaskOnStartWork: boolean; } +export interface WorktreeConfig { + globalBaseDir: string; + globalDirNamePattern: string; +} + export interface ShellOption { id: string; name: string; diff --git a/ui/src/views/GeneralSettings.vue b/ui/src/views/GeneralSettings.vue index 6a6a2f2..0ea0c20 100644 --- a/ui/src/views/GeneralSettings.vue +++ b/ui/src/views/GeneralSettings.vue @@ -321,6 +321,45 @@ + + + + + + + + {{ t('settings.worktreeGlobalBaseDirTip') }} + + + + + + {{ t('settings.worktreeGlobalDirNamePatternTip') }} + + + + + + @@ -608,7 +647,7 @@ import { lightenColor, darkenColor, ensureHexWithHash, isDarkHex, getReadableTex import Apis from '@/api'; import { http } from '@/api/http'; import { useReq, useInit } from '@/api/composable'; -import type { AIAssistantStatusConfig, DeveloperConfig, AvailableShellsResponse } from '@/types/models'; +import type { AIAssistantStatusConfig, DeveloperConfig, AvailableShellsResponse, WorktreeConfig } from '@/types/models'; type ShortcutTarget = 'terminal' | 'notepad'; @@ -778,6 +817,98 @@ async function handleSaveDeveloperConfig() { } } +// Worktree 全局设置 +const worktreeSettingsForm = reactive({ + globalBaseDir: '', + globalDirNamePattern: '{projectName}-{branch}', +}); +const worktreeSettingsOriginal = ref(null); +const globalBaseDirError = ref(''); + +/** + * 判断路径是否看起来像绝对路径(跨平台) + */ +function looksLikeAbsPath(path: string) { + const trimmed = path.trim(); + // Unix 风格:以 / 开头 + if (trimmed.startsWith('/')) { + return true; + } + // Windows 风格:盘符 + 冒号 + 斜杠 + return /^[a-zA-Z]:[\\/]/.test(trimmed); +} + +/** + * 验证全局基础目录路径 + */ +function validateGlobalBaseDir() { + const val = worktreeSettingsForm.globalBaseDir.trim(); + if (val === '') { + globalBaseDirError.value = ''; + return true; + } + if (!looksLikeAbsPath(val)) { + globalBaseDirError.value = t('validation.mustBeAbsolutePath'); + return false; + } + globalBaseDirError.value = ''; + return true; +} + +// 检测表单是否有改动 +const worktreeSettingsDirty = computed(() => { + if (!worktreeSettingsOriginal.value) { + return false; + } + return ( + worktreeSettingsForm.globalBaseDir !== worktreeSettingsOriginal.value.globalBaseDir || + worktreeSettingsForm.globalDirNamePattern !== worktreeSettingsOriginal.value.globalDirNamePattern + ); +}); + +const { send: fetchWorktreeSettings, loading: worktreeSettingsLoading } = useReq( + () => http.Get>('/system/worktree-settings') +); + +const { send: updateWorktreeSettings, loading: worktreeSettingsSaving } = useReq( + (config: WorktreeConfig) => http.Post>('/system/worktree-settings/update', config) +); + +/** + * 加载 Worktree 全局设置 + */ +async function loadWorktreeSettings() { + try { + const resp = await fetchWorktreeSettings(); + const config = resp?.item; + if (config) { + worktreeSettingsForm.globalBaseDir = config.globalBaseDir ?? ''; + worktreeSettingsForm.globalDirNamePattern = + config.globalDirNamePattern ?? worktreeSettingsForm.globalDirNamePattern; + worktreeSettingsOriginal.value = { ...worktreeSettingsForm }; + } else { + worktreeSettingsOriginal.value = { ...worktreeSettingsForm }; + } + } catch (error) { + console.error('Failed to load worktree settings:', error); + worktreeSettingsOriginal.value = { ...worktreeSettingsForm }; + } +} + +/** + * 保存 Worktree 全局设置 + */ +async function handleSaveWorktreeSettings() { + try { + await updateWorktreeSettings({ ...worktreeSettingsForm }); + worktreeSettingsOriginal.value = { ...worktreeSettingsForm }; + message.success(t('common.saveSuccess')); + } catch (error) { + console.error('Failed to save worktree settings:', error); + message.error(t('common.saveFailed')); + } +} + // Terminal Shell Settings const shellsData = ref(null); const selectedShellId = ref(SHELL_AUTO_VALUE); @@ -823,6 +954,7 @@ async function loadShellsConfig() { useInit(() => { loadAIStatus(); loadDeveloperConfig(); + loadWorktreeSettings(); loadShellsConfig(); }); diff --git a/utils/app_config.go b/utils/app_config.go index 2ca4b43..50eb26c 100644 --- a/utils/app_config.go +++ b/utils/app_config.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "os" + "sync" "time" "github.com/knadh/koanf/parsers/yaml" @@ -33,6 +34,12 @@ type DeveloperConfig struct { AutoCreateTaskOnStartWork bool `json:"autoCreateTaskOnStartWork" yaml:"autoCreateTaskOnStartWork"` } +// WorktreeConfig Worktree 全局配置。 +type WorktreeConfig struct { + GlobalBaseDir string `json:"globalBaseDir" yaml:"globalBaseDir"` // 全局 Worktree 基础目录 + GlobalDirNamePattern string `json:"globalDirNamePattern" yaml:"globalDirNamePattern"` // 全局目录命名模式(支持 {projectName}、{branch}) +} + type AIAssistantStatusConfig struct { ClaudeCode bool `json:"claudeCode" yaml:"claudeCode"` // 状态监测准确,默认启用 Codex bool `json:"codex" yaml:"codex"` // 默认启用 @@ -118,11 +125,15 @@ type AppConfig struct { DisableAutoOpenBrowser bool `json:"disableAutoOpenBrowser" yaml:"disableAutoOpenBrowser"` Terminal TerminalConfig `json:"terminal" yaml:"terminal"` Developer DeveloperConfig `json:"developer" yaml:"developer"` + Worktree WorktreeConfig `json:"worktree" yaml:"worktree"` } var configStore = koanf.New(".") -// activeConfigPath stores the path of the config file that was actually loaded +// configMu 保护对 configStore 和 activeConfigPath 的并发访问 +var configMu sync.RWMutex + +// activeConfigPath 存储实际加载的配置文件路径 var activeConfigPath string // ReadConfig 会加载 config.yaml,若不存在则写入默认配置。 @@ -197,18 +208,24 @@ func ReadConfig() *AppConfig { RenameSessionTitleEachCommand: false, AutoCreateTaskOnStartWork: true, }, + Worktree: WorktreeConfig{ + GlobalBaseDir: "", + GlobalDirNamePattern: "{projectName}-{branch}", + }, } lo.Must0(configStore.Load(structs.Provider(&defaults, "yaml"), nil)) - // Store the active config path for later use by WriteConfig + // 存储活动配置路径以供后续 WriteConfig 使用 activeConfigPath = configPath provider := file.Provider(configPath) if err := configStore.Load(provider, yaml.Parser()); err != nil { fmt.Printf("Failed to read config: %v\n", err) if os.IsNotExist(err) { - WriteConfigToPath(&defaults, configPath) + if writeErr := WriteConfigToPath(&defaults, configPath); writeErr != nil { + fmt.Printf("Failed to write default config: %v\n", writeErr) + } } else { os.Exit(1) } @@ -220,7 +237,7 @@ func ReadConfig() *AppConfig { os.Exit(1) } - // Normalize derived values to avoid redundant calculations. + // 规范化派生值,避免重复计算 _ = config.Terminal.IdleDuration() if config.PrintConfig { @@ -231,30 +248,59 @@ func ReadConfig() *AppConfig { } // WriteConfig 会将当前配置写回磁盘,写入的是启动时实际加载的配置文件路径。 -func WriteConfig(config *AppConfig) { - // Use the config path that was actually loaded during ReadConfig - // This ensures we write back to the same file we read from +// Deprecated: 推荐使用 UpdateConfig 进行原子更新,避免并发修改问题。 +func WriteConfig(config *AppConfig) error { + configMu.Lock() + defer configMu.Unlock() + + // 使用 ReadConfig 时实际加载的配置路径 + // 确保写入与读取的是同一个文件 if activeConfigPath == "" { - // Fallback to data directory if ReadConfig hasn't been called yet + // 如果 ReadConfig 尚未调用,回退到数据目录 dataDir := GetDataDir() activeConfigPath = fmt.Sprintf("%s/config.yaml", dataDir) } - WriteConfigToPath(config, activeConfigPath) + return writeConfigToPathLocked(config, activeConfigPath) +} + +// UpdateConfig 提供原子更新配置的能力,在锁内完成"修改+写盘"操作。 +// modifier 函数接收当前配置指针,可直接修改其字段。 +// 修改完成后自动持久化到磁盘。 +func UpdateConfig(config *AppConfig, modifier func(*AppConfig)) error { + configMu.Lock() + defer configMu.Unlock() + + // 在锁内应用修改 + modifier(config) + + // 持久化到磁盘 + if activeConfigPath == "" { + dataDir := GetDataDir() + activeConfigPath = fmt.Sprintf("%s/config.yaml", dataDir) + } + return writeConfigToPathLocked(config, activeConfigPath) +} + +// WriteConfigToPath 将配置写入指定路径 +func WriteConfigToPath(config *AppConfig, path string) error { + configMu.Lock() + defer configMu.Unlock() + return writeConfigToPathLocked(config, path) } -// WriteConfigToPath writes configuration to specified path -func WriteConfigToPath(config *AppConfig, path string) { +// writeConfigToPathLocked 不获取锁直接写入配置(调用者必须持有锁) +func writeConfigToPathLocked(config *AppConfig, path string) error { if config != nil { lo.Must0(configStore.Load(structs.Provider(config, "yaml"), nil)) } content, err := yaml.Parser().Marshal(configStore.Raw()) if err != nil { - fmt.Println("Failed to write config: serialization error") - return + return fmt.Errorf("failed to serialize config: %w", err) } if err := os.WriteFile(path, content, 0o644); err != nil { - fmt.Printf("Failed to write config: cannot write file %s\n", path) + return fmt.Errorf("failed to write config file %s: %w", path, err) } + return nil } diff --git a/utils/path.go b/utils/path.go new file mode 100644 index 0000000..7a80526 --- /dev/null +++ b/utils/path.go @@ -0,0 +1,74 @@ +package utils + +import ( + "path/filepath" + "runtime" + "strings" +) + +// SensitiveSystemDirs 包含不应用作 worktree 基础目录的敏感系统目录。 +// 这些是 Unix 和 Windows 系统上的关键路径。 +var SensitiveSystemDirs = []string{ + // Unix 系统目录 + "/etc", "/bin", "/sbin", "/usr", "/var", "/boot", "/root", "/lib", "/lib64", + "/proc", "/sys", "/dev", "/run", "/snap", + // Windows 系统目录 + "C:\\Windows", "C:\\Program Files", "C:\\Program Files (x86)", + "C:\\ProgramData", "C:\\System Volume Information", +} + +// IsSensitiveSystemDir 检查给定路径是否为敏感系统目录或位于敏感系统目录下。 +// 出于安全原因,返回 true 表示应拒绝该路径。 +func IsSensitiveSystemDir(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + + cleanPath := filepath.Clean(path) + + // 检查根目录 + if cleanPath == "/" || cleanPath == "\\" { + return true + } + + // 在 Windows 上检查驱动器根目录(如 "C:\") + if runtime.GOOS == "windows" && len(cleanPath) == 3 && cleanPath[1] == ':' && (cleanPath[2] == '\\' || cleanPath[2] == '/') { + return true + } + + // 检查敏感目录列表 + for _, sensitive := range SensitiveSystemDirs { + // 精确匹配(Windows 上不区分大小写) + if pathEquals(cleanPath, sensitive) { + return true + } + + // 检查路径是否在敏感目录下 + if isSubPath(cleanPath, sensitive) { + return true + } + } + + return false +} + +// pathEquals 比较两个路径是否相等,Windows 上不区分大小写。 +func pathEquals(path1, path2 string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(path1, path2) + } + return path1 == path2 +} + +// isSubPath 检查 path 是否在 basePath 目录下。 +func isSubPath(path, basePath string) bool { + sep := string(filepath.Separator) + + if runtime.GOOS == "windows" { + pathLower := strings.ToLower(path) + baseLower := strings.ToLower(basePath) + return strings.HasPrefix(pathLower, baseLower+sep) + } + + return strings.HasPrefix(path, basePath+sep) +}