Skip to content

Commit 63826e6

Browse files
sallyomclaude
andcommitted
feat: support multiple branches per repo and display actual checked-out branch
Implements the remaining functionality from the git repo context feature: 1. **Fix 2nd branch checkout issue (Runner)**: - Modified `clone_repo_at_runtime()` in main.py to handle branch switching - When repo already exists, now fetches and checks out the requested branch - Previously skipped clone entirely if repo directory existed 2. **Add /repos/status endpoint (Runner)**: - New GET endpoint to query actual checked-out branches - Returns list of repos with current branch info from git - Used by operator to sync actual state to status 3. **Fetch and store current branch (Operator)**: - Added `fetchActualBranchesFromRunner()` helper function - Calls runner's /repos/status endpoint during reconciliation - Updates status.reconciledRepos with both intended and actual branch - New field: `currentBranch` alongside existing `branch` field 4. **Display actual branch (Frontend)**: - Updated RepositoriesAccordion to support `currentBranch` field - Changed data source from spec.repos to status.reconciledRepos - Badge now shows actual checked-out branch from runner **User Impact**: - Can now add same repo with different branches as context - Context Modal displays the actual checked-out branch - Runner properly switches branches when user adds 2nd branch - System maintains both intended (spec) and actual (status) branch info Fixes the issue described in PR ambient-code#498 where adding a 2nd branch for the same repo would show in UI but not actually checkout in the runner pod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 8cb14fb commit 63826e6

File tree

4 files changed

+192
-21
lines changed

4 files changed

+192
-21
lines changed

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
99
type Repository = {
1010
url: string;
1111
branch?: string;
12+
currentBranch?: string; // Actual checked-out branch from status
1213
};
1314

