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) {
|
||||
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.
|
||||
// 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) {
|
||||
return nil, fmt.Errorf("unused")
|
||||
}
|
||||
func (s *fakeWsSvc) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
|
||||
return nil, fmt.Errorf("unused")
|
||||
}
|
||||
|
||||
// ===== Tests =====
|
||||
|
||||
|
||||
@@ -436,6 +436,10 @@ func (s *acpStubWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid
|
||||
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
|
||||
// exercised by the branch=main path.
|
||||
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 {
|
||||
Clone(ctx context.Context, dst, remoteURL, branch 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
|
||||
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err 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
|
||||
WorktreeRemove(ctx context.Context, dir, path string) 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` 的一行。
|
||||
|
||||
@@ -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) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// ===== test helpers =====
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ type WorkspaceService interface {
|
||||
|
||||
UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*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 的应用服务方法。
|
||||
|
||||
@@ -115,3 +115,7 @@ func toFileStatusResp(f git.FileStatus) fileStatusResp {
|
||||
type commitResp struct {
|
||||
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.Post("/worktrees", h.createWorktree)
|
||||
r.Get("/branches", h.listBranches)
|
||||
})
|
||||
r.With(auth).Route("/api/v1/worktrees/{wtID}", func(r chi.Router) {
|
||||
r.Delete("/", h.deleteWorktree)
|
||||
@@ -340,6 +341,25 @@ func (h *Handler) createWorktree(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -48,6 +48,10 @@ func (f *fakeWS) GetCredentialMeta(_ context.Context, _ Caller, _ uuid.UUID) (*C
|
||||
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 模式)。
|
||||
type fakeResolver struct{ valid map[string]uuid.UUID }
|
||||
|
||||
@@ -82,3 +86,26 @@ func TestHandler_CreateWorkspace_Smoke(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
cred := &Credential{
|
||||
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) {
|
||||
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{}
|
||||
|
||||
@@ -379,3 +418,128 @@ func TestCloneRunner_Failure(t *testing.T) {
|
||||
require.Equal(t, SyncStatusError, got.SyncStatus)
|
||||
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",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
Generated
+8
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@vicons/ionicons5':
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
@@ -524,6 +527,9 @@ packages:
|
||||
resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2065,6 +2071,8 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.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))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.13
|
||||
|
||||
@@ -52,6 +52,10 @@ export const workspacesApi = {
|
||||
release: (wtID: string) =>
|
||||
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 =====
|
||||
statusOnMain: (wsID: string) =>
|
||||
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
|
||||
|
||||
@@ -14,17 +14,39 @@
|
||||
v-for="c in chat.conversations"
|
||||
:key="c.id"
|
||||
: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' : '',
|
||||
]"
|
||||
@click="open(c.id)"
|
||||
>
|
||||
<div class="truncate text-sm">
|
||||
{{ c.title || '(未命名)' }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-400">
|
||||
{{ formatTime(c.updated_at) }}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate text-sm">
|
||||
{{ c.title || '(未命名)' }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-400">
|
||||
{{ formatTime(c.updated_at) }}
|
||||
</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
|
||||
v-if="chat.conversations.length === 0"
|
||||
@@ -43,6 +65,28 @@
|
||||
选择或新建会话
|
||||
</div>
|
||||
<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
|
||||
ref="scrollEl"
|
||||
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
@@ -101,7 +145,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
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 { useChatStore } from '@/stores/chat'
|
||||
@@ -206,6 +251,15 @@ async function onCreated(id: string) {
|
||||
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) {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="showCreate = true"
|
||||
@click="openCreate"
|
||||
>
|
||||
新建 worktree
|
||||
</NButton>
|
||||
@@ -31,7 +31,15 @@
|
||||
<NInput v-model:value="newBranch" />
|
||||
</NFormItem>
|
||||
<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>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
@@ -71,11 +79,13 @@ import {
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
useMessage,
|
||||
useDialog,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
import CommitDialog from './CommitDialog.vue'
|
||||
import type { Workspace, Worktree, WorktreeStatus } from '@/api/types'
|
||||
|
||||
@@ -87,16 +97,40 @@ const dialog = useDialog()
|
||||
const showCreate = ref<boolean>(false)
|
||||
const creating = ref<boolean>(false)
|
||||
const newBranch = ref<string>('')
|
||||
const newBase = ref<string>('')
|
||||
const newBase = ref<string | 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'> = {
|
||||
active: 'warning',
|
||||
pruning: 'error',
|
||||
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() {
|
||||
if (!newBranch.value.trim()) {
|
||||
msg.error('分支名为必填')
|
||||
@@ -110,7 +144,7 @@ async function onCreate() {
|
||||
})
|
||||
showCreate.value = false
|
||||
newBranch.value = ''
|
||||
newBase.value = ''
|
||||
newBase.value = null
|
||||
} catch (e: unknown) {
|
||||
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user