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 }