Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/internal/httpd/controllers/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions backend/internal/service/project/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
}
Comment on lines +236 to +242

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 symbolic-ref HEAD captures the current checkout, not the repo's default branch

git symbolic-ref --short HEAD resolves whatever branch the user currently has checked out, not the repository's canonical default. If a developer runs ao project add while on feature/new-ui of a main-default repo, feature/new-ui != "main" is true and that transient branch gets persisted as DefaultBranch. Every subsequent ao spawn then bases worktrees off feature/new-ui; once the branch is deleted after merging, all spawns break in exactly the same way as the original bug.

A more reliable source is the remote-tracking ref refs/remotes/origin/HEAD, which records the remote's default branch locally without a network round-trip (set by git clone or git remote set-head). Falling back to symbolic-ref HEAD only when that ref is absent would preserve the current improvement while avoiding the false-positive case.


// Remove archives a project registration.
func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) {
if err := validateProjectID(id); err != nil {
Expand Down
56 changes: 54 additions & 2 deletions backend/internal/service/project/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading