From fa71a7dda572d12c4e99e9731f5058115d2502ef Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Wed, 1 Jul 2026 14:01:30 +0530 Subject: [PATCH] feat: track workspace repo default branches --- backend/internal/domain/project.go | 1 + backend/internal/httpd/apispec/openapi.yaml | 3 ++ .../internal/service/project/service_test.go | 41 ++++++++++++++++++- backend/internal/service/project/types.go | 7 ++-- .../service/project/workspace_registration.go | 15 +++++-- backend/internal/session_manager/manager.go | 2 +- .../internal/session_manager/manager_test.go | 10 ++++- backend/internal/storage/sqlite/gen/models.go | 1 + .../storage/sqlite/gen/workspace.sql.go | 10 +++-- .../migrations/0009_workspace_projects.sql | 1 + .../storage/sqlite/queries/workspace.sql | 7 ++-- .../storage/sqlite/store/project_store.go | 2 + frontend/src/api/schema.ts | 1 + 13 files changed, 85 insertions(+), 16 deletions(-) diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index f99162ae5..24520d471 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -44,6 +44,7 @@ type WorkspaceRepoRecord struct { Name string RelativePath string RepoOriginURL string + DefaultBranch string RegisteredAt time.Time } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index c194ffa0b..9f9173843 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -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 diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4901b83bd..9cd9d219d 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -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() diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index f33b338d5..6958eb25e 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -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"` } diff --git a/backend/internal/service/project/workspace_registration.go b/backend/internal/service/project/workspace_registration.go index fe072984c..3dcbd21a9 100644 --- a/backend/internal/service/project/workspace_registration.go +++ b/backend/internal/service/project/workspace_registration.go @@ -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 } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index f7137c1fa..edc16e897 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -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{ diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 5d6ccbe4b..c77561640 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -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) diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 1140f53cc..59143b176 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -210,5 +210,6 @@ type WorkspaceRepo struct { Name string RelativePath string RepoOriginURL string + DefaultBranch string RegisteredAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/workspace.sql.go b/backend/internal/storage/sqlite/gen/workspace.sql.go index 60d838764..cf8c48fd3 100644 --- a/backend/internal/storage/sqlite/gen/workspace.sql.go +++ b/backend/internal/storage/sqlite/gen/workspace.sql.go @@ -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 diff --git a/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql b/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql index cd9e074df..c1861482b 100644 --- a/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql +++ b/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql @@ -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) diff --git a/backend/internal/storage/sqlite/queries/workspace.sql b/backend/internal/storage/sqlite/queries/workspace.sql index 79e4c7ec0..57dbd7bd2 100644 --- a/backend/internal/storage/sqlite/queries/workspace.sql +++ b/backend/internal/storage/sqlite/queries/workspace.sql @@ -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; diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index abc5eca95..e66ccbd4b 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -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, }) } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 9ee643dad..5f387f791 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -916,6 +916,7 @@ export interface components { reviews: components["schemas"]["PRReviewState"][]; }; WorkspaceRepo: { + defaultBranch: string; name: string; relativePath: string; repo: string;