199 lines
5.9 KiB
Go
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
|
|
}
|