Skip to content

Commit 923d777

Browse files
sallyomclaude
andcommitted
feat: Generate working branch names upfront in backend API
Implements Option 3 solution for improved git repository context UX. The backend now generates working branch names during session creation, providing clear visibility in the UI and simplifying runner logic. Backend Changes: - Added branch name generation helpers in handlers/helpers.go: - isProtectedBranch(): Detects commonly protected branches - sanitizeBranchName(): Converts display names to valid git branch names - generateWorkingBranch(): Core logic for branch name generation - Uses user-requested branch if specified - Creates work/{branch}/{sessionID} for protected branches - Falls back to sanitized session name or session-{ID} - Updated CreateSession and AddRepo handlers to process repos and generate branch names upfront - Extended SimpleRepo type with WorkingBranch and AllowProtectedWork input fields Runner Changes: - Simplified wrapper.py repository cloning logic - Removed _is_protected_branch() and _handle_protected_branch() methods - Now simply reads 'branch' from spec and checks out/creates it - No more runtime auto-generation logic Frontend Changes: - Updated addRepoMutation to send workingBranch and allowProtectedWork - Simplified Repository type (removed baseBranch/featureBranch) - Changed display from "Base: main" to "Branch: {workingBranch}" - Now shows the actual working branch generated by backend User Experience: - Users can now see the exact branch that will be used before session starts - Protected branch handling is transparent (shows work/{branch}/{sessionID}) - Session-named branches are visible upfront (e.g., "Fix-login-bug") Fixes part of #376 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 2746323 commit 923d777

File tree

6 files changed

+161
-105
lines changed

6 files changed

+161
-105
lines changed

components/backend/handlers/helpers.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log"
77
"math"
8+
"strings"
89
"time"
910

1011
authv1 "k8s.io/api/authorization/v1"
@@ -74,3 +75,74 @@ func ValidateSecretAccess(ctx context.Context, k8sClient kubernetes.Interface, n
7475

7576
return nil
7677
}
78+
79+
// isProtectedBranch checks if a branch name is commonly protected
80+
func isProtectedBranch(branch string) bool {
81+
if branch == "" {
82+
return false
83+
}
84+
protectedNames := []string{
85+
"main", "master", "develop", "dev", "development",
86+
"production", "prod", "staging", "stage", "qa", "test", "stable",
87+
}
88+
branchLower := strings.ToLower(strings.TrimSpace(branch))
89+
for _, protected := range protectedNames {
90+
if branchLower == protected {
91+
return true
92+
}
93+
}
94+
return false
95+
}
96+
97+
// sanitizeBranchName converts a display name to a valid git branch name
98+
func sanitizeBranchName(name string) string {
99+
// Replace spaces with hyphens
100+
name = strings.ReplaceAll(name, " ", "-")
101+
// Remove or replace invalid characters for git branch names
102+
// Valid: alphanumeric, dash, underscore, slash, dot (but not at start/end)
103+
var result strings.Builder
104+
for _, r := range name {
105+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
106+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' {
107+
result.WriteRune(r)
108+
}
109+
}
110+
sanitized := result.String()
111+
// Trim leading/trailing dashes or slashes
112+
sanitized = strings.Trim(sanitized, "-/")
113+
return sanitized
114+
}
115+
116+
// generateWorkingBranch generates a working branch name based on the session and repo context
117+
// Returns the branch name to use for the session
118+
func generateWorkingBranch(sessionDisplayName, sessionID, requestedBranch string, allowProtectedWork bool) string {
119+
// If user explicitly requested a branch
120+
if requestedBranch != "" {
121+
// Check if it's protected and user hasn't allowed working on it
122+
if isProtectedBranch(requestedBranch) && !allowProtectedWork {
123+
// Create a temporary working branch to protect the base branch
124+
sessionIDShort := sessionID
125+
if len(sessionID) > 8 {
126+
sessionIDShort = sessionID[:8]
127+
}
128+
return fmt.Sprintf("work/%s/%s", requestedBranch, sessionIDShort)
129+
}
130+
// User requested non-protected branch or explicitly allowed protected work
131+
return requestedBranch
132+
}
133+
134+
// No branch requested - generate from session name
135+
if sessionDisplayName != "" {
136+
sanitized := sanitizeBranchName(sessionDisplayName)
137+
if sanitized != "" {
138+
return sanitized
139+
}
140+
}
141+
142+
// Fallback: use session ID
143+
sessionIDShort := sessionID
144+
if len(sessionID) > 8 {
145+
sessionIDShort = sessionID[:8]
146+
}
147+
return fmt.Sprintf("session-%s", sessionIDShort)
148+
}

components/backend/handlers/sessions.go

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -621,14 +621,36 @@ func CreateSession(c *gin.Context) {
621621
}
622622

623623
// Set multi-repo configuration on spec (simplified format)
624+
// Generate working branch names upfront based on session context
624625
{
625626
spec := session["spec"].(map[string]interface{})
626627
if len(req.Repos) > 0 {
627628
arr := make([]map[string]interface{}, 0, len(req.Repos))
628629
for _, r := range req.Repos {
629-
m := map[string]interface{}{"url": r.URL}
630-
if r.Branch != nil {
631-
m["branch"] = *r.Branch
630+
// Determine the working branch to use
631+
var requestedBranch string
632+
if r.WorkingBranch != nil {
633+
requestedBranch = strings.TrimSpace(*r.WorkingBranch)
634+
} else if r.Branch != nil {
635+
requestedBranch = strings.TrimSpace(*r.Branch)
636+
}
637+
638+
allowProtected := false
639+
if r.AllowProtectedWork != nil {
640+
allowProtected = *r.AllowProtectedWork
641+
}
642+
643+
// Generate the actual branch name that will be used
644+
workingBranch := generateWorkingBranch(
645+
req.DisplayName,
646+
name, // session name (unique ID)
647+
requestedBranch,
648+
allowProtected,
649+
)
650+
651+
m := map[string]interface{}{
652+
"url": r.URL,
653+
"branch": workingBranch,
632654
}
633655
arr = append(arr, m)
634656
}
@@ -1406,19 +1428,17 @@ func AddRepo(c *gin.Context) {
14061428
}
14071429

14081430
var req struct {
1409-
URL string `json:"url" binding:"required"`
1410-
Branch string `json:"branch"`
1431+
URL string `json:"url" binding:"required"`
1432+
Branch string `json:"branch"`
1433+
WorkingBranch string `json:"workingBranch"`
1434+
AllowProtectedWork bool `json:"allowProtectedWork"`
14111435
}
14121436

14131437
if err := c.ShouldBindJSON(&req); err != nil {
14141438
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
14151439
return
14161440
}
14171441

1418-
if req.Branch == "" {
1419-
req.Branch = "main"
1420-
}
1421-
14221442
gvr := GetAgenticSessionV1Alpha1Resource()
14231443
item, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{})
14241444
if err != nil {
@@ -1447,9 +1467,29 @@ func AddRepo(c *gin.Context) {
14471467
repos = []interface{}{}
14481468
}
14491469

1470+
// Determine the requested branch
1471+
requestedBranch := req.WorkingBranch
1472+
if requestedBranch == "" {
1473+
requestedBranch = req.Branch
1474+
}
1475+
1476+
// Get session display name for branch generation
1477+
displayName := ""
1478+
if dn, ok := spec["displayName"].(string); ok {
1479+
displayName = dn
1480+
}
1481+
1482+
// Generate the actual working branch name
1483+
workingBranch := generateWorkingBranch(
1484+
displayName,
1485+
sessionName,
1486+
requestedBranch,
1487+
req.AllowProtectedWork,
1488+
)
1489+
14501490
newRepo := map[string]interface{}{
14511491
"url": req.URL,
1452-
"branch": req.Branch,
1492+
"branch": workingBranch,
14531493
}
14541494
repos = append(repos, newRepo)
14551495
spec["repos"] = repos

components/backend/types/session.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ type AgenticSessionSpec struct {
2828

2929
// SimpleRepo represents a simplified repository configuration
3030
type SimpleRepo struct {
31-
URL string `json:"url"`
32-
Branch *string `json:"branch,omitempty"`
31+
URL string `json:"url"`
32+
Branch *string `json:"branch,omitempty"`
33+
WorkingBranch *string `json:"workingBranch,omitempty"` // User-requested working branch (input only)
34+
AllowProtectedWork *bool `json:"allowProtectedWork,omitempty"` // Allow work directly on protected branches (input only)
3335
}
3436

3537
type AgenticSessionStatus struct {

components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ import { Button } from "@/components/ui/button";
88

99
type Repository = {
1010
url: string;
11-
branch?: string;
12-
baseBranch?: string;
13-
featureBranch?: string;
11+
branch?: string; // The actual working branch (generated by backend)
1412
allowProtectedWork?: boolean;
1513
sync?: {
1614
url: string;
@@ -105,9 +103,8 @@ export function RepositoriesAccordion({
105103
const repoName = repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`;
106104
const isRemoving = removingRepo === repoName;
107105

108-
// Determine which branch info to show
109-
const baseBranch = repo.baseBranch || repo.branch || 'main';
110-
const featureBranch = repo.featureBranch;
106+
// Get the actual working branch from spec (generated by backend)
107+
const workingBranch = repo.branch || 'main';
111108
const hasSync = !!repo.sync?.url;
112109
const allowProtected = repo.allowProtectedWork;
113110

@@ -135,16 +132,10 @@ export function RepositoriesAccordion({
135132

136133
{/* Branch information */}
137134
<div className="flex flex-wrap gap-1 text-xs">
138-
<div className="inline-flex items-center gap-1 bg-background px-2 py-0.5 rounded border">
139-
<span className="text-muted-foreground">Base:</span>
140-
<span className="font-mono">{baseBranch}</span>
135+
<div className="inline-flex items-center gap-1 bg-blue-50 dark:bg-blue-950 px-2 py-0.5 rounded border border-blue-200 dark:border-blue-800">
136+
<span className="text-blue-700 dark:text-blue-300">Branch:</span>
137+
<span className="font-mono text-blue-900 dark:text-blue-100">{workingBranch}</span>
141138
</div>
142-
{featureBranch && (
143-
<div className="inline-flex items-center gap-1 bg-blue-50 dark:bg-blue-950 px-2 py-0.5 rounded border border-blue-200 dark:border-blue-800">
144-
<span className="text-blue-700 dark:text-blue-300">Feature:</span>
145-
<span className="font-mono text-blue-900 dark:text-blue-100">{featureBranch}</span>
146-
</div>
147-
)}
148139
{hasSync && (
149140
<div className="inline-flex items-center gap-1 bg-green-50 dark:bg-green-950 px-2 py-0.5 rounded border border-green-200 dark:border-green-800">
150141
<GitMerge className="h-3 w-3 text-green-600 dark:text-green-400" />

components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export default function ProjectSessionDetailPage({
214214

215215
// Repo management mutations
216216
const addRepoMutation = useMutation({
217-
mutationFn: async (repo: { url: string; branch: string }) => {
217+
mutationFn: async (repo: { url: string; workingBranch?: string; allowProtectedWork?: boolean }) => {
218218
setRepoChanging(true);
219219
const response = await fetch(
220220
`/api/projects/${projectName}/agentic-sessions/${sessionName}/repos`,
@@ -1460,10 +1460,12 @@ export default function ProjectSessionDetailPage({
14601460
open={contextModalOpen}
14611461
onOpenChange={setContextModalOpen}
14621462
onAddRepository={async (config) => {
1463-
// For now, use workingBranch as branch (backend compatibility)
1463+
// Send workingBranch and allowProtectedWork to backend
1464+
// Backend will generate the actual branch name to use
14641465
await addRepoMutation.mutateAsync({
14651466
url: config.url,
1466-
branch: config.workingBranch || 'main'
1467+
workingBranch: config.workingBranch,
1468+
allowProtectedWork: config.allowProtectedWork
14671469
});
14681470
setContextModalOpen(false);
14691471
}}

0 commit comments

Comments
 (0)