You've already forked agentic-coding-workflow
Compare commits
9 Commits
6eca9c053b
...
cdb22d4ddb
| Author | SHA1 | Date | |
|---|---|---|---|
| cdb22d4ddb | |||
| 8d16fabaed | |||
| 493775cd1e | |||
| 112b99035c | |||
| 12ba5d5365 | |||
| 665d029d2b | |||
| a4a6e6a01a | |||
| 3a1909f8f4 | |||
| a49d45a846 |
@@ -68,6 +68,9 @@ func (s *stubWsService) UpsertCredential(context.Context, workspace.Caller, uuid
|
|||||||
func (s *stubWsService) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
|
func (s *stubWsService) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
|
||||||
panic("stubWsService.GetCredentialMeta unused")
|
panic("stubWsService.GetCredentialMeta unused")
|
||||||
}
|
}
|
||||||
|
func (s *stubWsService) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
|
||||||
|
panic("stubWsService.ListBranches unused")
|
||||||
|
}
|
||||||
|
|
||||||
// stubProjectAccess satisfies acp.ProjectAccess for the e2e test.
|
// stubProjectAccess satisfies acp.ProjectAccess for the e2e test.
|
||||||
// Only GetProjectByWorkspace is exercised; the rest panic.
|
// Only GetProjectByWorkspace is exercised; the rest panic.
|
||||||
|
|||||||
@@ -327,6 +327,9 @@ func (s *fakeWsSvc) UpsertCredential(context.Context, workspace.Caller, uuid.UUI
|
|||||||
func (s *fakeWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
|
func (s *fakeWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
|
||||||
return nil, fmt.Errorf("unused")
|
return nil, fmt.Errorf("unused")
|
||||||
}
|
}
|
||||||
|
func (s *fakeWsSvc) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Tests =====
|
// ===== Tests =====
|
||||||
|
|
||||||
|
|||||||
@@ -436,6 +436,10 @@ func (s *acpStubWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid
|
|||||||
return nil, fmt.Errorf("acpStubWsSvc.GetCredentialMeta unused")
|
return nil, fmt.Errorf("acpStubWsSvc.GetCredentialMeta unused")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *acpStubWsSvc) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
|
||||||
|
return nil, fmt.Errorf("acpStubWsSvc.ListBranches unused")
|
||||||
|
}
|
||||||
|
|
||||||
// acpStubProjectAccess satisfies acp.ProjectAccess; only GetProjectByWorkspace is
|
// acpStubProjectAccess satisfies acp.ProjectAccess; only GetProjectByWorkspace is
|
||||||
// exercised by the branch=main path.
|
// exercised by the branch=main path.
|
||||||
type acpStubProjectAccess struct {
|
type acpStubProjectAccess struct {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchRemoteBranch fetches a single remote branch (origin/<branch>).
|
||||||
|
// Lighter than Fetch --all when we only need one branch.
|
||||||
|
func (r *DefaultRunner) FetchRemoteBranch(ctx context.Context, dir, branch string, cred *Credential) error {
|
||||||
|
ctx, cancel := withCtx(ctx, r.cfg.FetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
_, _, err = r.exec(ctx, dir, env, "fetch", "origin", branch)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkout switches the working tree to the given branch.
|
||||||
|
func (r *DefaultRunner) Checkout(ctx context.Context, dir, branch string) error {
|
||||||
|
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := r.exec(ctx, dir, nil, "checkout", branch)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRemoteBranches queries the remote for all branch names via git ls-remote.
|
||||||
|
// Returns branch names with refs/heads/ prefix stripped.
|
||||||
|
func (r *DefaultRunner) ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error) {
|
||||||
|
ctx, cancel := withCtx(ctx, r.cfg.FetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
env, cleanup, err := credentialEnv(r.cfg.TmpDir, cred)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
out, _, err := r.exec(ctx, dir, env, "ls-remote", "--heads")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseRemoteBranches(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRemoteBranches parses git ls-remote --heads output.
|
||||||
|
// Each line: "<sha>\trefs/heads/<branch>"
|
||||||
|
func parseRemoteBranches(buf []byte) []string {
|
||||||
|
var branches []string
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(buf))
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if idx := strings.Index(line, "\trefs/heads/"); idx >= 0 {
|
||||||
|
name := line[idx+len("\trefs/heads/"):]
|
||||||
|
if name != "" {
|
||||||
|
branches = append(branches, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return branches
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRemoteBranches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := []byte("aabbccdd\trefs/heads/main\r\n" +
|
||||||
|
"eeff0011\trefs/heads/develop\n" +
|
||||||
|
"22334455\trefs/heads/feature/foo\n" +
|
||||||
|
"deadbeef\trefs/tags/v1.0\n")
|
||||||
|
got := parseRemoteBranches(input)
|
||||||
|
require.Equal(t, []string{"main", "develop", "feature/foo"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteBranches_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := parseRemoteBranches(nil)
|
||||||
|
require.Nil(t, got)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const stderrTruncateLimit = 2000
|
|||||||
type Runner interface {
|
type Runner interface {
|
||||||
Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error
|
Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error
|
||||||
Fetch(ctx context.Context, dir string, cred *Credential) error
|
Fetch(ctx context.Context, dir string, cred *Credential) error
|
||||||
|
FetchRemoteBranch(ctx context.Context, dir, branch string, cred *Credential) error
|
||||||
Push(ctx context.Context, dir, branch string, cred *Credential) error
|
Push(ctx context.Context, dir, branch string, cred *Credential) error
|
||||||
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error)
|
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error)
|
||||||
Status(ctx context.Context, dir string) ([]FileStatus, error)
|
Status(ctx context.Context, dir string) ([]FileStatus, error)
|
||||||
@@ -30,6 +31,8 @@ type Runner interface {
|
|||||||
WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error
|
WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error
|
||||||
WorktreeRemove(ctx context.Context, dir, path string) error
|
WorktreeRemove(ctx context.Context, dir, path string) error
|
||||||
WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
|
WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
|
||||||
|
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
|
||||||
|
Checkout(ctx context.Context, dir, branch string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileStatus 表示 `git status --porcelain=v1` 的一行。
|
// FileStatus 表示 `git status --porcelain=v1` 的一行。
|
||||||
|
|||||||
@@ -266,6 +266,9 @@ func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Calle
|
|||||||
func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Credential, error) {
|
func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Credential, error) {
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
|
func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
// ===== test helpers =====
|
// ===== test helpers =====
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ type WorkspaceService interface {
|
|||||||
|
|
||||||
UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error)
|
UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error)
|
||||||
GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, error)
|
GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, error)
|
||||||
|
ListBranches(ctx context.Context, c Caller, wsID uuid.UUID) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorktreeService 暴露支线 worktree 的应用服务方法。
|
// WorktreeService 暴露支线 worktree 的应用服务方法。
|
||||||
|
|||||||
@@ -115,3 +115,7 @@ func toFileStatusResp(f git.FileStatus) fileStatusResp {
|
|||||||
type commitResp struct {
|
type commitResp struct {
|
||||||
SHA string `json:"sha"`
|
SHA string `json:"sha"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type branchListResp struct {
|
||||||
|
Branches []string `json:"branches"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (h *Handler) Mount(r chi.Router) {
|
|||||||
|
|
||||||
r.Get("/worktrees", h.listWorktrees)
|
r.Get("/worktrees", h.listWorktrees)
|
||||||
r.Post("/worktrees", h.createWorktree)
|
r.Post("/worktrees", h.createWorktree)
|
||||||
|
r.Get("/branches", h.listBranches)
|
||||||
})
|
})
|
||||||
r.With(auth).Route("/api/v1/worktrees/{wtID}", func(r chi.Router) {
|
r.With(auth).Route("/api/v1/worktrees/{wtID}", func(r chi.Router) {
|
||||||
r.Delete("/", h.deleteWorktree)
|
r.Delete("/", h.deleteWorktree)
|
||||||
@@ -340,6 +341,25 @@ func (h *Handler) createWorktree(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, toWorktreeResp(wt))
|
writeJSON(w, http.StatusCreated, toWorktreeResp(wt))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listBranches(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "wsID"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
branches, err := h.ws.ListBranches(r.Context(), c, id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, branchListResp{Branches: branches})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) deleteWorktree(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) deleteWorktree(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := h.caller(r)
|
c, err := h.caller(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ func (f *fakeWS) GetCredentialMeta(_ context.Context, _ Caller, _ uuid.UUID) (*C
|
|||||||
return &Credential{Kind: CredKindNone}, nil
|
return &Credential{Kind: CredKindNone}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeWS) ListBranches(_ context.Context, _ Caller, _ uuid.UUID) ([]string, error) {
|
||||||
|
return []string{"main", "develop"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeResolver 把固定 token 解码为 uuid(沿 internal/project handler_test.go 模式)。
|
// fakeResolver 把固定 token 解码为 uuid(沿 internal/project handler_test.go 模式)。
|
||||||
type fakeResolver struct{ valid map[string]uuid.UUID }
|
type fakeResolver struct{ valid map[string]uuid.UUID }
|
||||||
|
|
||||||
@@ -82,3 +86,26 @@ func TestHandler_CreateWorkspace_Smoke(t *testing.T) {
|
|||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
require.Equal(t, http.StatusCreated, w.Code)
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_ListBranches_Smoke(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ws := &fakeWS{}
|
||||||
|
uid := uuid.New()
|
||||||
|
tok := "tok-branches"
|
||||||
|
resolver := &fakeResolver{valid: map[string]uuid.UUID{tok: uid}}
|
||||||
|
h := NewHandler(ws, nil, nil, resolver, fakeAdmin{})
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
h.Mount(r)
|
||||||
|
|
||||||
|
wsID := uuid.New()
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/workspaces/"+wsID.String()+"/branches", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp branchListResp
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||||
|
require.Equal(t, []string{"main", "develop"}, resp.Branches)
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,7 +167,24 @@ func (s *workspaceService) Update(ctx context.Context, c Caller, wsID uuid.UUID,
|
|||||||
if in.Description != nil {
|
if in.Description != nil {
|
||||||
desc = *in.Description
|
desc = *in.Description
|
||||||
}
|
}
|
||||||
if in.DefaultBranch != nil {
|
if in.DefaultBranch != nil && *in.DefaultBranch != ws.DefaultBranch {
|
||||||
|
// 切换 default_branch:先校验→拉远端→切到新分支,再写 DB。
|
||||||
|
// 任一 git 步骤失败则不改库,让调用方感知并自行重试/回滚。
|
||||||
|
if err := ValidateBranch(*in.DefaultBranch); err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch")
|
||||||
|
}
|
||||||
|
cred, credErr := s.loadDecryptedCredential(ctx, wsID)
|
||||||
|
if credErr != nil {
|
||||||
|
return nil, credErr
|
||||||
|
}
|
||||||
|
if fetchErr := s.gitr.FetchRemoteBranch(ctx, ws.MainPath, *in.DefaultBranch, cred); fetchErr != nil {
|
||||||
|
return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "fetch remote branch")
|
||||||
|
}
|
||||||
|
if coErr := s.gitr.Checkout(ctx, ws.MainPath, *in.DefaultBranch); coErr != nil {
|
||||||
|
return nil, errs.Wrap(coErr, errs.CodeGitCmdFailed, "checkout branch")
|
||||||
|
}
|
||||||
|
defaultBr = *in.DefaultBranch
|
||||||
|
} else if in.DefaultBranch != nil {
|
||||||
if err := ValidateBranch(*in.DefaultBranch); err != nil {
|
if err := ValidateBranch(*in.DefaultBranch); err != nil {
|
||||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch")
|
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch")
|
||||||
}
|
}
|
||||||
@@ -311,6 +328,29 @@ func (s *workspaceService) GetCredentialMeta(ctx context.Context, c Caller, wsID
|
|||||||
return cred, nil
|
return cred, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *workspaceService) ListBranches(ctx context.Context, c Caller, wsID uuid.UUID) ([]string, error) {
|
||||||
|
ws, err := s.repo.GetWorkspaceByID(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !canRead {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "no read access")
|
||||||
|
}
|
||||||
|
if ws.MainPath == "" {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "workspace not yet cloned")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(ws.MainPath); errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "workspace not yet cloned")
|
||||||
|
}
|
||||||
|
cred, err := s.loadDecryptedCredential(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.gitr.ListRemoteBranches(ctx, ws.MainPath, cred)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error) {
|
func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error) {
|
||||||
cred := &Credential{
|
cred := &Credential{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
|
|||||||
@@ -226,6 +226,45 @@ func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error
|
|||||||
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
|
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error { return nil }
|
||||||
|
func (f *fakeGit) Checkout(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (f *fakeGit) ListRemoteBranches(_ context.Context, _ string, _ *git.Credential) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackGit 包装 git.Runner,记录 FetchRemoteBranch / Checkout 调用以供断言。
|
||||||
|
// 嵌入 Runner 接口本身可避免为每个方法重复转发。
|
||||||
|
type trackGit struct {
|
||||||
|
git.Runner
|
||||||
|
fetchRemoteBranchCalls []string
|
||||||
|
checkoutCalls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackGit) FetchRemoteBranch(_ context.Context, _, branch string, _ *git.Credential) error {
|
||||||
|
t.fetchRemoteBranchCalls = append(t.fetchRemoteBranchCalls, branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackGit) Checkout(_ context.Context, _, branch string) error {
|
||||||
|
t.checkoutCalls = append(t.checkoutCalls, branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type errFetchGit struct {
|
||||||
|
git.Runner
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *errFetchGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type errCheckoutGit struct {
|
||||||
|
git.Runner
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *errCheckoutGit) Checkout(_ context.Context, _, _ string) error { return e.err }
|
||||||
|
|
||||||
type noopAudit struct{}
|
type noopAudit struct{}
|
||||||
|
|
||||||
@@ -379,3 +418,128 @@ func TestCloneRunner_Failure(t *testing.T) {
|
|||||||
require.Equal(t, SyncStatusError, got.SyncStatus)
|
require.Equal(t, SyncStatusError, got.SyncStatus)
|
||||||
require.Contains(t, got.LastSyncError, "boom")
|
require.Contains(t, got.LastSyncError, "boom")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Update_DefaultBranch_Switch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
fg := &trackGit{Runner: &fakeGit{}}
|
||||||
|
svc, repo := newSvc(t, pa, nil, fg)
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
|
||||||
|
// Seed a workspace with main_path set.
|
||||||
|
wsID := uuid.New()
|
||||||
|
existing := &Workspace{
|
||||||
|
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
|
||||||
|
DefaultBranch: "main", MainPath: "/data/ws1/main",
|
||||||
|
}
|
||||||
|
repo.wss[wsID] = existing
|
||||||
|
|
||||||
|
newBr := "develop"
|
||||||
|
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
|
||||||
|
DefaultBranch: &newBr,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "develop", repo.wss[wsID].DefaultBranch)
|
||||||
|
require.Equal(t, []string{"develop"}, fg.fetchRemoteBranchCalls)
|
||||||
|
require.Equal(t, []string{"develop"}, fg.checkoutCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Update_DefaultBranch_FetchFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
fg := &errFetchGit{Runner: &fakeGit{}, err: errors.New("remote branch not found")}
|
||||||
|
svc, repo := newSvc(t, pa, nil, fg)
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo.wss[wsID] = &Workspace{
|
||||||
|
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
|
||||||
|
DefaultBranch: "main", MainPath: "/data/ws1/main",
|
||||||
|
}
|
||||||
|
|
||||||
|
newBr := "nonexistent"
|
||||||
|
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
|
||||||
|
DefaultBranch: &newBr,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
// DB should NOT be updated
|
||||||
|
require.Equal(t, "main", repo.wss[wsID].DefaultBranch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Update_DefaultBranch_CheckoutFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
fg := &errCheckoutGit{Runner: &fakeGit{}, err: errors.New("dirty working tree")}
|
||||||
|
svc, repo := newSvc(t, pa, nil, fg)
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo.wss[wsID] = &Workspace{
|
||||||
|
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
|
||||||
|
DefaultBranch: "main", MainPath: "/data/ws1/main",
|
||||||
|
}
|
||||||
|
|
||||||
|
newBr := "develop"
|
||||||
|
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
|
||||||
|
DefaultBranch: &newBr,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
// DB should NOT be updated
|
||||||
|
require.Equal(t, "main", repo.wss[wsID].DefaultBranch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Update_DefaultBranch_SameValue_NoGitOps(t *testing.T) {
|
||||||
|
// 把 default_branch 更新成相同值时,不应触发任何 git 操作(避免无谓的 fetch/checkout)。
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
fg := &trackGit{Runner: &fakeGit{}}
|
||||||
|
svc, repo := newSvc(t, pa, nil, fg)
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo.wss[wsID] = &Workspace{
|
||||||
|
ID: wsID, ProjectID: pa.pid, Slug: "ws1", Name: "W1",
|
||||||
|
DefaultBranch: "main", MainPath: "/data/ws1/main",
|
||||||
|
}
|
||||||
|
|
||||||
|
sameBr := "main"
|
||||||
|
_, err := svc.Update(context.Background(), caller, wsID, UpdateWorkspaceInput{
|
||||||
|
DefaultBranch: &sameBr,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, fg.fetchRemoteBranchCalls)
|
||||||
|
require.Empty(t, fg.checkoutCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_ListBranches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
svc, repo := newSvc(t, pa, nil, &fakeGit{})
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo.wss[wsID] = &Workspace{
|
||||||
|
ID: wsID, ProjectID: pa.pid, Slug: "ws1",
|
||||||
|
DefaultBranch: "main", MainPath: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
branches, err := svc.ListBranches(context.Background(), caller, wsID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// fakeGit returns nil, nil → empty slice
|
||||||
|
require.Nil(t, branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_ListBranches_NoReadAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: false, canWrite: false}
|
||||||
|
svc, repo := newSvc(t, pa, nil, &fakeGit{})
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo.wss[wsID] = &Workspace{
|
||||||
|
ID: wsID, ProjectID: pa.pid, Slug: "ws1",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.ListBranches(context.Background(), caller, wsID)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.32.1",
|
"packageManager": "pnpm@10.32.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
Generated
+8
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@vicons/ionicons5':
|
||||||
|
specifier: ^0.13.0
|
||||||
|
version: 0.13.0
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.1
|
specifier: ^14.1.1
|
||||||
version: 14.1.1
|
version: 14.1.1
|
||||||
@@ -524,6 +527,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
|
resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@vicons/ionicons5@0.13.0':
|
||||||
|
resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@6.0.6':
|
'@vitejs/plugin-vue@6.0.6':
|
||||||
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
|
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2065,6 +2071,8 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.59.1
|
'@typescript-eslint/types': 8.59.1
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
|
'@vicons/ionicons5@0.13.0': {}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))':
|
'@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.13
|
'@rolldown/pluginutils': 1.0.0-rc.13
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export const workspacesApi = {
|
|||||||
release: (wtID: string) =>
|
release: (wtID: string) =>
|
||||||
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }),
|
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }),
|
||||||
|
|
||||||
|
listBranches: (wsID: string): Promise<string[]> =>
|
||||||
|
request<{ branches: string[] }>(`/api/v1/workspaces/${enc(wsID)}/branches`)
|
||||||
|
.then(r => r.branches),
|
||||||
|
|
||||||
// ===== Git ops =====
|
// ===== Git ops =====
|
||||||
statusOnMain: (wsID: string) =>
|
statusOnMain: (wsID: string) =>
|
||||||
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
|
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
|
||||||
|
|||||||
@@ -14,17 +14,39 @@
|
|||||||
v-for="c in chat.conversations"
|
v-for="c in chat.conversations"
|
||||||
:key="c.id"
|
:key="c.id"
|
||||||
:class="[
|
:class="[
|
||||||
'p-2 rounded cursor-pointer hover:bg-neutral-100',
|
'group p-2 rounded cursor-pointer hover:bg-neutral-100 flex items-center justify-between gap-2',
|
||||||
chat.currentConversation?.id === c.id ? 'bg-blue-50' : '',
|
chat.currentConversation?.id === c.id ? 'bg-blue-50' : '',
|
||||||
]"
|
]"
|
||||||
@click="open(c.id)"
|
@click="open(c.id)"
|
||||||
>
|
>
|
||||||
<div class="truncate text-sm">
|
<div class="flex-1 min-w-0">
|
||||||
{{ c.title || '(未命名)' }}
|
<div class="truncate text-sm">
|
||||||
</div>
|
{{ c.title || '(未命名)' }}
|
||||||
<div class="text-xs text-neutral-400">
|
</div>
|
||||||
{{ formatTime(c.updated_at) }}
|
<div class="text-xs text-neutral-400">
|
||||||
|
{{ formatTime(c.updated_at) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<NPopconfirm
|
||||||
|
:show-icon="false"
|
||||||
|
@positive-click="chat.deleteConversation(c.id)"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<NButton
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><TrashOutline /></NIcon>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
删除后无法恢复,确定删除此会话?
|
||||||
|
</template>
|
||||||
|
</NPopconfirm>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="chat.conversations.length === 0"
|
v-if="chat.conversations.length === 0"
|
||||||
@@ -43,6 +65,28 @@
|
|||||||
选择或新建会话
|
选择或新建会话
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- 头部操作栏 -->
|
||||||
|
<div class="border-b px-4 py-2 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-medium truncate flex-1">
|
||||||
|
{{ chat.currentConversation.title || '(未命名)' }}
|
||||||
|
</div>
|
||||||
|
<NPopconfirm
|
||||||
|
:show-icon="false"
|
||||||
|
@positive-click="deleteCurrentConversation"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<NButton text size="small">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><TrashOutline /></NIcon>
|
||||||
|
</template>
|
||||||
|
删除
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
删除后无法恢复,确定删除此会话?
|
||||||
|
</template>
|
||||||
|
</NPopconfirm>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
ref="scrollEl"
|
ref="scrollEl"
|
||||||
class="flex-1 overflow-y-auto p-4 space-y-3"
|
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||||
@@ -101,7 +145,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { NButton, NInput } from 'naive-ui'
|
import { NButton, NInput, NPopconfirm, NIcon } from 'naive-ui'
|
||||||
|
import { TrashOutline } from '@vicons/ionicons5'
|
||||||
|
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
@@ -206,6 +251,15 @@ async function onCreated(id: string) {
|
|||||||
await open(id)
|
await open(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentConversation() {
|
||||||
|
const id = chat.currentConversation?.id
|
||||||
|
if (!id) return
|
||||||
|
await chat.deleteConversation(id)
|
||||||
|
await router.replace({
|
||||||
|
path: projectId.value ? `/projects/${projectId.value}/chat` : `/chat`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
function formatTime(iso: string) {
|
||||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
>
|
>
|
||||||
<NButton
|
<NButton
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="showCreate = true"
|
@click="openCreate"
|
||||||
>
|
>
|
||||||
新建 worktree
|
新建 worktree
|
||||||
</NButton>
|
</NButton>
|
||||||
@@ -31,7 +31,15 @@
|
|||||||
<NInput v-model:value="newBranch" />
|
<NInput v-model:value="newBranch" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="基线分支(默认 default_branch)">
|
<NFormItem label="基线分支(默认 default_branch)">
|
||||||
<NInput v-model:value="newBase" />
|
<NSelect
|
||||||
|
v-model:value="newBase"
|
||||||
|
:options="baseBranchOptions"
|
||||||
|
:loading="branchesLoading"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
tag
|
||||||
|
placeholder="默认使用 default_branch"
|
||||||
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
</NForm>
|
</NForm>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -71,11 +79,13 @@ import {
|
|||||||
NForm,
|
NForm,
|
||||||
NFormItem,
|
NFormItem,
|
||||||
NInput,
|
NInput,
|
||||||
|
NSelect,
|
||||||
useMessage,
|
useMessage,
|
||||||
useDialog,
|
useDialog,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
|
import { workspacesApi } from '@/api/workspaces'
|
||||||
import CommitDialog from './CommitDialog.vue'
|
import CommitDialog from './CommitDialog.vue'
|
||||||
import type { Workspace, Worktree, WorktreeStatus } from '@/api/types'
|
import type { Workspace, Worktree, WorktreeStatus } from '@/api/types'
|
||||||
|
|
||||||
@@ -87,16 +97,40 @@ const dialog = useDialog()
|
|||||||
const showCreate = ref<boolean>(false)
|
const showCreate = ref<boolean>(false)
|
||||||
const creating = ref<boolean>(false)
|
const creating = ref<boolean>(false)
|
||||||
const newBranch = ref<string>('')
|
const newBranch = ref<string>('')
|
||||||
const newBase = ref<string>('')
|
const newBase = ref<string | null>(null)
|
||||||
|
|
||||||
const commitFor = ref<Worktree | null>(null)
|
const commitFor = ref<Worktree | null>(null)
|
||||||
|
|
||||||
|
// Branch list for base_branch selector
|
||||||
|
const branches = ref<string[]>([])
|
||||||
|
const branchesLoading = ref(false)
|
||||||
|
|
||||||
const STATUS_TAG_TYPE: Record<WorktreeStatus, 'warning' | 'error' | 'default'> = {
|
const STATUS_TAG_TYPE: Record<WorktreeStatus, 'warning' | 'error' | 'default'> = {
|
||||||
active: 'warning',
|
active: 'warning',
|
||||||
pruning: 'error',
|
pruning: 'error',
|
||||||
idle: 'default',
|
idle: 'default',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openCreate() {
|
||||||
|
showCreate.value = true
|
||||||
|
newBranch.value = ''
|
||||||
|
newBase.value = null
|
||||||
|
// Fetch branch list when dialog opens
|
||||||
|
branchesLoading.value = true
|
||||||
|
try {
|
||||||
|
branches.value = await workspacesApi.listBranches(props.ws.id)
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user can still type manually
|
||||||
|
branches.value = []
|
||||||
|
} finally {
|
||||||
|
branchesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseBranchOptions = computed(() =>
|
||||||
|
branches.value.map((b) => ({ label: b, value: b })),
|
||||||
|
)
|
||||||
|
|
||||||
async function onCreate() {
|
async function onCreate() {
|
||||||
if (!newBranch.value.trim()) {
|
if (!newBranch.value.trim()) {
|
||||||
msg.error('分支名为必填')
|
msg.error('分支名为必填')
|
||||||
@@ -110,7 +144,7 @@ async function onCreate() {
|
|||||||
})
|
})
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
newBranch.value = ''
|
newBranch.value = ''
|
||||||
newBase.value = ''
|
newBase.value = null
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
|
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user