615 lines
17 KiB
Go
615 lines
17 KiB
Go
package git
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// WorktreeInfo describes an active git worktree.
|
|
type WorktreeInfo struct {
|
|
Path string // absolute worktree dir
|
|
Branch string // e.g. "plan/setup-monitoring"
|
|
BaseBranch string // branch forked from
|
|
RepoRoot string // main repo root
|
|
}
|
|
|
|
// ManagedWorktree is a user-facing summary for worktree management commands/UI.
|
|
type ManagedWorktree struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"-"`
|
|
Branch string `json:"branch"`
|
|
LastCommitHash string `json:"last_commit_hash"`
|
|
LastCommitSubject string `json:"last_commit_subject"`
|
|
LastCommitAge string `json:"last_commit_age"`
|
|
HasUncommitted bool `json:"has_uncommitted"`
|
|
}
|
|
|
|
var (
|
|
// ErrInvalidWorktreeName is returned when the given worktree name is unsafe.
|
|
ErrInvalidWorktreeName = errors.New("invalid worktree name")
|
|
// ErrWorktreeNotFound is returned when the named worktree cannot be found.
|
|
ErrWorktreeNotFound = errors.New("worktree not found")
|
|
)
|
|
|
|
// DisposeResult describes what happened when a worktree was disposed.
|
|
type DisposeResult struct {
|
|
Branch string
|
|
AutoCommitted bool // true if uncommitted changes were saved
|
|
BranchDeleted bool // true if branch had no unique commits
|
|
CommitsAhead int // unique commits on branch (0 = safe to delete)
|
|
}
|
|
|
|
// FindRepoRoot returns the git repository root for dir, or "" if not a git repo.
|
|
func FindRepoRoot(dir string) string {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// CurrentBranch returns the current branch name, or "" on error.
|
|
func CurrentBranch(dir string) string {
|
|
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
var unsafeBranchRe = regexp.MustCompile(`[^a-z0-9-]`)
|
|
|
|
// SanitizeBranchName creates a safe branch name from a task description.
|
|
// Returns "plan/<safe-40-chars>".
|
|
func SanitizeBranchName(task string) string {
|
|
s := strings.ToLower(strings.TrimSpace(task))
|
|
s = unsafeBranchRe.ReplaceAllString(s, "-")
|
|
|
|
// Collapse consecutive hyphens
|
|
for strings.Contains(s, "--") {
|
|
s = strings.ReplaceAll(s, "--", "-")
|
|
}
|
|
s = strings.Trim(s, "-")
|
|
|
|
if s == "" {
|
|
s = "worktree"
|
|
}
|
|
|
|
// Truncate to 40 chars (ASCII fast path: branch names are ASCII after sanitization)
|
|
if len(s) > 40 {
|
|
s = s[:40]
|
|
}
|
|
s = strings.TrimRight(s, "-")
|
|
|
|
return "plan/" + s
|
|
}
|
|
|
|
// CreateWorktree creates a new git worktree at worktreePath with branchName.
|
|
// If the branch already exists, it reuses it.
|
|
func CreateWorktree(repoDir, worktreePath, branchName string) (*WorktreeInfo, error) {
|
|
baseBranch := CurrentBranch(repoDir)
|
|
if baseBranch == "" {
|
|
baseBranch = "HEAD"
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create worktree parent: %w", err)
|
|
}
|
|
|
|
// Check if branch already exists
|
|
checkCmd := exec.Command("git", "rev-parse", "--verify", branchName)
|
|
checkCmd.Dir = repoDir
|
|
branchExists := checkCmd.Run() == nil
|
|
|
|
var cmd *exec.Cmd
|
|
if branchExists {
|
|
// Reuse existing branch
|
|
cmd = exec.Command("git", "worktree", "add", worktreePath, branchName)
|
|
} else {
|
|
// Create new branch
|
|
cmd = exec.Command("git", "worktree", "add", "-b", branchName, worktreePath)
|
|
}
|
|
cmd.Dir = repoDir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return nil, fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
return &WorktreeInfo{
|
|
Path: worktreePath,
|
|
Branch: branchName,
|
|
BaseBranch: baseBranch,
|
|
RepoRoot: repoDir,
|
|
}, nil
|
|
}
|
|
|
|
// HasUncommittedChanges returns true if the working tree has staged or unstaged changes.
|
|
func HasUncommittedChanges(dir string) bool {
|
|
cmd := exec.Command("git", "status", "--porcelain")
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return len(strings.TrimSpace(string(out))) > 0
|
|
}
|
|
|
|
// AutoCommit stages all changes and commits with the given message.
|
|
func AutoCommit(worktreePath, message string) error {
|
|
addCmd := exec.Command("git", "add", "-A")
|
|
addCmd.Dir = worktreePath
|
|
if out, err := addCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("git add: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
commitCmd := exec.Command("git", "commit", "-m", message, "--allow-empty-message")
|
|
commitCmd.Dir = worktreePath
|
|
if out, err := commitCmd.CombinedOutput(); err != nil {
|
|
// "nothing to commit" is not a real error
|
|
if strings.Contains(string(out), "nothing to commit") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("git commit: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CommitsAhead returns the number of commits on branch that are not on base.
|
|
func CommitsAhead(repoDir, base, branch string) int {
|
|
cmd := exec.Command("git", "rev-list", "--count", base+".."+branch)
|
|
cmd.Dir = repoDir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
|
|
return n
|
|
}
|
|
|
|
// SafeDispose auto-commits uncommitted changes, removes the worktree directory,
|
|
// and deletes the branch ONLY if it has no unique commits.
|
|
func SafeDispose(repoDir string, wt *WorktreeInfo) DisposeResult {
|
|
result := DisposeResult{Branch: wt.Branch}
|
|
|
|
// 1. Auto-commit if there are uncommitted changes
|
|
if HasUncommittedChanges(wt.Path) {
|
|
msg := fmt.Sprintf("auto: save from %s", wt.Branch)
|
|
if err := AutoCommit(wt.Path, msg); err == nil {
|
|
result.AutoCommitted = true
|
|
}
|
|
}
|
|
|
|
// 2. Count unique commits
|
|
result.CommitsAhead = CommitsAhead(repoDir, wt.BaseBranch, wt.Branch)
|
|
|
|
// 3. Remove worktree
|
|
removeCmd := exec.Command("git", "worktree", "remove", "--force", wt.Path)
|
|
removeCmd.Dir = repoDir
|
|
removeCmd.Run() // best-effort
|
|
|
|
// 4. Delete branch if no unique commits
|
|
if result.CommitsAhead == 0 {
|
|
delCmd := exec.Command("git", "branch", "-D", wt.Branch)
|
|
delCmd.Dir = repoDir
|
|
if delCmd.Run() == nil {
|
|
result.BranchDeleted = true
|
|
}
|
|
}
|
|
|
|
// 5. Fallback cleanup
|
|
os.RemoveAll(wt.Path)
|
|
|
|
return result
|
|
}
|
|
|
|
// MergeResult describes the outcome of a worktree branch merge attempt.
|
|
type MergeResult struct {
|
|
Merged bool // true if merge succeeded
|
|
Branch string // branch name that was merged
|
|
Conflict bool // true if merge failed due to conflict
|
|
}
|
|
|
|
// MergeWorktreeBranch attempts to merge the worktree branch into the base branch.
|
|
// On conflict, it aborts the merge and returns Conflict=true.
|
|
// Must be called AFTER auto-commit and BEFORE SafeDispose.
|
|
func MergeWorktreeBranch(repoDir string, wt *WorktreeInfo) MergeResult {
|
|
result := MergeResult{Branch: wt.Branch}
|
|
|
|
mergeCmd := exec.Command("git", "merge", "--no-edit", wt.Branch)
|
|
mergeCmd.Dir = repoDir
|
|
if err := mergeCmd.Run(); err != nil {
|
|
// Merge failed — abort and report conflict
|
|
abortCmd := exec.Command("git", "merge", "--abort")
|
|
abortCmd.Dir = repoDir
|
|
abortCmd.Run() // best-effort
|
|
result.Conflict = true
|
|
return result
|
|
}
|
|
|
|
result.Merged = true
|
|
return result
|
|
}
|
|
|
|
// ListManagedWorktrees returns active worktree summaries under worktreesDir.
|
|
func ListManagedWorktrees(repoDir, worktreesDir string) ([]ManagedWorktree, error) {
|
|
entries, err := os.ReadDir(worktreesDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []ManagedWorktree{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]ManagedWorktree, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
wtPath := filepath.Join(worktreesDir, name)
|
|
if !isLinkedWorktree(wtPath) {
|
|
continue
|
|
}
|
|
result = append(result, buildManagedWorktree(repoDir, name, wtPath))
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
|
return result, nil
|
|
}
|
|
|
|
// GetManagedWorktree resolves one managed worktree by name.
|
|
func GetManagedWorktree(repoDir, worktreesDir, name string) (*ManagedWorktree, error) {
|
|
if !isSafeWorktreeName(name) {
|
|
return nil, ErrInvalidWorktreeName
|
|
}
|
|
wtPath := filepath.Join(worktreesDir, name)
|
|
if !isLinkedWorktree(wtPath) {
|
|
return nil, ErrWorktreeNotFound
|
|
}
|
|
wt := buildManagedWorktree(repoDir, name, wtPath)
|
|
return &wt, nil
|
|
}
|
|
|
|
// MergeManagedWorktree merges a named worktree branch into baseBranch.
|
|
// When baseBranch is empty, DetectDefaultBranch(repoDir) is used.
|
|
func MergeManagedWorktree(repoDir, worktreesDir, name, baseBranch string) (MergeResult, string, error) {
|
|
var zero MergeResult
|
|
|
|
wt, err := GetManagedWorktree(repoDir, worktreesDir, name)
|
|
if err != nil {
|
|
return zero, "", err
|
|
}
|
|
if wt.Branch == "" || wt.Branch == "HEAD" {
|
|
return zero, "", fmt.Errorf("worktree %q has no mergeable branch", name)
|
|
}
|
|
|
|
if baseBranch == "" {
|
|
baseBranch = DetectDefaultBranch(repoDir)
|
|
}
|
|
if baseBranch == "" {
|
|
return zero, "", fmt.Errorf("failed to resolve base branch")
|
|
}
|
|
|
|
current := CurrentBranch(repoDir)
|
|
if current == "" {
|
|
return zero, "", fmt.Errorf("failed to detect current branch")
|
|
}
|
|
|
|
if current != baseBranch {
|
|
if err := checkoutBranch(repoDir, baseBranch); err != nil {
|
|
return zero, "", err
|
|
}
|
|
defer func() {
|
|
if err := checkoutBranch(repoDir, current); err != nil {
|
|
logger.WarnCF("git", "Failed to restore original branch after merge", map[string]any{
|
|
"branch": current,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}()
|
|
}
|
|
|
|
res := MergeWorktreeBranch(repoDir, &WorktreeInfo{
|
|
Path: wt.Path,
|
|
Branch: wt.Branch,
|
|
BaseBranch: baseBranch,
|
|
RepoRoot: repoDir,
|
|
})
|
|
return res, baseBranch, nil
|
|
}
|
|
|
|
// DisposeManagedWorktree removes a named worktree with SafeDispose.
|
|
// When baseBranch is empty, DetectDefaultBranch(repoDir) is used.
|
|
func DisposeManagedWorktree(repoDir, worktreesDir, name, baseBranch string) (DisposeResult, error) {
|
|
var zero DisposeResult
|
|
|
|
wt, err := GetManagedWorktree(repoDir, worktreesDir, name)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if wt.Branch == "" {
|
|
return zero, fmt.Errorf("worktree %q has no branch", name)
|
|
}
|
|
if baseBranch == "" {
|
|
baseBranch = DetectDefaultBranch(repoDir)
|
|
}
|
|
if baseBranch == "" {
|
|
baseBranch = "main"
|
|
}
|
|
res := SafeDispose(repoDir, &WorktreeInfo{
|
|
Path: wt.Path,
|
|
Branch: wt.Branch,
|
|
BaseBranch: baseBranch,
|
|
RepoRoot: repoDir,
|
|
})
|
|
return res, nil
|
|
}
|
|
|
|
// WorktreeStatusShort returns "git status --short" output for a worktree.
|
|
func WorktreeStatusShort(worktreePath string) (string, error) {
|
|
cmd := exec.Command("git", "status", "--short")
|
|
cmd.Dir = worktreePath
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("git status --short: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// WorktreeRecentLog returns recent oneline commits for a worktree branch.
|
|
func WorktreeRecentLog(worktreePath string, n int) (string, error) {
|
|
if n <= 0 {
|
|
n = 10
|
|
}
|
|
cmd := exec.Command("git", "log", "--oneline", fmt.Sprintf("-%d", n))
|
|
cmd.Dir = worktreePath
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("git log --oneline: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// WorktreeDiffStat returns a compact diff stat for a worktree.
|
|
func WorktreeDiffStat(worktreePath string) (string, error) {
|
|
cmd := exec.Command("git", "diff", "--stat")
|
|
cmd.Dir = worktreePath
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", fmt.Errorf("git diff --stat: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// DetectDefaultBranch returns the preferred base branch name for merges/dispose.
|
|
func DetectDefaultBranch(repoDir string) string {
|
|
if localBranchExists(repoDir, "main") {
|
|
return "main"
|
|
}
|
|
if localBranchExists(repoDir, "master") {
|
|
return "master"
|
|
}
|
|
|
|
// Try origin/HEAD -> origin/<branch>
|
|
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
|
|
cmd.Dir = repoDir
|
|
if out, err := cmd.Output(); err == nil {
|
|
ref := strings.TrimSpace(string(out))
|
|
if idx := strings.LastIndex(ref, "/"); idx >= 0 && idx < len(ref)-1 {
|
|
return ref[idx+1:]
|
|
}
|
|
}
|
|
|
|
current := CurrentBranch(repoDir)
|
|
if current != "" && current != "HEAD" {
|
|
return current
|
|
}
|
|
return "main"
|
|
}
|
|
|
|
// PruneOrphaned removes stale worktree directories under worktreesDir.
|
|
// For orphaned linked worktrees with uncommitted changes, it auto-commits before removal.
|
|
func PruneOrphaned(repoDir, worktreesDir string) {
|
|
entries, err := os.ReadDir(worktreesDir)
|
|
if err != nil {
|
|
// Still attempt git's own metadata prune.
|
|
pruneCmd := exec.Command("git", "worktree", "prune")
|
|
pruneCmd.Dir = repoDir
|
|
pruneCmd.Run() // best-effort
|
|
return
|
|
}
|
|
|
|
active := map[string]bool{}
|
|
activeKnown := false
|
|
if activePaths, err := listGitWorktreePaths(repoDir); err == nil {
|
|
activeKnown = true
|
|
for _, p := range activePaths {
|
|
active[filepath.Clean(p)] = true
|
|
}
|
|
} else {
|
|
logger.WarnCF("git", "Skip linked-worktree prune: failed to enumerate active worktrees", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
wtPath := filepath.Clean(filepath.Join(worktreesDir, entry.Name()))
|
|
|
|
if !isLinkedWorktree(wtPath) {
|
|
_ = os.RemoveAll(wtPath)
|
|
continue
|
|
}
|
|
if !activeKnown {
|
|
continue
|
|
}
|
|
if active[wtPath] {
|
|
continue
|
|
}
|
|
|
|
// Orphaned linked worktree: protect changes before prune.
|
|
if HasUncommittedChanges(wtPath) {
|
|
if err := AutoCommit(wtPath, "auto-save before prune"); err != nil {
|
|
logger.WarnCF("git", "Skip pruning orphaned worktree: auto-commit failed", map[string]any{
|
|
"path": wtPath,
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
logger.InfoCF("git", "Auto-committed orphaned worktree before prune", map[string]any{"path": wtPath})
|
|
}
|
|
|
|
removeCmd := exec.Command("git", "worktree", "remove", "--force", wtPath)
|
|
removeCmd.Dir = repoDir
|
|
if out, err := removeCmd.CombinedOutput(); err != nil {
|
|
logger.WarnCF(
|
|
"git",
|
|
"git worktree remove failed for orphaned worktree; removing directory directly",
|
|
map[string]any{
|
|
"path": wtPath,
|
|
"error": strings.TrimSpace(string(out)),
|
|
},
|
|
)
|
|
_ = os.RemoveAll(wtPath)
|
|
continue
|
|
}
|
|
logger.InfoCF("git", "Pruned orphaned worktree", map[string]any{"path": wtPath})
|
|
}
|
|
|
|
pruneCmd := exec.Command("git", "worktree", "prune")
|
|
pruneCmd.Dir = repoDir
|
|
if out, err := pruneCmd.CombinedOutput(); err != nil {
|
|
logger.WarnCF("git", "git worktree prune failed", map[string]any{"error": strings.TrimSpace(string(out))})
|
|
}
|
|
}
|
|
|
|
// BranchBaseName extracts the last segment of a branch name.
|
|
// "plan/add-auth" → "plan-add-auth"
|
|
func BranchBaseName(branch string) string {
|
|
s := strings.ReplaceAll(branch, "/", "-")
|
|
// Remove leading/trailing hyphens
|
|
s = strings.Trim(s, "-")
|
|
// Remove non-printable chars
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if unicode.IsPrint(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
if b.Len() == 0 {
|
|
return "worktree"
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isSafeWorktreeName(name string) bool {
|
|
if name == "" || name == "." || name == ".." {
|
|
return false
|
|
}
|
|
if filepath.Base(name) != name {
|
|
return false
|
|
}
|
|
if strings.ContainsAny(name, `/\\`) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isLinkedWorktree(path string) bool {
|
|
gitPath := filepath.Join(path, ".git")
|
|
info, err := os.Stat(gitPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Linked worktrees have a .git file (not a directory).
|
|
if info.IsDir() {
|
|
return false
|
|
}
|
|
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
|
cmd.Dir = path
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
func buildManagedWorktree(repoDir, name, wtPath string) ManagedWorktree {
|
|
hash, subject, age := lastCommitInfo(wtPath)
|
|
return ManagedWorktree{
|
|
Name: name,
|
|
Path: wtPath,
|
|
Branch: CurrentBranch(wtPath),
|
|
LastCommitHash: hash,
|
|
LastCommitSubject: subject,
|
|
LastCommitAge: age,
|
|
HasUncommitted: HasUncommittedChanges(wtPath),
|
|
}
|
|
}
|
|
|
|
func lastCommitInfo(dir string) (hash, subject, age string) {
|
|
cmd := exec.Command("git", "log", "-1", "--pretty=format:%h\x1f%s\x1f%cr")
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", "", ""
|
|
}
|
|
parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
|
|
if len(parts) > 0 {
|
|
hash = parts[0]
|
|
}
|
|
if len(parts) > 1 {
|
|
subject = parts[1]
|
|
}
|
|
if len(parts) > 2 {
|
|
age = parts[2]
|
|
}
|
|
return hash, subject, age
|
|
}
|
|
|
|
func localBranchExists(repoDir, name string) bool {
|
|
cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/"+name)
|
|
cmd.Dir = repoDir
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
func checkoutBranch(repoDir, branch string) error {
|
|
cmd := exec.Command("git", "checkout", branch)
|
|
cmd.Dir = repoDir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("git checkout %s: %s: %w", branch, strings.TrimSpace(string(out)), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func listGitWorktreePaths(repoDir string) ([]string, error) {
|
|
cmd := exec.Command("git", "worktree", "list", "--porcelain")
|
|
cmd.Dir = repoDir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lines := strings.Split(string(out), "\n")
|
|
paths := make([]string, 0)
|
|
for _, line := range lines {
|
|
if !strings.HasPrefix(line, "worktree ") {
|
|
continue
|
|
}
|
|
p := strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
|
|
if p != "" {
|
|
paths = append(paths, filepath.Clean(p))
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|