Files

199 lines
5.9 KiB
Go

package workspaceruntime
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"inbox/internal/app/lanegit"
)
type gitWorktree struct {
Path string
Branch string
}
type gitWorktreeManager struct {
projectRoot string
runner lanegit.Runner
}
func (g *gitWorktreeManager) ensureRepository(ctx context.Context, projectDir string) (string, error) {
isRepo, err := g.isRepository(ctx, projectDir)
if err != nil {
return "", err
}
if isRepo {
branch, err := g.currentBranch(ctx, projectDir)
if err == nil && strings.TrimSpace(branch) != "" {
return strings.TrimSpace(branch), nil
}
return "main", nil
}
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "init", "-b", "main")
if err != nil {
return "", commandError("git init "+projectDir, out, err)
}
env := map[string]string{
"GIT_AUTHOR_NAME": "Inbox",
"GIT_AUTHOR_EMAIL": "inbox@local",
"GIT_COMMITTER_NAME": "Inbox",
"GIT_COMMITTER_EMAIL": "inbox@local",
}
out, err = g.runner.Run(ctx, g.projectRoot, env, "git", "-C", projectDir, "commit", "--allow-empty", "-m", "Initialize workspace repository")
if err != nil {
return "", commandError("create initial empty commit", out, err)
}
return "main", nil
}
func (g *gitWorktreeManager) ensureWorktree(ctx context.Context, projectDir, worktreePath, baseBranch, worktreeBranch string) error {
worktreePath = filepath.Clean(worktreePath)
entries, err := g.listWorktrees(ctx, projectDir)
if err != nil {
return err
}
if stale, err := hasMissingWorktreePaths(entries); err != nil {
return err
} else if stale {
if err := g.pruneWorktrees(ctx, projectDir); err != nil {
return err
}
entries, err = g.listWorktrees(ctx, projectDir)
if err != nil {
return err
}
}
for _, entry := range entries {
if filepath.Clean(entry.Path) == worktreePath {
expected := "refs/heads/" + worktreeBranch
if strings.TrimSpace(entry.Branch) != "" && entry.Branch != expected {
return fmt.Errorf("worktree path %s is attached to %s, want %s", worktreePath, entry.Branch, expected)
}
return nil
}
if entry.Branch == "refs/heads/"+worktreeBranch && filepath.Clean(entry.Path) != worktreePath {
return fmt.Errorf("worktree branch %s already attached at %s", worktreeBranch, entry.Path)
}
}
if info, err := os.Stat(worktreePath); err == nil && info.IsDir() {
return fmt.Errorf("worktree path already exists but is not registered: %s", worktreePath)
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("stat worktree path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
return fmt.Errorf("create worktree parent: %w", err)
}
branchExists, err := g.branchExists(ctx, projectDir, worktreeBranch)
if err != nil {
return err
}
args := []string{"-C", projectDir, "worktree", "add"}
if !branchExists {
args = append(args, "-b", worktreeBranch)
}
args = append(args, worktreePath)
if branchExists {
args = append(args, worktreeBranch)
} else {
args = append(args, baseBranch)
}
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", args...)
if err != nil {
return commandError("create worktree "+worktreePath, out, err)
}
return nil
}
func (g *gitWorktreeManager) isRepository(ctx context.Context, projectDir string) (bool, error) {
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "rev-parse", "--is-inside-work-tree")
if err != nil {
if strings.Contains(out, "not a git repository") {
return false, nil
}
return false, fmt.Errorf("detect git repository: %w", err)
}
return strings.TrimSpace(out) == "true", nil
}
func (g *gitWorktreeManager) currentBranch(ctx context.Context, projectDir string) (string, error) {
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
func (g *gitWorktreeManager) listWorktrees(ctx context.Context, projectDir string) ([]gitWorktree, error) {
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "worktree", "list", "--porcelain")
if err != nil {
return nil, fmt.Errorf("list git worktrees: %w", err)
}
lines := strings.Split(out, "\n")
items := make([]gitWorktree, 0)
var current gitWorktree
flush := func() {
if strings.TrimSpace(current.Path) == "" {
return
}
items = append(items, current)
current = gitWorktree{}
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
switch {
case strings.HasPrefix(line, "worktree "):
flush()
current.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
case strings.HasPrefix(line, "branch "):
current.Branch = strings.TrimSpace(strings.TrimPrefix(line, "branch "))
}
}
flush()
return items, nil
}
func (g *gitWorktreeManager) branchExists(ctx context.Context, projectDir, branch string) (bool, error) {
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
if err != nil {
if out == "" {
return false, nil
}
return false, fmt.Errorf("check git branch %s: %w", branch, err)
}
return true, nil
}
func (g *gitWorktreeManager) pruneWorktrees(ctx context.Context, projectDir string) error {
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "worktree", "prune", "--expire", "now")
if err != nil {
return commandError("prune stale worktrees", out, err)
}
return nil
}
func hasMissingWorktreePaths(entries []gitWorktree) (bool, error) {
for _, entry := range entries {
info, err := os.Stat(filepath.Clean(entry.Path))
if err == nil {
if !info.IsDir() {
return false, fmt.Errorf("worktree path is not a directory: %s", entry.Path)
}
continue
}
if os.IsNotExist(err) {
return true, nil
}
return false, fmt.Errorf("stat registered worktree path %s: %w", entry.Path, err)
}
return false, nil
}