From 0104831e2773a24ffc56abf0a095010d4b5a8be6 Mon Sep 17 00:00:00 2001 From: sallyom Date: Tue, 16 Dec 2025 21:20:39 -0500 Subject: [PATCH] feat: Improve git repository context UX (Issue #376) Enhances the user experience for adding git repositories as context. Frontend UX Improvements: 1. Enhanced Add Context Modal - Repository options with base/feature branch configuration, protected branch detection, sync configuration 2. Improved Repository Dialog - Branch configuration with branch fetching, protected branch warnings, sync support 3. Enhanced Repository Display - Visual badges, color-coded branch pills, improved layout Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: sallyom --- components/backend/handlers/helpers.go | 125 +++++++ components/backend/handlers/sessions.go | 124 ++++-- components/backend/types/session.go | 6 +- components/frontend/package-lock.json | 1 + components/frontend/package.json | 1 + .../accordions/repositories-accordion.tsx | 85 +++-- .../components/modals/add-context-modal.tsx | 191 ++++++++-- .../[name]/sessions/[sessionName]/page.tsx | 18 +- .../app/projects/[name]/sessions/new/page.tsx | 1 - .../[name]/sessions/new/repository-dialog.tsx | 173 ++++++--- .../src/components/ui/collapsible.tsx | 33 ++ .../runners/claude-code-runner/wrapper.py | 224 ++++++++++- docs/user-guide/git-repository-options.md | 352 ++++++++++++++++++ mkdocs.yml | 1 + 14 files changed, 1200 insertions(+), 135 deletions(-) create mode 100644 components/frontend/src/components/ui/collapsible.tsx create mode 100644 docs/user-guide/git-repository-options.md diff --git a/components/backend/handlers/helpers.go b/components/backend/handlers/helpers.go index c251e2504..cb452dfbb 100644 --- a/components/backend/handlers/helpers.go +++ b/components/backend/handlers/helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "math" + "strings" "time" authv1 "k8s.io/api/authorization/v1" @@ -74,3 +75,127 @@ func ValidateSecretAccess(ctx context.Context, k8sClient kubernetes.Interface, n return nil } + +// isProtectedBranch checks if a branch name is commonly protected +func isProtectedBranch(branch string) bool { + if branch == "" { + return false + } + protectedNames := []string{ + "main", "master", "develop", "dev", "development", + "production", "prod", "staging", "stage", "qa", "test", "stable", + } + branchLower := strings.ToLower(strings.TrimSpace(branch)) + for _, protected := range protectedNames { + if branchLower == protected { + return true + } + } + return false +} + +// isValidGitBranchName validates a user-supplied branch name against git branch naming rules +// and shell injection risks. Returns true if the branch name is safe to use. +// Security: This prevents command injection by rejecting shell metacharacters. +func isValidGitBranchName(branch string) bool { + if branch == "" { + return false + } + + // Reject if longer than 255 characters (git limit) + if len(branch) > 255 { + return false + } + + // Reject shell metacharacters that could lead to command injection + // CRITICAL: These characters could break out of git commands in wrapper.py + shellMetachars := []rune{';', '&', '|', '$', '`', '\\', '\n', '\r', '\t', '<', '>', '(', ')', '{', '}', '\'', '"', ' '} + for _, char := range shellMetachars { + if strings.ContainsRune(branch, char) { + return false + } + } + + // Reject git control characters and patterns + gitControlChars := []string{"..", "~", "^", ":", "?", "*", "[", "@{"} + for _, pattern := range gitControlChars { + if strings.Contains(branch, pattern) { + return false + } + } + + // Cannot start or end with dot or slash + if strings.HasPrefix(branch, ".") || strings.HasSuffix(branch, ".") || + strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") { + return false + } + + // Cannot contain consecutive slashes + if strings.Contains(branch, "//") { + return false + } + + // Must contain only valid characters: alphanumeric, dash, underscore, slash, dot + for _, r := range branch { + valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' || r == '.' + if !valid { + return false + } + } + + return true +} + +// sanitizeBranchName converts a display name to a valid git branch name +func sanitizeBranchName(name string) string { + // Replace spaces with hyphens + name = strings.ReplaceAll(name, " ", "-") + // Remove or replace invalid characters for git branch names + // Valid: alphanumeric, dash, underscore, slash, dot (but not at start/end) + var result strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' { + result.WriteRune(r) + } + } + sanitized := result.String() + // Trim leading/trailing dashes or slashes + sanitized = strings.Trim(sanitized, "-/") + return sanitized +} + +// generateWorkingBranch generates a working branch name based on the session and repo context +// Returns the branch name to use for the session +func generateWorkingBranch(sessionDisplayName, sessionID, requestedBranch string, allowProtectedWork bool) string { + // If user explicitly requested a branch + if requestedBranch != "" { + // Check if it's protected and user hasn't allowed working on it + if isProtectedBranch(requestedBranch) && !allowProtectedWork { + // Create a temporary working branch to protect the base branch + sessionIDShort := sessionID + if len(sessionID) > 8 { + sessionIDShort = sessionID[:8] + } + return fmt.Sprintf("work/%s/%s", requestedBranch, sessionIDShort) + } + // User requested non-protected branch or explicitly allowed protected work + return requestedBranch + } + + // No branch requested - generate from session name + if sessionDisplayName != "" { + sanitized := sanitizeBranchName(sessionDisplayName) + if sanitized != "" { + return sanitized + } + } + + // Fallback: use session ID + sessionIDShort := sessionID + if len(sessionID) > 8 { + sessionIDShort = sessionID[:8] + } + return fmt.Sprintf("session-%s", sessionIDShort) +} diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 111d97bb3..5f79c874e 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -599,6 +599,7 @@ func CreateSession(c *gin.Context) { if metadata["annotations"] == nil { metadata["annotations"] = make(map[string]interface{}) } + // Direct map access for plain maps (no need for unstructured helpers) annotations := metadata["annotations"].(map[string]interface{}) annotations["vteam.ambient-code/parent-session-id"] = req.ParentSessionID log.Printf("Creating continuation session from parent %s (operator will handle temp pod cleanup)", req.ParentSessionID) @@ -606,34 +607,69 @@ func CreateSession(c *gin.Context) { } if len(envVars) > 0 { + // Direct map access for plain maps (no need for unstructured helpers) spec := session["spec"].(map[string]interface{}) spec["environmentVariables"] = envVars } // Interactive flag if req.Interactive != nil { - session["spec"].(map[string]interface{})["interactive"] = *req.Interactive + // Direct map access for plain maps (no need for unstructured helpers) + spec := session["spec"].(map[string]interface{}) + spec["interactive"] = *req.Interactive } // AutoPushOnComplete flag if req.AutoPushOnComplete != nil { - session["spec"].(map[string]interface{})["autoPushOnComplete"] = *req.AutoPushOnComplete + // Direct map access for plain maps (no need for unstructured helpers) + spec := session["spec"].(map[string]interface{}) + spec["autoPushOnComplete"] = *req.AutoPushOnComplete } // Set multi-repo configuration on spec (simplified format) - { + // Generate working branch names upfront based on session context + if len(req.Repos) > 0 { + // Direct map access for plain maps (no need for unstructured helpers) spec := session["spec"].(map[string]interface{}) - if len(req.Repos) > 0 { - arr := make([]map[string]interface{}, 0, len(req.Repos)) - for _, r := range req.Repos { - m := map[string]interface{}{"url": r.URL} - if r.Branch != nil { - m["branch"] = *r.Branch - } - arr = append(arr, m) + arr := make([]map[string]interface{}, 0, len(req.Repos)) + for _, r := range req.Repos { + // Determine the working branch to use + var requestedBranch string + if r.WorkingBranch != nil { + requestedBranch = strings.TrimSpace(*r.WorkingBranch) + } else if r.Branch != nil { + requestedBranch = strings.TrimSpace(*r.Branch) + } + + // Validate user-supplied branch names to prevent command injection + if requestedBranch != "" && !isValidGitBranchName(requestedBranch) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid branch name format: %s", requestedBranch)}) + return } - spec["repos"] = arr + + allowProtected := false + if r.AllowProtectedWork != nil { + allowProtected = *r.AllowProtectedWork + } + + // Generate the actual branch name that will be used + workingBranch := generateWorkingBranch( + req.DisplayName, + name, // session name (unique ID) + requestedBranch, + allowProtected, + ) + + // Wrap in 'input' object to match runner expectations + m := map[string]interface{}{ + "input": map[string]interface{}{ + "url": r.URL, + "branch": workingBranch, + }, + } + arr = append(arr, m) } + spec["repos"] = arr } // Add userContext derived from authenticated caller; ignore client-supplied userId @@ -661,7 +697,9 @@ func CreateSession(c *gin.Context) { if len(groups) == 0 && req.UserContext != nil { groups = req.UserContext.Groups } - session["spec"].(map[string]interface{})["userContext"] = map[string]interface{}{ + // Direct map access for plain maps (no need for unstructured helpers) + spec := session["spec"].(map[string]interface{}) + spec["userContext"] = map[string]interface{}{ "userId": uid, "displayName": displayName, "groups": groups, @@ -1406,8 +1444,14 @@ func AddRepo(c *gin.Context) { } var req struct { - URL string `json:"url" binding:"required"` - Branch string `json:"branch"` + URL string `json:"url" binding:"required"` + Branch string `json:"branch"` + WorkingBranch string `json:"workingBranch"` + AllowProtectedWork bool `json:"allowProtectedWork"` + Sync *struct { + URL string `json:"url"` + Branch string `json:"branch"` + } `json:"sync"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -1415,10 +1459,6 @@ func AddRepo(c *gin.Context) { return } - if req.Branch == "" { - req.Branch = "main" - } - gvr := GetAgenticSessionV1Alpha1Resource() item, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) if err != nil { @@ -1447,10 +1487,52 @@ func AddRepo(c *gin.Context) { repos = []interface{}{} } + // Determine the requested branch + requestedBranch := req.WorkingBranch + if requestedBranch == "" { + requestedBranch = req.Branch + } + + // Validate user-supplied branch names to prevent command injection + if requestedBranch != "" && !isValidGitBranchName(requestedBranch) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid branch name format: %s", requestedBranch)}) + return + } + + // Get session display name for branch generation + displayName := "" + if dn, ok := spec["displayName"].(string); ok { + displayName = dn + } + + // Generate the actual working branch name + workingBranch := generateWorkingBranch( + displayName, + sessionName, + requestedBranch, + req.AllowProtectedWork, + ) + + // Wrap in 'input' object to match runner expectations newRepo := map[string]interface{}{ - "url": req.URL, - "branch": req.Branch, + "input": map[string]interface{}{ + "url": req.URL, + "branch": workingBranch, + }, } + + // Add sync configuration if provided + if req.Sync != nil && strings.TrimSpace(req.Sync.URL) != "" { + syncBranch := strings.TrimSpace(req.Sync.Branch) + if syncBranch == "" { + syncBranch = "main" + } + newRepo["sync"] = map[string]interface{}{ + "url": strings.TrimSpace(req.Sync.URL), + "branch": syncBranch, + } + } + repos = append(repos, newRepo) spec["repos"] = repos diff --git a/components/backend/types/session.go b/components/backend/types/session.go index 1ee23676b..9aa152814 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -28,8 +28,10 @@ type AgenticSessionSpec struct { // SimpleRepo represents a simplified repository configuration type SimpleRepo struct { - URL string `json:"url"` - Branch *string `json:"branch,omitempty"` + URL string `json:"url"` + Branch *string `json:"branch,omitempty"` + WorkingBranch *string `json:"workingBranch,omitempty"` // User-requested working branch (input only) + AllowProtectedWork *bool `json:"allowProtectedWork,omitempty"` // Allow work directly on protected branches (input only) } type AgenticSessionStatus struct { diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 82f2f638f..72b7d22a1 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", diff --git a/components/frontend/package.json b/components/frontend/package.json index 9deafaa57..384cd3f00 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx index 5724a08a7..5d12b04bf 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx @@ -1,14 +1,19 @@ "use client"; import { useState } from "react"; -import { GitBranch, X, Link, Loader2, CloudUpload } from "lucide-react"; +import { GitBranch, X, Link, Loader2, CloudUpload, GitMerge, Shield } from "lucide-react"; import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; type Repository = { url: string; - branch?: string; + branch?: string; // The actual working branch (generated by backend) + allowProtectedWork?: boolean; + sync?: { + url: string; + branch?: string; + }; }; type UploadedFile = { @@ -78,7 +83,7 @@ export function RepositoriesAccordion({

Add additional context to improve AI responses.

- + {/* Context Items List (Repos + Uploaded Files) */} {totalContextItems === 0 ? (
@@ -98,26 +103,63 @@ export function RepositoriesAccordion({ const repoName = repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`; const isRemoving = removingRepo === repoName; + // Get the actual working branch from spec (generated by backend) + const workingBranch = repo.branch || 'main'; + const hasSync = !!repo.sync?.url; + const allowProtected = repo.allowProtectedWork; + return ( -
- -
-
{repoName}
-
{repo.url}
+
+
+ +
+
+
{repoName}
+ {hasSync && ( + + + Synced + + )} + {allowProtected && ( + + + Protected + + )} +
+
{repo.url}
+ + {/* Branch information */} +
+
+ Branch: + {workingBranch} +
+ {hasSync && ( +
+ + + {repo.sync?.url.split('/').pop()?.replace('.git', '')} + +
+ )} +
+
+
-
); })} @@ -166,4 +208,3 @@ export function RepositoriesAccordion({ ); } - diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx index b0fb5f4b7..021a5833e 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx @@ -1,18 +1,30 @@ "use client"; import { useState } from "react"; -import { Loader2, Info, Upload } from "lucide-react"; +import { Loader2, Info, Upload, ChevronDown, ChevronRight } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; + +export type RepositoryConfig = { + url: string; + workingBranch?: string; + allowProtectedWork?: boolean; + sync?: { + url: string; + branch?: string; + }; +}; type AddContextModalProps = { open: boolean; onOpenChange: (open: boolean) => void; - onAddRepository: (url: string, branch: string) => Promise; + onAddRepository: (config: RepositoryConfig) => Promise; onUploadFile?: () => void; isLoading?: boolean; }; @@ -25,34 +37,76 @@ export function AddContextModal({ isLoading = false, }: AddContextModalProps) { const [contextUrl, setContextUrl] = useState(""); - const [contextBranch, setContextBranch] = useState("main"); + const [workingBranch, setWorkingBranch] = useState(""); + const [allowProtectedWork, setAllowProtectedWork] = useState(false); + const [syncExpanded, setSyncExpanded] = useState(false); + const [syncUrl, setSyncUrl] = useState(""); + const [syncBranch, setSyncBranch] = useState("main"); + + // Check if working branch is likely protected + const isProtectedBranch = (branch: string): boolean => { + const protectedNames = ['main', 'master', 'develop', 'dev', 'development', + 'production', 'prod', 'staging', 'stage', 'qa', 'test', 'stable']; + return protectedNames.includes(branch.toLowerCase().trim()); + }; + + const showProtectedWarning = workingBranch && isProtectedBranch(workingBranch); const handleSubmit = async () => { if (!contextUrl.trim()) return; - - await onAddRepository(contextUrl.trim(), contextBranch.trim() || 'main'); - + + const config: RepositoryConfig = { + url: contextUrl.trim(), + }; + + // Add working branch if specified + if (workingBranch.trim()) { + config.workingBranch = workingBranch.trim(); + } + + // Add protected work flag if specified + if (allowProtectedWork) { + config.allowProtectedWork = true; + } + + // Add sync configuration if provided + if (syncUrl.trim()) { + config.sync = { + url: syncUrl.trim(), + branch: syncBranch.trim() || 'main', + }; + } + + await onAddRepository(config); + // Reset form + resetForm(); + }; + + const resetForm = () => { setContextUrl(""); - setContextBranch("main"); + setWorkingBranch(""); + setAllowProtectedWork(false); + setSyncExpanded(false); + setSyncUrl(""); + setSyncBranch("main"); }; const handleCancel = () => { - setContextUrl(""); - setContextBranch("main"); + resetForm(); onOpenChange(false); }; return ( - + Add Context - Add additional context to improve AI responses. + Add additional repository context with advanced git workflow options. - +
@@ -61,8 +115,9 @@ export function AddContextModal({ + {/* Repository URL */}
- + setContextUrl(e.target.value)} />

- Currently supports GitHub repositories for code context + Currently supports GitHub and GitLab repositories

-
- - setContextBranch(e.target.value)} - /> -

- Leave empty to use the default branch -

+ {/* Repository Options Section */} +
+

Repository Options

+ + {/* Working Branch */} +
+ + setWorkingBranch(e.target.value)} + /> +

+ The branch to work on. If left empty, a branch will be created based on the session name (e.g., 'Fix-login-bug'). If specified and it exists remotely, it will be checked out. If it doesn't exist, it will be created from the repository's default branch. +

+
+ + {/* Protected Branch Warning & Checkbox */} + {showProtectedWarning && ( + + + +

+ '{workingBranch}' appears to be a protected branch. +

+
+ setAllowProtectedWork(checked === true)} + /> + +
+

+ {allowProtectedWork + ? "âš ī¸ Any changes will be made directly to this protected branch" + : "A temporary working branch will be created automatically to preserve this branch"} +

+
+
+ )} + + {/* Sync Configuration (Collapsible) */} + + + + + +
+ + setSyncUrl(e.target.value)} + /> +

+ Remote repository to sync from (useful for forks or keeping up-to-date with upstream) +

+
+ +
+ + setSyncBranch(e.target.value)} + /> +

+ Branch to sync from remote. Your working branch will be rebased onto this. +

+
+ + + + + When configured, the system will run: git fetch upstream && git rebase upstream/main before starting work. + + +
+
{onUploadFile && ( @@ -117,6 +254,7 @@ export function AddContextModal({ type="button" variant="outline" onClick={handleCancel} + disabled={isLoading} > Cancel @@ -131,7 +269,7 @@ export function AddContextModal({ Adding... ) : ( - 'Add' + 'Add Context' )} @@ -139,4 +277,3 @@ export function AddContextModal({
); } - diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index a6a46aee5..f716676c4 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -215,7 +215,12 @@ export default function ProjectSessionDetailPage({ // Repo management mutations const addRepoMutation = useMutation({ - mutationFn: async (repo: { url: string; branch: string }) => { + mutationFn: async (repo: { + url: string; + workingBranch?: string; + allowProtectedWork?: boolean; + sync?: { url: string; branch?: string }; + }) => { setRepoChanging(true); const response = await fetch( `/api/projects/${projectName}/agentic-sessions/${sessionName}/repos`, @@ -1465,8 +1470,15 @@ export default function ProjectSessionDetailPage({ { - await addRepoMutation.mutateAsync({ url, branch }); + onAddRepository={async (config) => { + // Send workingBranch, allowProtectedWork, and sync to backend + // Backend will generate the actual branch name to use + await addRepoMutation.mutateAsync({ + url: config.url, + workingBranch: config.workingBranch, + allowProtectedWork: config.allowProtectedWork, + sync: config.sync, + }); setContextModalOpen(false); }} onUploadFile={() => setUploadModalOpen(true)} diff --git a/components/frontend/src/app/projects/[name]/sessions/new/page.tsx b/components/frontend/src/app/projects/[name]/sessions/new/page.tsx index 5ec1ad2b2..b7be028a9 100644 --- a/components/frontend/src/app/projects/[name]/sessions/new/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/new/page.tsx @@ -242,7 +242,6 @@ export default function NewProjectSessionPage({ params }: { params: Promise<{ na } }} isEditing={editingRepoIndex !== null} - projectName={projectName} /> {/* Runner behavior */} diff --git a/components/frontend/src/app/projects/[name]/sessions/new/repository-dialog.tsx b/components/frontend/src/app/projects/[name]/sessions/new/repository-dialog.tsx index ba3c3ac31..c32c0be02 100644 --- a/components/frontend/src/app/projects/[name]/sessions/new/repository-dialog.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/new/repository-dialog.tsx @@ -1,14 +1,24 @@ "use client"; +import { useState } from "react"; +import { Info, ChevronDown, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { useRepoBranches } from "@/services/queries"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; type Repo = { url: string; branch?: string; + workingBranch?: string; + allowProtectedWork?: boolean; + sync?: { + url: string; + branch?: string; + }; }; type RepositoryDialogProps = { @@ -18,7 +28,6 @@ type RepositoryDialogProps = { onRepoChange: (repo: Repo) => void; onSave: () => void; isEditing: boolean; - projectName: string; }; export function RepositoryDialog({ @@ -28,61 +37,141 @@ export function RepositoryDialog({ onRepoChange, onSave, isEditing, - projectName, }: RepositoryDialogProps) { - // Fetch branches for the repository - const { data: branchesData, isLoading: branchesLoading } = useRepoBranches( - projectName, - repo.url, - { enabled: !!repo.url && open } - ); + const [syncExpanded, setSyncExpanded] = useState(false); + + // Check if working branch is likely protected + const isProtectedBranch = (branch: string): boolean => { + const protectedNames = ['main', 'master', 'develop', 'dev', 'development', + 'production', 'prod', 'staging', 'stage', 'qa', 'test', 'stable']; + return protectedNames.includes(branch.toLowerCase().trim()); + }; + + const workingBranch = repo.workingBranch || ''; + const showProtectedWarning = workingBranch && isProtectedBranch(workingBranch); return ( - + {isEditing ? "Edit Repository" : "Add Repository"} - Configure repository URL and branch + Configure repository with advanced git workflow options
- + onRepoChange({ ...repo, url: e.target.value })} />
-
- - - {!repo.url && ( -

Enter repository URL first to load branches

+ + {/* Repository Options Section */} +
+

Branch Configuration

+ +
+ + onRepoChange({ ...repo, workingBranch: e.target.value })} + /> +

+ The branch to work on. If left empty, a branch will be created based on the session name (e.g., 'Fix-login-bug'). If specified and it exists remotely, it will be checked out. If it doesn't exist, it will be created from the repository's default branch. +

+
+ + {/* Protected Branch Warning & Checkbox */} + {showProtectedWarning && ( + + + +

+ '{workingBranch}' appears to be a protected branch. +

+
+ onRepoChange({ ...repo, allowProtectedWork: checked === true })} + /> + +
+

+ {repo.allowProtectedWork + ? "âš ī¸ Any changes will be made directly to this protected branch" + : "A temporary working branch will be created automatically to preserve this branch"} +

+
+
)} + + {/* Sync Configuration (Collapsible) */} + + + + + +
+ + onRepoChange({ + ...repo, + sync: { + url: e.target.value, + branch: repo.sync?.branch || 'main' + } + })} + /> +

+ Remote repository to sync from (useful for forks or keeping up-to-date with upstream) +

+
+ +
+ + onRepoChange({ + ...repo, + sync: { + url: repo.sync?.url || '', + branch: e.target.value + } + })} + /> +

+ Branch to sync from remote. Your working branch will be rebased onto this. +

+
+ + + + + When configured, the system will run: git fetch upstream && git rebase upstream/main before starting work. + + +
+
diff --git a/components/frontend/src/components/ui/collapsible.tsx b/components/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 000000000..ae9fad04a --- /dev/null +++ b/components/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/components/runners/claude-code-runner/wrapper.py b/components/runners/claude-code-runner/wrapper.py index e94f9e02c..54afebbef 100644 --- a/components/runners/claude-code-runner/wrapper.py +++ b/components/runners/claude-code-runner/wrapper.py @@ -109,6 +109,57 @@ def _sanitize_user_context(user_id: str, user_name: str) -> tuple[str, str]: return user_id, user_name + @staticmethod + def _is_valid_git_branch_name(branch: str) -> bool: + """Validate git branch name to prevent command injection. + + Defense-in-depth validation matching backend's isValidGitBranchName(). + Even though backend validates, runner must also validate to prevent: + - Bypass if backend validation is incomplete + - Future code changes that bypass backend + - Direct runner invocation in testing/debugging + + Args: + branch: Branch name to validate + + Returns: + True if branch name is safe, False otherwise + """ + if not branch or not isinstance(branch, str): + return False + + # Reject if longer than 255 characters (git limit) + if len(branch) > 255: + return False + + # CRITICAL: Reject shell metacharacters that could lead to command injection + # These characters could break out of git commands + shell_metachars = {';', '&', '|', '$', '`', '\\', '\n', '\r', '\t', '<', '>', + '(', ')', '{', '}', "'", '"', ' '} + if any(char in branch for char in shell_metachars): + return False + + # Reject git control characters and patterns + git_control_patterns = ["..", "~", "^", ":", "?", "*", "[", "@{"] + if any(pattern in branch for pattern in git_control_patterns): + return False + + # Cannot start or end with dot or slash + if branch.startswith('.') or branch.endswith('.') or \ + branch.startswith('/') or branch.endswith('/'): + return False + + # Cannot contain consecutive slashes + if '//' in branch: + return False + + # Must contain only valid characters: alphanumeric, dash, underscore, slash, dot + for char in branch: + if not (char.isalnum() or char in '-_/.'): + return False + + return True + async def run(self): """Run the Claude Code CLI session.""" try: @@ -826,6 +877,101 @@ async def _setup_google_credentials(self): except Exception as e: logging.error("Failed to copy Google OAuth credentials: %s", e) + async def _clone_repository_with_error_handling( + self, + url: str, + target_dir: Path, + token: str = None, + repo_name: str = "repository", + workspace: Path = None, + working_branch: str = "", + allow_protected: bool = False + ) -> bool: + """ + Clone a repository with graceful error handling. + + The working_branch parameter is now generated by the backend API and passed in the spec. + This method simply clones the repository and checks out/creates the specified branch. + + Workflow: + 1. Clone repository (default branch) + 2. If working_branch specified: + - Try to checkout if exists remotely + - Create from default branch if doesn't exist + + Returns: + True if clone succeeded, False otherwise + """ + clone_url = self._url_with_token(url, token) if token else url + + try: + # Clone repository with default branch + await self._send_log(f"đŸ“Ĩ Cloning {repo_name}...") + logging.info(f"Cloning {repo_name} from {url}") + + await self._run_cmd( + ["git", "clone", clone_url, str(target_dir)], + cwd=str(workspace or target_dir.parent) + ) + + # Get the default branch name + default_branch_result = await self._run_cmd( + ["git", "branch", "--show-current"], + cwd=str(target_dir) + ) + default_branch = default_branch_result.get('stdout', '').strip() or 'main' + logging.info(f"Repository default branch: {default_branch}") + + # If working branch specified, checkout or create it + if working_branch: + # Validate branch name to prevent command injection (defense-in-depth) + if not self._is_valid_git_branch_name(working_branch): + error_msg = f"Invalid or potentially unsafe branch name: {working_branch}" + logging.error(f"Branch name validation failed for {repo_name}: {error_msg}") + await self._send_log(f"✗ {error_msg}") + raise RuntimeError(error_msg) + + # Try to checkout the working branch (may exist remotely or locally) + try: + # First try remote branch + await self._run_cmd(["git", "checkout", working_branch], cwd=str(target_dir)) + await self._send_log(f"✓ Checked out branch '{working_branch}'") + logging.info(f"Checked out existing branch '{working_branch}' in {repo_name}") + except RuntimeError: + # Branch doesn't exist - create it from default branch + await self._run_cmd(["git", "checkout", "-b", working_branch], cwd=str(target_dir)) + await self._send_log(f"✓ Created branch '{working_branch}' from '{default_branch}'") + logging.info(f"Created new branch '{working_branch}' from '{default_branch}' in {repo_name}") + else: + # No branch specified - stay on default branch + await self._send_log(f"✓ Using default branch '{default_branch}'") + logging.info(f"Using default branch '{default_branch}' in {repo_name}") + + # Update remote URL to persist token + await self._run_cmd( + ["git", "remote", "set-url", "origin", clone_url], + cwd=str(target_dir), + ignore_errors=True + ) + + logging.info(f"Successfully cloned and configured {repo_name}") + return True + + except RuntimeError as clone_err: + error_msg = str(clone_err).lower() + + # Handle authentication errors gracefully + if "authentication" in error_msg or "permission denied" in error_msg or "could not read" in error_msg: + await self._send_log(f"âš ī¸ Authentication failed for {repo_name} - continuing without this repository") + logging.warning(f"Authentication error cloning {repo_name}: {clone_err}") + return False + + # Other git errors + else: + await self._send_log(f"âš ī¸ Failed to clone {repo_name}: {clone_err} - continuing without this repository") + logging.warning(f"Clone error for {repo_name}: {clone_err}") + return False + async def _prepare_workspace(self): """Clone input repo/branch into workspace and configure git remotes.""" workspace = Path(self.context.workspace_path) @@ -848,7 +994,12 @@ async def _prepare_workspace(self): name = (r.get('name') or '').strip() inp = r.get('input') or {} url = (inp.get('url') or '').strip() - branch = (inp.get('branch') or '').strip() or 'main' + + # Get branch from spec (generated by backend API) + # The backend now handles branch name generation including protected branch logic + branch = (inp.get('branch') or '').strip() + sync_config = inp.get('sync') or {} + if not name or not url: continue repo_dir = workspace / name @@ -860,14 +1011,47 @@ async def _prepare_workspace(self): repo_exists = repo_dir.exists() and (repo_dir / ".git").exists() if not repo_exists: - # Clone fresh copy - await self._send_log(f"đŸ“Ĩ Cloning {name}...") - logging.info(f"Cloning {name} from {url} (branch: {branch})") - clone_url = self._url_with_token(url, token) if token else url - await self._run_cmd(["git", "clone", "--branch", branch, "--single-branch", clone_url, str(repo_dir)], cwd=str(workspace)) - # Update remote URL to persist token (git strips it from clone URL) - await self._run_cmd(["git", "remote", "set-url", "origin", clone_url], cwd=str(repo_dir), ignore_errors=True) - logging.info(f"Successfully cloned {name}") + # Clone fresh copy with graceful error handling + clone_success = await self._clone_repository_with_error_handling( + url=url, + target_dir=repo_dir, + token=token, + repo_name=name, + workspace=workspace, + working_branch=branch, + allow_protected=False # Not needed - backend handles this + ) + + if not clone_success: + logging.warning(f"Skipping {name} due to clone failure") + continue + + # Setup sync remote if configured + if sync_config.get('url'): + sync_url = sync_config['url'].strip() + sync_branch = (sync_config.get('branch') or '').strip() or 'main' + + # Validate sync branch name to prevent command injection + if not self._is_valid_git_branch_name(sync_branch): + error_msg = f"Invalid or potentially unsafe sync branch name: {sync_branch}" + logging.error(f"Sync branch validation failed for {name}: {error_msg}") + await self._send_log(f"✗ {error_msg}") + raise RuntimeError(error_msg) + + try: + sync_url_with_token = self._url_with_token(sync_url, token) if token else sync_url + await self._run_cmd(["git", "remote", "add", "upstream", sync_url_with_token], cwd=str(repo_dir)) + await self._send_log(f"✓ Added upstream remote for {name}: {sync_url}") + + # Fetch and rebase onto the sync branch + await self._run_cmd(["git", "fetch", "upstream", sync_branch], cwd=str(repo_dir)) + await self._run_cmd(["git", "rebase", f"upstream/{sync_branch}"], cwd=str(repo_dir)) + current_branch_result = await self._run_cmd(["git", "branch", "--show-current"], cwd=str(repo_dir)) + current_branch = current_branch_result.get('stdout', '').strip() + await self._send_log(f"✓ Rebased {current_branch} onto upstream/{sync_branch}") + logging.info(f"Rebased {current_branch} onto upstream/{sync_branch} in {name}") + except RuntimeError as e: + logging.warning(f"Failed to setup upstream remote for {name}: {e}") elif reusing_workspace: # Reusing workspace - preserve local changes from previous session await self._send_log(f"✓ Preserving {name} (continuation)") @@ -920,14 +1104,20 @@ async def _prepare_workspace(self): try: if not workspace_has_git: - # Clone fresh copy - await self._send_log("đŸ“Ĩ Cloning input repository...") - logging.info(f"Cloning from {input_repo} (branch: {input_branch})") - clone_url = self._url_with_token(input_repo, token) if token else input_repo - await self._run_cmd(["git", "clone", "--branch", input_branch, "--single-branch", clone_url, str(workspace)], cwd=str(workspace.parent)) - # Update remote URL to persist token (git strips it from clone URL) - await self._run_cmd(["git", "remote", "set-url", "origin", clone_url], cwd=str(workspace), ignore_errors=True) - logging.info("Successfully cloned repository") + # Clone fresh copy with graceful error handling + clone_success = await self._clone_repository_with_error_handling( + url=input_repo, + branch=input_branch, + target_dir=workspace, + token=token, + repo_name="repository", + workspace=workspace.parent, + allow_protected=False # For legacy single-repo, use default protection + ) + + if not clone_success: + logging.warning("Failed to clone input repository, session will continue without repository context") + return elif reusing_workspace: # Reusing workspace - preserve local changes from previous session await self._send_log("✓ Preserving workspace (continuation)") diff --git a/docs/user-guide/git-repository-options.md b/docs/user-guide/git-repository-options.md new file mode 100644 index 000000000..c5a554088 --- /dev/null +++ b/docs/user-guide/git-repository-options.md @@ -0,0 +1,352 @@ +# Git Repository Options Guide + +This guide explains the git repository configuration options available when adding context repositories to your Ambient Code sessions. + +## Overview + +When adding a repository as context, you can configure how the runner interacts with git branches and remotes. Understanding these options helps you set up the right workflow for your use case. + +**Key Feature**: The backend generates working branch names **before your session starts**, so you can see the exact branch that will be used right in the UI. This provides full transparency and helps you understand where your changes will go before the session executes. + +## Required vs Optional Fields + +**Only ONE field is strictly required:** + +- **Repository URL**: The git repository to clone (HTTPS or SSH format) + +**All other fields are optional** and provide advanced git workflow capabilities: + +- **Working Branch** (optional): The branch to work on (created if it doesn't exist) +- **Sync with Remote/Upstream** (optional): Keep your fork in sync with a remote or upstream repository + +## Configuration Options Explained + +### 1. Working Branch + +**What it is**: The branch you want to work on. The runner will check out this branch, creating it if it doesn't exist. + +**Default behavior**: +- If not specified, the backend automatically generates a branch name based on the session name (with spaces replaced by hyphens) +- The generated branch name is visible in the UI before the session starts +- If the branch exists remotely, it will be checked out +- If the branch doesn't exist remotely, it will be created from the repository's default branch + +**When to use**: +- You want to work on a specific existing branch (e.g., `develop`, `feature/my-work`) +- You want to create a new branch for your changes (e.g., `feature/add-login`) +- You're implementing a specific feature and want a descriptive branch name + +**Example scenarios**: + +```yaml +# Scenario 1: Work on existing 'develop' branch +Working Branch: develop +→ Runner clones repo and checks out 'develop' +→ All work happens on 'develop' +``` + +```yaml +# Scenario 2: Create new feature branch +Working Branch: feature/add-login +→ If 'feature/add-login' exists remotely: checkout that branch +→ If it doesn't exist: create it from the default branch +→ All changes go to 'feature/add-login' +``` + +```yaml +# Scenario 3: Not specified (auto-names from session) +Working Branch: (empty) +Session Name: "Add user authentication" +→ Backend generates branch name: 'Add-user-authentication' +→ UI shows "Branch: Add-user-authentication" before session starts +→ Runner creates/checks out 'Add-user-authentication' from default branch +→ All changes go to 'Add-user-authentication' +→ Makes it clear what the session worked on +``` + +**Protected branch detection**: If the working branch name matches common protected branch names (`main`, `master`, `develop`, `production`, etc.), the backend automatically detects this during session creation and generates a safe working branch name. The UI shows the actual branch that will be used (see Protected Branch Behavior below). + +--- + +### 2. Sync with Remote/Upstream (Fork Workflow) + +**What it is**: Configuration for keeping your fork synchronized with a remote or upstream (parent) repository. + +**Default behavior**: If not specified, no remote synchronization occurs. + +**When to use**: +- You're working with a forked repository +- You want to keep your branch up-to-date with the original project +- You need to rebase your changes onto the latest remote code + +**Configuration fields**: +- **Remote/Upstream Repository URL**: The original repository your fork came from (or any remote you want to sync with) +- **Remote/Upstream Branch**: The branch to sync from (typically `main`) + +**Example scenarios**: + +```yaml +# Forked repository workflow +Repository URL: https://github.com/yourname/project-fork +Working Branch: feature/my-contribution +Sync: + URL: https://github.com/original/project + Branch: main + +→ Runner clones your fork +→ Adds 'upstream' remote pointing to original repo +→ Checks out or creates 'feature/my-contribution' +→ Runs: git fetch upstream && git rebase upstream/main +→ Your working branch now includes latest upstream changes +``` + +**What happens during sync**: +1. Adds `upstream` remote pointing to the specified URL +2. Fetches the specified remote branch +3. Rebases your working branch onto `upstream/` +4. This ensures your changes are applied on top of the latest remote code + +--- + +## Protected Branch Behavior + +The backend automatically detects when you're working with a protected branch and generates a safe working branch name **before the session starts**. + +### Protected Branch Names + +The following branch names are considered protected: +- `main`, `master` +- `develop`, `dev`, `development` +- `production`, `prod` +- `staging`, `stage` +- `qa`, `test`, `stable` + +### Automatic Protection + +**When working branch is protected:** + +The backend generates a **safe working branch name** and shows it in the UI: +``` +Working Branch: main (protected) + +→ Backend detects 'main' is protected during session creation +→ Generates working branch: work/main/ +→ UI displays: "Branch: work/main/abc123def" +→ Runner clones repo and checks out/creates this working branch +→ All changes go to the generated working branch +→ Original 'main' remains untouched +``` + +**Key improvement**: You can see the exact branch name that will be used **before the session starts**, providing full transparency in the UI. + +This prevents accidental commits to protected branches while still allowing you to work with the latest code. + +### Overriding Protection + +If you genuinely need to work directly on a protected branch, you can enable the **"Allow direct work on this protected branch"** checkbox in the UI. This sets the `allowProtectedWork` flag. + +**Use with extreme caution** - this disables the safety mechanism and allows direct commits to protected branches. + +--- + +## Common Workflow Combinations + +### Simple Clone (Minimal Configuration) + +```yaml +Repository URL: https://github.com/org/project +# Everything else: default + +Behavior: +→ Backend generates session-named branch (e.g., 'Fix-login-bug') +→ UI displays generated branch name before session starts +→ Runner clones repository's default branch (usually 'main') +→ Runner creates working branch from default +→ All changes committed to session-named branch +``` + +--- + +### Feature Branch Development + +```yaml +Repository URL: https://github.com/org/project +Working Branch: feature/issue-123 + +Behavior: +→ Clones repository +→ If 'feature/issue-123' exists: checks it out +→ If it doesn't exist: creates it from default branch +→ All changes committed to feature/issue-123 +→ Ready for PR: feature/issue-123 → main +``` + +--- + +### Fork Contribution Workflow + +```yaml +Repository URL: https://github.com/yourname/project-fork +Working Branch: feature/fix-bug-456 +Sync: + URL: https://github.com/upstream/project + Branch: main + +Behavior: +→ Clones your fork +→ Adds upstream remote +→ Checks out or creates 'feature/fix-bug-456' +→ Fetches and rebases onto upstream/main +→ Working branch now has latest upstream changes +→ Changes committed to feature/fix-bug-456 +→ Ready for PR: yourname:feature/fix-bug-456 → upstream:main +``` + +--- + +### Working from Release Branch + +```yaml +Repository URL: https://github.com/org/project +Working Branch: hotfix/critical-issue +Sync: + URL: https://github.com/org/project + Branch: release/v2.0 + +Behavior: +→ Clones repository +→ Creates 'hotfix/critical-issue' from default branch +→ Syncs with release/v2.0 branch +→ All changes committed to hotfix branch +→ Ready for PR: hotfix/critical-issue → release/v2.0 +``` + +--- + +## What Happens Under the Hood + +### Repository Initialization Sequence + +1. **Backend Branch Name Generation** (before session starts) + ``` + Backend receives request with optional workingBranch + + If workingBranch specified: + - Use that branch (unless it's protected) + + If workingBranch is protected and allowProtectedWork is false: + - Generate: work// + + If no workingBranch specified: + - Generate from session name: + + Store generated branch name in session spec + Display in UI before session starts + ``` + +2. **Clone Phase** (runner execution) + ```bash + # Runner executes (simplified) + git clone + ``` + +3. **Working Branch Checkout/Creation** + ```bash + # Runner reads branch name from session spec (already generated by backend) + # Try to checkout if exists remotely + git checkout + + # Or create it if doesn't exist + git checkout -b + ``` + +4. **Remote/Upstream Sync** (if configured) + ```bash + git remote add upstream + git fetch upstream + git rebase upstream/ + ``` + +5. **Ready for Work** + - Working directory is now on the appropriate branch + - Claude can read/modify files + - Changes will be committed to the active branch + +--- + +## Error Handling + +The runner handles git errors gracefully: + +### Authentication Failures +``` +âš ī¸ Authentication failed for - continuing without this repository +``` +**Cause**: Invalid credentials or no access to private repo +**Solution**: Check that your GitHub/GitLab token has access to the repository + +### Branch Not Found +``` +â„šī¸ Branch 'feature-xyz' not found remotely - creating it from default branch +``` +**Cause**: Specified working branch doesn't exist in remote +**Action**: Automatic - branch created from repository's default branch + +### Protected Branch Handling +**UI Behavior**: When you specify a protected branch name (e.g., `main`), the UI immediately shows the generated working branch name (e.g., `work/main/`) before you create the session. + +**Action**: Automatic - safe working branch name generated by backend to protect the original branch. No runtime warnings needed since the branch name is determined and visible upfront. + +--- + +## Best Practices + +1. **Verify Branch Name in UI Before Starting Session** + - Always check the displayed branch name in the UI before starting your session + - Confirms you understand where your changes will go + - Especially important for protected branch scenarios + +2. **Use Descriptive Branch Names** + - Use clear working branch names: `feature/add-user-auth` + - Include issue numbers: `fix/issue-123` + - Makes it clear what the session is working on + +3. **Leverage Remote/Upstream Sync for Forks** + - Always work with latest remote code + - Reduces merge conflicts + - Ensures compatibility with parent project + +4. **Let Protected Branches Stay Protected** + - Check the generated working branch name in the UI when working with protected branches + - Only enable "allow protected work" if you have a specific need + - Helps prevent accidental changes to critical branches + +5. **Specify Working Branches Explicitly** + - Instead of relying on defaults, specify your intended branch + - If creating a new feature, use `feature/name-here` + - If working on existing branch, specify it clearly + +6. **Match Your Team's Workflow** + - If team uses specific branch naming conventions, follow them + - If team requires PRs from forks, configure sync + - Align runner behavior with your git conventions + +--- + +## Quick Reference + +| Scenario | Working Branch | Sync | Result | +|----------|----------------|------|--------| +| Simple clone | _(empty)_ | _(none)_ | Creates session-named branch from default | +| Feature development | `feature/my-work` | _(none)_ | Create or checkout feature branch, work there | +| Fork contribution | `feature/contribution` | `remote: upstream/main` | Clone fork, sync with upstream, work on feature | +| Existing branch work | `develop` | _(none)_ | Checkout existing develop branch, work there | +| Protected override | `main` + allow protected | _(none)_ | Work directly on main (âš ī¸ caution) | + +--- + +## Related Documentation + +- [Multi-Repo Support](../adr/0003-multi-repo-support.md) - Architecture decision for multi-repository sessions +- [Runner Documentation](../CLAUDE_CODE_RUNNER.md) - Claude Code runner implementation details +- [GitLab Integration](../gitlab-integration.md) - Working with GitLab repositories diff --git a/mkdocs.yml b/mkdocs.yml index 0d80bbeae..289a332ac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Getting Started: user-guide/getting-started.md - Working with Amber: user-guide/working-with-amber.md - Amber Quickstart: user-guide/amber-quickstart.md + - Git Repository Options: user-guide/git-repository-options.md - Developer Guide: - Observability & Instrumentation: observability-langfuse.md - Model Pricing: model-pricing.md