From 50a87f90a04aca3543481d0f7ca3e03951c1cc40 Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Sat, 13 Jun 2026 01:35:18 +0530 Subject: [PATCH] fix(project): detect the repo's default branch on add Register the repo's actual checked-out branch as the project default so session worktrees base off a ref that exists. Previously Config.DefaultBranch was left empty and defaulted to "main", so a repo on master/develop/trunk failed every spawn with BRANCH_NOT_FETCHED and had no CLI workaround. Detection is best-effort (symbolic-ref --short HEAD); a detached HEAD or git error falls back to the existing main default. Only persist when the branch diverges from main, so the common main repo keeps a NULL config. Closes #208 --- .../httpd/controllers/projects_test.go | 2 +- backend/internal/service/project/service.go | 23 ++++++++ .../internal/service/project/service_test.go | 56 ++++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index bd4ccce0..d102b28c 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -509,7 +509,7 @@ func gitRepo(t *testing.T, name string) string { } - if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil { t.Fatalf("git init fixture: %v\n%s", err, out) diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index adc92438..f0bf49f3 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -176,6 +176,16 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { if !isGitRepo(path) { return Project{}, apierr.Invalid("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) } + // Record the repo's actual checked-out branch as the project default so + // session worktrees base off a branch that exists. Without this a repo on + // `master` (or any non-`main` default) falls back to DefaultBranchName and + // every spawn fails BRANCH_NOT_FETCHED. Only persist when it diverges from + // the default, so the common `main` repo keeps an empty (NULL) config. + if row.Config.DefaultBranch == "" { + if branch := resolveDefaultBranch(path); branch != "" && branch != domain.DefaultBranchName { + row.Config.DefaultBranch = branch + } + } row.RepoOriginURL = resolveGitOriginURL(path) if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") @@ -218,6 +228,19 @@ func resolveGitOriginURL(path string) string { return strings.TrimSpace(string(out)) } +// resolveDefaultBranch returns the repo's currently checked-out branch via +// `git -C path symbolic-ref --short HEAD`. A detached HEAD, missing repo, or any +// other git error returns an empty string — `project add` must not fail just +// because the branch can't be resolved (the caller falls back to +// DefaultBranchName). +func resolveDefaultBranch(path string) string { + out, err := exec.Command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + // Remove archives a project registration. func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { if err := validateProjectID(id); err != nil { diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4d07dce9..9fc150eb 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -28,11 +28,24 @@ func newManager(t *testing.T) project.Manager { return project.New(store) } -// gitRepo creates a real git repository in a fresh temp dir and returns its path. +// gitRepo creates a real git repository in a fresh temp dir and returns its +// path. It pins the initial branch to `main` so default-branch detection is +// deterministic regardless of the host's init.defaultBranch. func gitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() - if out, err := exec.Command("git", "init", dir).CombinedOutput(); err != nil { + if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil { + t.Fatalf("git unavailable: %v (%s)", err, out) + } + return dir +} + +// gitRepoOnBranch creates a real git repository whose initial branch is +// `branch`, used to exercise default-branch detection for non-`main` repos. +func gitRepoOnBranch(t *testing.T, branch string) string { + t.Helper() + dir := t.TempDir() + if out, err := exec.Command("git", "init", "-b", branch, dir).CombinedOutput(); err != nil { t.Fatalf("git unavailable: %v (%s)", err, out) } return dir @@ -133,6 +146,45 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { } } +func TestManager_AddDetectsNonMainDefaultBranch(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepoOnBranch(t, "master") + + proj, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}) + if err != nil { + t.Fatalf("Add: %v", err) + } + // A repo whose checked-out branch is not `main` must record that branch so + // session worktrees base off a ref that exists (otherwise spawn fails + // BRANCH_NOT_FETCHED). + if proj.DefaultBranch != "master" { + t.Fatalf("DefaultBranch = %q, want master", proj.DefaultBranch) + } + + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil || got.Project.DefaultBranch != "master" { + t.Fatalf("Get DefaultBranch = %#v, want master", got.Project) + } + + // An explicit config wins over detection. + mainRepo := gitRepoOnBranch(t, "trunk") + proj2, err := m.Add(ctx, project.AddInput{ + Path: mainRepo, + ProjectID: ptr("ao2"), + Config: &domain.ProjectConfig{DefaultBranch: "release"}, + }) + if err != nil { + t.Fatalf("Add with config: %v", err) + } + if proj2.DefaultBranch != "release" { + t.Fatalf("explicit DefaultBranch = %q, want release", proj2.DefaultBranch) + } +} + func TestManager_SetConfig(t *testing.T) { ctx := context.Background() m := newManager(t)