feat: track workspace repo default branches

This commit is contained in:
Aditi Chauhan 2026-07-01 14:01:30 +05:30
parent e8a2438291
commit fa71a7dda5
13 changed files with 85 additions and 16 deletions

View File

@ -44,6 +44,7 @@ type WorkspaceRepoRecord struct {
Name string
RelativePath string
RepoOriginURL string
DefaultBranch string
RegisteredAt time.Time
}

View File

@ -2560,6 +2560,8 @@ components:
type: object
WorkspaceRepo:
properties:
defaultBranch:
type: string
name:
type: string
relativePath:
@ -2570,6 +2572,7 @@ components:
- name
- relativePath
- repo
- defaultBranch
type: object
tags:
- description: Project registry, configuration, and lifecycle administration

View File

@ -530,7 +530,12 @@ func configureCommitter(t *testing.T) {
func gitRepoWithCommit(t *testing.T, dir string) string {
t.Helper()
if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil {
return gitRepoWithCommitOnBranch(t, dir, "main")
}
func gitRepoWithCommitOnBranch(t *testing.T, dir, branch string) string {
t.Helper()
if out, err := exec.Command("git", "init", "-b", branch, dir).CombinedOutput(); err != nil {
t.Fatalf("git init: %v (%s)", err, out)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\n"), 0o644); err != nil {
@ -566,6 +571,11 @@ func TestManager_AddWorkspaceInitializesPlainParent(t *testing.T) {
if len(proj.WorkspaceRepos) != 2 || proj.WorkspaceRepos[0].Name != "api" || proj.WorkspaceRepos[1].Name != "cli" {
t.Fatalf("WorkspaceRepos = %#v", proj.WorkspaceRepos)
}
for _, repo := range proj.WorkspaceRepos {
if repo.DefaultBranch != "main" {
t.Fatalf("repo %s default branch = %q, want main", repo.Name, repo.DefaultBranch)
}
}
ignored, err := os.ReadFile(filepath.Join(parent, ".gitignore"))
if err != nil {
t.Fatal(err)
@ -595,6 +605,35 @@ func TestManager_AddWorkspaceInitializesPlainParent(t *testing.T) {
}
}
func TestManager_AddWorkspaceDetectsChildDefaultBranches(t *testing.T) {
configureCommitter(t)
ctx := context.Background()
m := newManager(t)
parent := t.TempDir()
gitRepoWithCommitOnBranch(t, filepath.Join(parent, "api"), "master")
gitRepoWithCommitOnBranch(t, filepath.Join(parent, "cli"), "develop")
proj, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("ws"), AsWorkspace: true})
if err != nil {
t.Fatalf("Add workspace: %v", err)
}
want := map[string]string{"api": "master", "cli": "develop"}
for _, repo := range proj.WorkspaceRepos {
if repo.DefaultBranch != want[repo.Name] {
t.Fatalf("repo %s default branch = %q, want %q", repo.Name, repo.DefaultBranch, want[repo.Name])
}
}
got, err := m.Get(ctx, "ws")
if err != nil {
t.Fatalf("Get workspace: %v", err)
}
for _, repo := range got.Project.WorkspaceRepos {
if repo.DefaultBranch != want[repo.Name] {
t.Fatalf("stored repo %s default branch = %q, want %q", repo.Name, repo.DefaultBranch, want[repo.Name])
}
}
}
func TestManager_AddWorkspaceRejectsUncommittedChild(t *testing.T) {
configureCommitter(t)
ctx := context.Background()

View File

@ -36,7 +36,8 @@ type Degraded struct {
// WorkspaceRepo is the project-detail read shape for a registered child repo.
type WorkspaceRepo struct {
Name string `json:"name"`
RelativePath string `json:"relativePath"`
Repo string `json:"repo"`
Name string `json:"name"`
RelativePath string `json:"relativePath"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
}

View File

@ -176,6 +176,7 @@ func detectWorkspaceChildren(ctx context.Context, parent string, projectID domai
Name: name,
RelativePath: filepath.ToSlash(name),
RepoOriginURL: resolveGitOriginURL(child),
DefaultBranch: resolveDefaultBranch(child),
RegisteredAt: registeredAt,
})
}
@ -206,8 +207,7 @@ func validateWorkspaceChild(ctx context.Context, child string) error {
"suggestedFix": "Run `git init -b main`, add the initial files, and create the first commit before registering the workspace.",
})
}
branch, err := gitOutput(ctx, child, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil || strings.TrimSpace(branch) == "" {
if branch := resolveDefaultBranch(child); branch == "" {
return apierr.Invalid("WORKSPACE_CHILD_DEFAULT_BRANCH_UNKNOWN", "Workspace child repositories must have an identifiable default branch", map[string]any{
"path": child,
"suggestedFix": "Check out the repository's default branch (for example `main`) and retry.",
@ -352,7 +352,16 @@ func guardNoGitlinks(ctx context.Context, repo string) error {
func workspaceReposFromRecords(records []domain.WorkspaceRepoRecord) []WorkspaceRepo {
out := make([]WorkspaceRepo, 0, len(records))
for _, rec := range records {
out = append(out, WorkspaceRepo{Name: rec.Name, RelativePath: rec.RelativePath, Repo: rec.RepoOriginURL})
defaultBranch := rec.DefaultBranch
if defaultBranch == "" {
defaultBranch = domain.DefaultBranchName
}
out = append(out, WorkspaceRepo{
Name: rec.Name,
RelativePath: rec.RelativePath,
Repo: rec.RepoOriginURL,
DefaultBranch: defaultBranch,
})
}
return out
}

View File

@ -340,7 +340,7 @@ func (m *Manager) createSessionWorkspace(ctx context.Context, project domain.Pro
Name: repo.Name,
RelativePath: repo.RelativePath,
RepoPath: filepath.Join(project.Path, filepath.FromSlash(repo.RelativePath)),
BaseBranch: project.Config.WithDefaults().DefaultBranch,
BaseBranch: firstNonEmptyString(repo.DefaultBranch, project.Config.WithDefaults().DefaultBranch),
})
}
info, err := workspaceProject.CreateWorkspaceProject(ctx, ports.WorkspaceProjectConfig{

View File

@ -616,8 +616,8 @@ func TestSpawn_WorkspaceProjectRecordsRootAndChildWorktrees(t *testing.T) {
Config: testRoleAgents(),
}
st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{
{Name: "api", RelativePath: "services/api"},
{Name: "web", RelativePath: "apps/web"},
{Name: "api", RelativePath: "services/api", DefaultBranch: "master"},
{Name: "web", RelativePath: "apps/web", DefaultBranch: "develop"},
}
rt := &fakeRuntime{}
ws := &fakeWorkspace{path: "/managed/mer-1"}
@ -649,6 +649,12 @@ func TestSpawn_WorkspaceProjectRecordsRootAndChildWorktrees(t *testing.T) {
if got := ws.lastProjectCfg.Repos[1].RepoPath; got != "/repo/mer/apps/web" {
t.Fatalf("web repo path = %q, want /repo/mer/apps/web", got)
}
if got := ws.lastProjectCfg.Repos[0].BaseBranch; got != "master" {
t.Fatalf("api base branch = %q, want master", got)
}
if got := ws.lastProjectCfg.Repos[1].BaseBranch; got != "develop" {
t.Fatalf("web base branch = %q, want develop", got)
}
rows := st.worktrees["mer-1"]
if len(rows) != 3 {
t.Fatalf("session worktree rows = %d, want 3: %#v", len(rows), rows)

View File

@ -210,5 +210,6 @@ type WorkspaceRepo struct {
Name string
RelativePath string
RepoOriginURL string
DefaultBranch string
RegisteredAt time.Time
}

View File

@ -95,7 +95,7 @@ func (q *Queries) ListSessionWorktrees(ctx context.Context, sessionID domain.Ses
}
const listWorkspaceRepos = `-- name: ListWorkspaceRepos :many
SELECT project_id, name, relative_path, repo_origin_url, registered_at
SELECT project_id, name, relative_path, repo_origin_url, default_branch, registered_at
FROM workspace_repos
WHERE project_id = ?
ORDER BY name
@ -115,6 +115,7 @@ func (q *Queries) ListWorkspaceRepos(ctx context.Context, projectID domain.Proje
&i.Name,
&i.RelativePath,
&i.RepoOriginURL,
&i.DefaultBranch,
&i.RegisteredAt,
); err != nil {
return nil, err
@ -165,11 +166,12 @@ func (q *Queries) UpsertSessionWorktree(ctx context.Context, arg UpsertSessionWo
}
const upsertWorkspaceRepo = `-- name: UpsertWorkspaceRepo :exec
INSERT INTO workspace_repos (project_id, name, relative_path, repo_origin_url, registered_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO workspace_repos (project_id, name, relative_path, repo_origin_url, default_branch, registered_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (project_id, name) DO UPDATE SET
relative_path = excluded.relative_path,
repo_origin_url = excluded.repo_origin_url,
default_branch = excluded.default_branch,
registered_at = excluded.registered_at
`
@ -178,6 +180,7 @@ type UpsertWorkspaceRepoParams struct {
Name string
RelativePath string
RepoOriginURL string
DefaultBranch string
RegisteredAt time.Time
}
@ -187,6 +190,7 @@ func (q *Queries) UpsertWorkspaceRepo(ctx context.Context, arg UpsertWorkspaceRe
arg.Name,
arg.RelativePath,
arg.RepoOriginURL,
arg.DefaultBranch,
arg.RegisteredAt,
)
return err

View File

@ -8,6 +8,7 @@ CREATE TABLE workspace_repos (
name TEXT NOT NULL,
relative_path TEXT NOT NULL,
repo_origin_url TEXT NOT NULL DEFAULT '',
default_branch TEXT NOT NULL DEFAULT 'main',
registered_at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, name),
UNIQUE (project_id, relative_path)

View File

@ -2,15 +2,16 @@
DELETE FROM workspace_repos WHERE project_id = ?;
-- name: UpsertWorkspaceRepo :exec
INSERT INTO workspace_repos (project_id, name, relative_path, repo_origin_url, registered_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO workspace_repos (project_id, name, relative_path, repo_origin_url, default_branch, registered_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (project_id, name) DO UPDATE SET
relative_path = excluded.relative_path,
repo_origin_url = excluded.repo_origin_url,
default_branch = excluded.default_branch,
registered_at = excluded.registered_at;
-- name: ListWorkspaceRepos :many
SELECT project_id, name, relative_path, repo_origin_url, registered_at
SELECT project_id, name, relative_path, repo_origin_url, default_branch, registered_at
FROM workspace_repos
WHERE project_id = ?
ORDER BY name;

View File

@ -45,6 +45,7 @@ func (s *Store) UpsertWorkspaceProject(ctx context.Context, r domain.ProjectReco
Name: repo.Name,
RelativePath: repo.RelativePath,
RepoOriginURL: repo.RepoOriginURL,
DefaultBranch: repo.DefaultBranch,
RegisteredAt: repo.RegisteredAt,
}); err != nil {
return err
@ -67,6 +68,7 @@ func (s *Store) ListWorkspaceRepos(ctx context.Context, projectID string) ([]dom
Name: row.Name,
RelativePath: row.RelativePath,
RepoOriginURL: row.RepoOriginURL,
DefaultBranch: row.DefaultBranch,
RegisteredAt: row.RegisteredAt,
})
}

View File

@ -916,6 +916,7 @@ export interface components {
reviews: components["schemas"]["PRReviewState"][];
};
WorkspaceRepo: {
defaultBranch: string;
name: string;
relativePath: string;
repo: string;