Skip to content

[User Story] Create Branch Management Service #45

@prodigy

Description

@prodigy

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
Loading

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: credentials

Dependencies

  • 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions