-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
User Story
As a job execution system, I want to automatically create and manage Git branches for each job, so that multiple jobs can work on the same repository simultaneously without conflicts.
Acceptance Criteria
- Create unique branch names based on job ID and timestamp
- Support branch creation from any base branch (main, develop, feature/*)
- Automatically delete branches after successful merge
- Preserve branches on failure for debugging
- Handle branch name conflicts gracefully
- Support protected branch rules validation
- Clean up stale branches older than configurable period (default: 7 days)
- Track branch lifecycle in database
- Support branch naming templates
- Implement branch locking to prevent concurrent modifications
Technical Implementation
Branch Management Service
// pkg/git/branch_service.go
package git
import (
"context"
"fmt"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/config"
)
type BranchService struct {
gitClient *GitClient
db *BranchDatabase
locker *BranchLocker
config *BranchConfig
}
type BranchConfig struct {
NameTemplate string // e.g., "flowforge/{job_id}/{timestamp}"
DeleteAfterMerge bool
StaleThreshold time.Duration // Default: 7 days
CleanupInterval time.Duration // Default: 1 hour
PreserveOnFailure bool
}
type Branch struct {
ID string
JobID string
Name string
BaseBranch string
CommitSHA string
Status BranchStatus
CreatedAt time.Time
MergedAt *time.Time
DeletedAt *time.Time
}
type BranchStatus string
const (
BranchStatusActive BranchStatus = "active"
BranchStatusMerged BranchStatus = "merged"
BranchStatusDeleted BranchStatus = "deleted"
BranchStatusFailed BranchStatus = "failed"
BranchStatusStale BranchStatus = "stale"
)
func (s *BranchService) CreateBranch(ctx context.Context, repo *git.Repository, req CreateBranchRequest) (*Branch, error) {
// Generate unique branch name
branchName := s.generateBranchName(req.JobID)
// Acquire lock to prevent race conditions
lock, err := s.locker.AcquireLock(repo.Path, branchName, 30*time.Second)
if err != nil {
return nil, fmt.Errorf("failed to acquire branch lock: %w", err)
}
defer lock.Release()
// Get base branch reference
baseRef, err := repo.Reference(plumbing.NewBranchReferenceName(req.BaseBranch), true)
if err != nil {
return nil, fmt.Errorf("base branch not found: %w", err)
}
// Create new branch
newRef := plumbing.NewBranchReferenceName(branchName)
ref := plumbing.NewHashReference(newRef, baseRef.Hash())
if err := repo.Storer.SetReference(ref); err != nil {
return nil, fmt.Errorf("failed to create branch: %w", err)
}
// Checkout new branch
w, err := repo.Worktree()
if err != nil {
return nil, err
}
if err := w.Checkout(&git.CheckoutOptions{
Branch: newRef,
Create: false,
}); err != nil {
return nil, fmt.Errorf("failed to checkout branch: %w", err)
}
// Record in database
branch := &Branch{
ID: generateID(),
JobID: req.JobID,
Name: branchName,
BaseBranch: req.BaseBranch,
CommitSHA: baseRef.Hash().String(),
Status: BranchStatusActive,
CreatedAt: time.Now(),
}
if err := s.db.CreateBranch(ctx, branch); err != nil {
return nil, fmt.Errorf("failed to record branch: %w", err)
}
return branch, nil
}
func (s *BranchService) generateBranchName(jobID string) string {
template := s.config.NameTemplate
timestamp := time.Now().Unix()
name := strings.ReplaceAll(template, "{job_id}", jobID)
name = strings.ReplaceAll(name, "{timestamp}", fmt.Sprintf("%d", timestamp))
name = strings.ReplaceAll(name, "{date}", time.Now().Format("20060102"))
// Ensure valid branch name
return sanitizeBranchName(name)
}Branch Lifecycle Management
// pkg/git/branch_lifecycle.go
func (s *BranchService) MergeBranch(ctx context.Context, repo *git.Repository, branch *Branch, targetBranch string) error {
// Validate branch can be merged
if branch.Status != BranchStatusActive {
return fmt.Errorf("branch is not active: %s", branch.Status)
}
// Check protected branch rules
if err := s.validateProtectedBranch(targetBranch); err != nil {
return fmt.Errorf("protected branch validation failed: %w", err)
}
// Perform merge (handled by rebase service)
// ... merge logic ...
// Update branch status
branch.Status = BranchStatusMerged
branch.MergedAt = &time.Now()
if err := s.db.UpdateBranch(ctx, branch); err != nil {
return err
}
// Delete branch if configured
if s.config.DeleteAfterMerge {
return s.DeleteBranch(ctx, repo, branch)
}
return nil
}
func (s *BranchService) DeleteBranch(ctx context.Context, repo *git.Repository, branch *Branch) error {
// Don't delete if configured to preserve on failure
if branch.Status == BranchStatusFailed && s.config.PreserveOnFailure {
return nil
}
// Delete remote branch
remote, err := repo.Remote("origin")
if err == nil {
refSpec := fmt.Sprintf(":refs/heads/%s", branch.Name)
err = remote.Push(&git.PushOptions{
RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
Auth: s.gitClient.GetAuth(),
})
if err != nil {
return fmt.Errorf("failed to delete remote branch: %w", err)
}
}
// Delete local branch
ref := plumbing.NewBranchReferenceName(branch.Name)
if err := repo.Storer.RemoveReference(ref); err != nil {
return fmt.Errorf("failed to delete local branch: %w", err)
}
// Update database
branch.Status = BranchStatusDeleted
branch.DeletedAt = &time.Now()
return s.db.UpdateBranch(ctx, branch)
}Stale Branch Cleanup
// pkg/git/branch_cleanup.go
func (s *BranchService) StartCleanupWorker(ctx context.Context) {
ticker := time.NewTicker(s.config.CleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.cleanupStaleBranches(ctx); err != nil {
log.Errorf("Stale branch cleanup failed: %v", err)
}
}
}
}
func (s *BranchService) cleanupStaleBranches(ctx context.Context) error {
// Find stale branches
staleBranches, err := s.db.FindBranches(ctx, BranchQuery{
Status: BranchStatusActive,
OlderThan: time.Now().Add(-s.config.StaleThreshold),
})
if err != nil {
return err
}
for _, branch := range staleBranches {
// Check if branch has unmerged changes
repo, err := s.gitClient.OpenRepository(branch.RepositoryPath)
if err != nil {
log.Warnf("Failed to open repository for branch %s: %v", branch.Name, err)
continue
}
hasChanges, err := s.hasUnmergedChanges(repo, branch)
if err != nil {
log.Warnf("Failed to check branch %s: %v", branch.Name, err)
continue
}
if !hasChanges {
// Safe to delete
if err := s.DeleteBranch(ctx, repo, branch); err != nil {
log.Warnf("Failed to delete stale branch %s: %v", branch.Name, err)
} else {
log.Infof("Deleted stale branch %s", branch.Name)
}
} else {
// Mark as stale but don't delete
branch.Status = BranchStatusStale
s.db.UpdateBranch(ctx, branch)
}
}
return nil
}Branch Locking
// pkg/git/branch_lock.go
type BranchLocker struct {
redis *redis.Client
prefix string
}
type BranchLock struct {
key string
redis *redis.Client
expiry time.Duration
}
func (l *BranchLocker) AcquireLock(repoPath, branchName string, ttl time.Duration) (*BranchLock, error) {
key := fmt.Sprintf("%s:lock:%s:%s", l.prefix, repoPath, branchName)
// Try to acquire lock with SET NX
ok, err := l.redis.SetNX(context.Background(), key, "1", ttl).Result()
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("branch is locked")
}
return &BranchLock{
key: key,
redis: l.redis,
expiry: ttl,
}, nil
}
func (l *BranchLock) Release() error {
return l.redis.Del(context.Background(), l.key).Err()
}Architecture References
Git Operations Flow
Reference: /docs/03-data-flow.md:148-169
The data flow shows branch management as part of the Git operations:
stateDiagram-v2
Cloning --> BranchCreation: Repository ready
BranchCreation --> TaskExecution: Branch created
TaskExecution --> CommitChanges: Task complete
Database Schema for Branch Tracking
Reference: /docs/02-system-components.md:599-611
CREATE TABLE git_operations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID REFERENCES jobs(id),
operation_type VARCHAR(50) NOT NULL, -- clone, pull, push, rebase, etc.
repository_url TEXT,
branch VARCHAR(255),
target_branch VARCHAR(255),
commit_sha VARCHAR(40),
status VARCHAR(50) NOT NULL,
duration_ms INTEGER,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Worker Container Context
Reference: /docs/02-system-components.md:857-860
Worker containers need proper Git credentials:
- name: GIT_CREDENTIALS
valueFrom:
secretKeyRef:
name: git-credentials-{job_id}
key: credentialsDependencies
- go-git/v5: Git operations
- Redis: Branch locking
- PostgreSQL: Branch lifecycle tracking
- Kubernetes CronJob: Stale branch cleanup
Definition of Done
- Unit tests cover branch creation, merging, and deletion with 90%+ coverage
- Integration tests verify branch operations on real repositories
- Branch naming conflicts are handled gracefully
- Stale branch cleanup runs successfully in test environment
- Documentation includes branch naming template examples
- Metrics track branch lifecycle durations
- Protected branch rules are respected
Effort Estimate
8 Story Points - Moderate complexity with lifecycle management
Labels
- backend
- git
- epic-5