1415
type UploadedFile = {
@@ -104,9 +105,13 @@ export function RepositoriesAccordion({
104105
<div className="flex-1 min-w-0">
105106
<div className="flex items-center gap-2">
106107
<div className="text-sm font-medium truncate">{repoName}</div>
107-
{repo.branch && (
108-
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 flex-shrink-0">
109-
{repo.branch}
108+
{/* Show current branch (actual checked-out) or fallback to intended branch */}
109+
{(repo.currentBranch || repo.branch) && (
110+
<Badge
111+
variant="outline"
112+
className="text-xs px-1.5 py-0 h-5 flex-shrink-0"
113+
>
114+
{repo.currentBranch || repo.branch}
110115
</Badge>
111116
)}
112117
</div>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1552,7 +1552,7 @@ export default function ProjectSessionDetailPage({
15521552
/>
15531553

15541554
<RepositoriesAccordion
1555-
repositories={session?.spec?.repos || []}
1555+
repositories={session?.status?.reconciledRepos || session?.spec?.repos || []}
15561556
uploadedFiles={fileUploadsList.map((f) => ({
15571557
name: f.name,
15581558
path: f.path,

components/operator/internal/handlers/sessions.go

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,14 +1552,26 @@ func reconcileSpecReposWithPatch(sessionNamespace, sessionName string, spec map[
15521552
}
15531553
}
15541554

1555+
// Fetch actual current branch info from runner
1556+
actualBranches := fetchActualBranchesFromRunner(runnerBaseURL)
1557+
15551558
// Update status to reflect the reconciled state (via statusPatch)
15561559
reconciled := make([]interface{}, 0, len(specRepos))
15571560
for _, repo := range specRepos {
1561+
repoName := deriveRepoNameFromURL(repo["url"])
1562+
1563+
// Get actual current branch from runner if available
1564+
currentBranch := repo["branch"] // Default to spec branch
1565+
if actualInfo, found := actualBranches[repoName]; found {
1566+
currentBranch = actualInfo.CurrentBranch
1567+
}
1568+
15581569
reconciled = append(reconciled, map[string]interface{}{
1559-
"url": repo["url"],
1560-
"branch": repo["branch"],
1561-
"status": "Ready",
1562-
"clonedAt": time.Now().UTC().Format(time.RFC3339),
1570+
"url": repo["url"],
1571+
"branch": repo["branch"], // Intended branch from spec
1572+
"currentBranch": currentBranch, // Actual checked-out branch
1573+
"status": "Ready",
1574+
"clonedAt": time.Now().UTC().Format(time.RFC3339),
15631575
})
15641576
}
15651577
statusPatch.SetField("reconciledRepos", reconciled)
@@ -2230,6 +2242,66 @@ func deleteAmbientLangfuseSecret(ctx context.Context, namespace string) error {
22302242
// LEGACY: getBackendAPIURL removed - AG-UI migration
22312243
// Workflow and repo changes now call runner's REST endpoints directly
22322244

2245+
// RepoStatusInfo holds the current branch information for a repository
2246+
type RepoStatusInfo struct {
2247+
Name string
2248+
CurrentBranch string
2249+
URL string
2250+
}
2251+
2252+
// fetchActualBranchesFromRunner queries the runner's /repos/status endpoint
2253+
// to get the actual checked-out branch for each repository
2254+
func fetchActualBranchesFromRunner(runnerBaseURL string) map[string]RepoStatusInfo {
2255+
result := make(map[string]RepoStatusInfo)
2256+
2257+
// Call runner's /repos/status endpoint
2258+
req, err := http.NewRequest("GET", runnerBaseURL+"/repos/status", nil)
2259+
if err != nil {
2260+
log.Printf("[RepoStatus] Failed to create request: %v", err)
2261+
return result
2262+
}
2263+
2264+
client := &http.Client{Timeout: 5 * time.Second}
2265+
resp, err := client.Do(req)
2266+
if err != nil {
2267+
log.Printf("[RepoStatus] Failed to fetch from runner: %v", err)
2268+
return result
2269+
}
2270+
defer resp.Body.Close()
2271+
2272+
if resp.StatusCode != http.StatusOK {
2273+
log.Printf("[RepoStatus] Runner returned %d for /repos/status", resp.StatusCode)
2274+
return result
2275+
}
2276+
2277+
// Parse response
2278+
var statusResp struct {
2279+
Repos []struct {
2280+
Name string `json:"name"`
2281+
CurrentBranch string `json:"currentBranch"`
2282+
URL string `json:"url"`
2283+
} `json:"repos"`
2284+
}
2285+
2286+
body, _ := io.ReadAll(resp.Body)
2287+
if err := json.Unmarshal(body, &statusResp); err != nil {
2288+
log.Printf("[RepoStatus] Failed to parse response: %v", err)
2289+
return result
2290+
}
2291+
2292+
// Index by repo name for quick lookup
2293+
for _, repo := range statusResp.Repos {
2294+
result[repo.Name] = RepoStatusInfo{
2295+
Name: repo.Name,
2296+
CurrentBranch: repo.CurrentBranch,
2297+
URL: repo.URL,
2298+
}
2299+
}
2300+
2301+
log.Printf("[RepoStatus] Fetched status for %d repos", len(result))
2302+
return result
2303+
}
2304+
22332305
// deriveRepoNameFromURL extracts the repository name from a git URL
22342306
func deriveRepoNameFromURL(repoURL string) string {
22352307
// Remove .git suffix

components/runners/claude-code-runner/main.py

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -575,11 +575,41 @@ async def clone_repo_at_runtime(git_url: str, branch: str, name: str) -> tuple[b
575575
repo_final = repos_dir / name
576576

577577
logger.info(f"Cloning repo '{name}' from {git_url}@{branch}")
578-
579-
# Skip if already cloned
578+
579+
# If repo already exists, check out the requested branch instead of skipping
580580
if repo_final.exists():
581-
logger.info(f"Repo '{name}' already exists at {repo_final}, skipping clone")
582-
return True, str(repo_final)
581+
logger.info(f"Repo '{name}' already exists at {repo_final}, checking out branch '{branch}'")
582+
try:
583+
# Fetch the latest changes first (in case branch doesn't exist locally)
584+
process = await asyncio.create_subprocess_exec(
585+
"git", "-C", str(repo_final), "fetch", "origin", branch,
586+
stdout=asyncio.subprocess.PIPE,
587+
stderr=asyncio.subprocess.PIPE
588+
)
589+
stdout, stderr = await process.communicate()
590+
591+
if process.returncode != 0:
592+
logger.warning(f"Failed to fetch branch {branch}: {stderr.decode()}")
593+
594+
# Checkout the requested branch
595+
process = await asyncio.create_subprocess_exec(
596+
"git", "-C", str(repo_final), "checkout", branch,
597+
stdout=asyncio.subprocess.PIPE,
598+
stderr=asyncio.subprocess.PIPE
599+
)
600+
stdout, stderr = await process.communicate()
601+
602+
if process.returncode != 0:
603+
error_msg = stderr.decode()
604+
logger.error(f"Failed to checkout branch {branch}: {error_msg}")
605+
return False, ""
606+
607+
logger.info(f"Successfully checked out branch '{branch}' in {repo_final}")
608+
return True, str(repo_final)
609+
610+
except Exception as e:
611+
logger.error(f"Error checking out branch: {e}")
612+
return False, ""
583613

584614
# Create temp directory for clone
585615
temp_dir = Path(tempfile.mkdtemp(prefix="repo-clone-"))
@@ -819,39 +849,103 @@ async def trigger_repo_added_notification(repo_name: str, repo_url: str):
819849
async def remove_repo(request: Request):
820850
"""
821851
Remove repository - triggers Claude SDK client restart.
822-
852+
823853
Accepts: {"name": "..."}
824854
"""
825855
global _adapter_initialized
826-
856+
827857
if not adapter:
828858
raise HTTPException(status_code=503, detail="Adapter not initialized")
829-
859+
830860
body = await request.json()
831861
repo_name = body.get("name", "")
832862
logger.info(f"Remove repo request: {repo_name}")
833-
863+
834864
# Update REPOS_JSON env var
835865
repos_json = os.getenv("REPOS_JSON", "[]")
836866
try:
837867
repos = json.loads(repos_json) if repos_json else []
838868
except:
839869
repos = []
840-
870+
841871
# Remove repo by name
842872
repos = [r for r in repos if r.get("name") != repo_name]
843-
873+
844874
os.environ["REPOS_JSON"] = json.dumps(repos)
845-
875+
846876
# Reset adapter state
847877
_adapter_initialized = False
848878
adapter._first_run = True
849-
879+
850880
logger.info(f"Repo removed, adapter will reinitialize on next run")
851-
881+
852882
return {"message": "Repository removed"}
853883

854884

885+
@app.get("/repos/status")
886+
async def get_repos_status():
887+
"""
888+
Get status of all checked-out repositories including actual current branch.
889+
890+
Returns:
891+
List of repositories with their current branch information
892+
"""
893+
from pathlib import Path
894+
895+
workspace_path = os.getenv("WORKSPACE_PATH", "/workspace")
896+
repos_dir = Path(workspace_path) / "repos"
897+
898+
if not repos_dir.exists():
899+
return {"repos": []}
900+
901+
repos_status = []
902+
903+
# Iterate through all directories in repos/
904+
for repo_path in repos_dir.iterdir():
905+
if not repo_path.is_dir():
906+
continue
907+
908+
# Check if it's a git repository
909+
git_dir = repo_path / ".git"
910+
if not git_dir.exists():
911+
continue
912+
913+
try:
914+
# Get current branch name
915+
process = await asyncio.create_subprocess_exec(
916+
"git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD",
917+
stdout=asyncio.subprocess.PIPE,
918+
stderr=asyncio.subprocess.PIPE
919+
)
920+
stdout, stderr = await process.communicate()
921+
922+
if process.returncode == 0:
923+
current_branch = stdout.decode().strip()
924+
925+
# Get remote URL
926+
process = await asyncio.create_subprocess_exec(
927+
"git", "-C", str(repo_path), "config", "--get", "remote.origin.url",
928+
stdout=asyncio.subprocess.PIPE,
929+
stderr=asyncio.subprocess.PIPE
930+
)
931+
stdout, stderr = await process.communicate()
932+
remote_url = stdout.decode().strip() if process.returncode == 0 else ""
933+
934+
repos_status.append({
935+
"name": repo_path.name,
936+
"path": str(repo_path),
937+
"currentBranch": current_branch,
938+
"url": remote_url
939+
})
940+
else:
941+
logger.warning(f"Failed to get branch for {repo_path.name}: {stderr.decode()}")
942+
943+
except Exception as e:
944+
logger.error(f"Error getting status for {repo_path.name}: {e}")
945+
946+
return {"repos": repos_status}
947+
948+
855949
@app.get("/health")
856950
async def health():
857951
"""Health check endpoint."""

0 commit comments

Comments
 (0)