Merge branch 'feat/task-5-heartbeat-worktree-management' into codex/fix-telegram-thread-conversation-issues
This commit is contained in:
commit
e1a783af77
8 changed files with 1101 additions and 23 deletions
|
|
@ -61,4 +61,4 @@ Lint: `golangci-lint run`
|
||||||
| [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode |
|
| [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode |
|
||||||
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | **Session DAG (SQLite Store)** — セッション管理の SQLite 移行、Turn ベース線形+セッション間 DAG、Fork/Report フロー |
|
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | **Session DAG (SQLite Store)** — セッション管理の SQLite 移行、Turn ベース線形+セッション間 DAG、Fork/Report フロー |
|
||||||
| [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 |
|
| [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 |
|
||||||
| [`todo/TASKS-5.md`](todo/TASKS-5.md) | **Heartbeat Worktree Management** — worktree 一覧・点検・手動 merge/dispose の CLI/Mini App UI |
|
| [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees` の `list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI) |
|
||||||
|
|
|
||||||
|
|
@ -3889,6 +3889,9 @@ func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string
|
||||||
phase := agent.ContextBuilder.GetCurrentPhase()
|
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||||
return fmt.Sprintf("Advanced to phase %d.", phase), true
|
return fmt.Sprintf("Advanced to phase %d.", phase), true
|
||||||
|
|
||||||
|
case "worktrees":
|
||||||
|
return al.handlePlanWorktreesCommand(agent, args[1:]), true
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// /plan <task description> — start new plan
|
// /plan <task description> — start new plan
|
||||||
// Block if a plan is already active (fast-path error).
|
// Block if a plan is already active (fast-path error).
|
||||||
|
|
@ -3903,6 +3906,163 @@ func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string
|
||||||
|
|
||||||
// isPlanPreExecution returns true if the plan is in a pre-execution state
|
// isPlanPreExecution returns true if the plan is in a pre-execution state
|
||||||
// (interviewing or review) where tool restrictions and iteration caps apply.
|
// (interviewing or review) where tool restrictions and iteration caps apply.
|
||||||
|
func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string {
|
||||||
|
repoRoot := git.FindRepoRoot(agent.Workspace)
|
||||||
|
if repoRoot == "" {
|
||||||
|
return "Workspace is not a git repository."
|
||||||
|
}
|
||||||
|
worktreesDir := filepath.Join(agent.Workspace, ".worktrees")
|
||||||
|
|
||||||
|
sub := "list"
|
||||||
|
if len(args) > 0 {
|
||||||
|
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "", "list":
|
||||||
|
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error listing worktrees: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "No active worktrees in workspace/.worktrees."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Active worktrees\n\n")
|
||||||
|
for _, wt := range items {
|
||||||
|
status := "clean"
|
||||||
|
if wt.HasUncommitted {
|
||||||
|
status = "dirty"
|
||||||
|
}
|
||||||
|
last := "(no commits)"
|
||||||
|
if wt.LastCommitHash != "" {
|
||||||
|
if wt.LastCommitAge != "" {
|
||||||
|
last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge)
|
||||||
|
} else {
|
||||||
|
last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last)
|
||||||
|
}
|
||||||
|
sb.WriteString("\nCommands:\n")
|
||||||
|
sb.WriteString("/plan worktrees inspect <name>\n")
|
||||||
|
sb.WriteString("/plan worktrees merge <name>\n")
|
||||||
|
sb.WriteString("/plan worktrees dispose <name> [force]")
|
||||||
|
return sb.String()
|
||||||
|
|
||||||
|
case "inspect":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan worktrees inspect <name>"
|
||||||
|
}
|
||||||
|
name := args[1]
|
||||||
|
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, git.ErrInvalidWorktreeName) {
|
||||||
|
return "Invalid worktree name."
|
||||||
|
}
|
||||||
|
if errors.Is(err, git.ErrWorktreeNotFound) {
|
||||||
|
return fmt.Sprintf("Worktree %q not found.", name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Error inspecting worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
statusOut, _ := git.WorktreeStatusShort(wt.Path)
|
||||||
|
diffOut, _ := git.WorktreeDiffStat(wt.Path)
|
||||||
|
logOut, _ := git.WorktreeRecentLog(wt.Path, 10)
|
||||||
|
if statusOut == "" {
|
||||||
|
statusOut = "(clean)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted)
|
||||||
|
if wt.LastCommitHash != "" {
|
||||||
|
fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject)
|
||||||
|
if wt.LastCommitAge != "" {
|
||||||
|
fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\nStatus:\n```\n")
|
||||||
|
sb.WriteString(statusOut)
|
||||||
|
sb.WriteString("\n```\n")
|
||||||
|
if diffOut != "" {
|
||||||
|
sb.WriteString("\nDiff (stat):\n```\n")
|
||||||
|
sb.WriteString(diffOut)
|
||||||
|
sb.WriteString("\n```\n")
|
||||||
|
}
|
||||||
|
if logOut != "" {
|
||||||
|
sb.WriteString("\nRecent commits:\n```\n")
|
||||||
|
sb.WriteString(logOut)
|
||||||
|
sb.WriteString("\n```")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
|
||||||
|
case "merge":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan worktrees merge <name>"
|
||||||
|
}
|
||||||
|
name := args[1]
|
||||||
|
res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, git.ErrInvalidWorktreeName) {
|
||||||
|
return "Invalid worktree name."
|
||||||
|
}
|
||||||
|
if errors.Is(err, git.ErrWorktreeNotFound) {
|
||||||
|
return fmt.Sprintf("Worktree %q not found.", name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Error merging worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
if res.Conflict {
|
||||||
|
return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base)
|
||||||
|
}
|
||||||
|
if res.Merged {
|
||||||
|
return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("No merge was performed for `%s`.", name)
|
||||||
|
|
||||||
|
case "dispose":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return "Usage: /plan worktrees dispose <name> [force]"
|
||||||
|
}
|
||||||
|
name := args[1]
|
||||||
|
force := len(args) > 2 && strings.EqualFold(args[2], "force")
|
||||||
|
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, git.ErrInvalidWorktreeName) {
|
||||||
|
return "Invalid worktree name."
|
||||||
|
}
|
||||||
|
if errors.Is(err, git.ErrWorktreeNotFound) {
|
||||||
|
return fmt.Sprintf("Worktree %q not found.", name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
if wt.HasUncommitted && !force {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.",
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
|
||||||
|
}
|
||||||
|
parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)}
|
||||||
|
if res.AutoCommitted {
|
||||||
|
parts = append(parts, "Uncommitted changes were auto-committed.")
|
||||||
|
}
|
||||||
|
if res.CommitsAhead > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead))
|
||||||
|
}
|
||||||
|
if res.BranchDeleted {
|
||||||
|
parts = append(parts, "Branch was deleted (no unique commits).")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Usage: /plan worktrees [list|inspect <name>|merge <name>|dispose <name> [force]]"
|
||||||
|
}
|
||||||
|
|
||||||
func isPlanPreExecution(status string) bool {
|
func isPlanPreExecution(status string) bool {
|
||||||
return status == "interviewing" || status == "review"
|
return status == "interviewing" || status == "review"
|
||||||
}
|
}
|
||||||
|
|
@ -4038,7 +4198,7 @@ func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string,
|
||||||
// Known subcommands are handled by handlePlanCommand (fast path).
|
// Known subcommands are handled by handlePlanCommand (fast path).
|
||||||
firstWord := strings.Fields(task)[0]
|
firstWord := strings.Fields(task)[0]
|
||||||
switch firstWord {
|
switch firstWord {
|
||||||
case "clear", "done", "add", "start", "next":
|
case "clear", "done", "add", "start", "next", "worktrees":
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WorktreeInfo describes an active git worktree.
|
// WorktreeInfo describes an active git worktree.
|
||||||
|
|
@ -19,6 +23,24 @@ type WorktreeInfo struct {
|
||||||
RepoRoot string // main repo root
|
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.
|
// DisposeResult describes what happened when a worktree was disposed.
|
||||||
type DisposeResult struct {
|
type DisposeResult struct {
|
||||||
Branch string
|
Branch string
|
||||||
|
|
@ -221,30 +243,258 @@ func MergeWorktreeBranch(repoDir string, wt *WorktreeInfo) MergeResult {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneOrphaned runs git worktree prune and removes dirs in worktreesDir
|
// ListManagedWorktrees returns active worktree summaries under worktreesDir.
|
||||||
// that aren't valid git worktrees.
|
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) {
|
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 := exec.Command("git", "worktree", "prune")
|
||||||
pruneCmd.Dir = repoDir
|
pruneCmd.Dir = repoDir
|
||||||
pruneCmd.Run() // best-effort
|
pruneCmd.Run() // best-effort
|
||||||
|
|
||||||
entries, err := os.ReadDir(worktreesDir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
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 {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
wtPath := filepath.Join(worktreesDir, entry.Name())
|
wtPath := filepath.Clean(filepath.Join(worktreesDir, entry.Name()))
|
||||||
// Check if it's still a valid git worktree
|
|
||||||
checkCmd := exec.Command("git", "rev-parse", "--git-dir")
|
if !isLinkedWorktree(wtPath) {
|
||||||
checkCmd.Dir = wtPath
|
_ = os.RemoveAll(wtPath)
|
||||||
if err := checkCmd.Run(); err != nil {
|
continue
|
||||||
// Not a valid git worktree — remove
|
|
||||||
os.RemoveAll(wtPath)
|
|
||||||
}
|
}
|
||||||
|
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))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,3 +516,100 @@ func BranchBaseName(branch string) string {
|
||||||
}
|
}
|
||||||
return b.String()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -297,3 +298,135 @@ func TestPruneOrphaned(t *testing.T) {
|
||||||
t.Error("orphaned dir should have been removed")
|
t.Error("orphaned dir should have been removed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagedWorktree_ListAndGet(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||||
|
wtPath := filepath.Join(worktreesDir, "managed-list")
|
||||||
|
|
||||||
|
if _, err := CreateWorktree(dir, wtPath, "plan/managed-list"); err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := ListManagedWorktrees(dir, worktreesDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListManagedWorktrees: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 managed worktree, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Name != "managed-list" {
|
||||||
|
t.Errorf("Name = %q, want %q", items[0].Name, "managed-list")
|
||||||
|
}
|
||||||
|
if items[0].Branch != "plan/managed-list" {
|
||||||
|
t.Errorf("Branch = %q, want %q", items[0].Branch, "plan/managed-list")
|
||||||
|
}
|
||||||
|
if items[0].Path != wtPath {
|
||||||
|
t.Errorf("Path = %q, want %q", items[0].Path, wtPath)
|
||||||
|
}
|
||||||
|
if items[0].HasUncommitted {
|
||||||
|
t.Error("HasUncommitted should be false for clean worktree")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := GetManagedWorktree(dir, worktreesDir, "../bad"); !errors.Is(err, ErrInvalidWorktreeName) {
|
||||||
|
t.Fatalf("expected ErrInvalidWorktreeName, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := GetManagedWorktree(dir, worktreesDir, "missing"); !errors.Is(err, ErrWorktreeNotFound) {
|
||||||
|
t.Fatalf("expected ErrWorktreeNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeManagedWorktree(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
baseBranch := CurrentBranch(dir)
|
||||||
|
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||||
|
wtPath := filepath.Join(worktreesDir, "managed-merge")
|
||||||
|
|
||||||
|
if _, err := CreateWorktree(dir, wtPath, "plan/managed-merge"); err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(wtPath, "merged-managed.txt"), []byte("hello"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
if err := AutoCommit(wtPath, "add merged-managed.txt"); err != nil {
|
||||||
|
t.Fatalf("AutoCommit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, usedBase, err := MergeManagedWorktree(dir, worktreesDir, "managed-merge", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MergeManagedWorktree: %v", err)
|
||||||
|
}
|
||||||
|
if usedBase == "" {
|
||||||
|
t.Fatal("used base branch should not be empty")
|
||||||
|
}
|
||||||
|
if !res.Merged {
|
||||||
|
t.Fatal("expected Merged=true")
|
||||||
|
}
|
||||||
|
if res.Conflict {
|
||||||
|
t.Fatal("expected Conflict=false")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "merged-managed.txt")); os.IsNotExist(err) {
|
||||||
|
t.Fatal("merged-managed.txt should exist after merge")
|
||||||
|
}
|
||||||
|
if branch := CurrentBranch(dir); branch != baseBranch {
|
||||||
|
t.Errorf("CurrentBranch after merge = %q, want %q", branch, baseBranch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisposeManagedWorktree(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||||
|
wtPath := filepath.Join(worktreesDir, "managed-dispose")
|
||||||
|
|
||||||
|
if _, err := CreateWorktree(dir, wtPath, "plan/managed-dispose"); err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(wtPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := DisposeManagedWorktree(dir, worktreesDir, "managed-dispose", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DisposeManagedWorktree: %v", err)
|
||||||
|
}
|
||||||
|
if !res.AutoCommitted {
|
||||||
|
t.Error("AutoCommitted should be true")
|
||||||
|
}
|
||||||
|
if res.CommitsAhead != 1 {
|
||||||
|
t.Errorf("CommitsAhead = %d, want 1", res.CommitsAhead)
|
||||||
|
}
|
||||||
|
if res.BranchDeleted {
|
||||||
|
t.Error("BranchDeleted should be false when branch has unique commits")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneOrphaned_AutoCommitBeforeRemoval(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
baseBranch := CurrentBranch(dir)
|
||||||
|
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||||
|
wtPath := filepath.Join(worktreesDir, "prune-autosave")
|
||||||
|
|
||||||
|
wt, err := CreateWorktree(dir, wtPath, "plan/prune-autosave")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(wtPath, "autosave.txt"), []byte("autosave"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
if !HasUncommittedChanges(wtPath) {
|
||||||
|
t.Fatal("worktree should have uncommitted changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
otherRepo := initTestRepo(t)
|
||||||
|
PruneOrphaned(otherRepo, worktreesDir)
|
||||||
|
|
||||||
|
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
||||||
|
}
|
||||||
|
if ahead := CommitsAhead(dir, baseBranch, wt.Branch); ahead != 1 {
|
||||||
|
t.Fatalf("CommitsAhead = %d, want 1 (auto-commit should be preserved)", ahead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@ package miniapp
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -54,6 +58,109 @@ func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoRoot := git.FindRepoRoot(h.workspace)
|
||||||
|
if repoRoot == "" {
|
||||||
|
http.Error(w, `{"error":"workspace is not a git repository"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
worktreesDir := filepath.Join(h.workspace, ".worktrees")
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to list worktrees"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, items)
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
BaseBranch string `json:"base_branch"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Action = strings.ToLower(strings.TrimSpace(req.Action))
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.BaseBranch = strings.TrimSpace(req.BaseBranch)
|
||||||
|
if req.Action == "" || req.Name == "" {
|
||||||
|
http.Error(w, `{"error":"action and name are required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Action {
|
||||||
|
case "merge":
|
||||||
|
res, baseBranch, err := git.MergeManagedWorktree(repoRoot, worktreesDir, req.Name, req.BaseBranch)
|
||||||
|
if err != nil {
|
||||||
|
if writeWorktreeAPIError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"error":"merge failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"action": "merge",
|
||||||
|
"name": req.Name,
|
||||||
|
"base_branch": baseBranch,
|
||||||
|
"result": res,
|
||||||
|
})
|
||||||
|
|
||||||
|
case "dispose":
|
||||||
|
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
if writeWorktreeAPIError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"error":"failed to inspect worktree"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wt.HasUncommitted && !req.Force {
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
`{"error":"worktree has uncommitted changes; retry with force=true"}`,
|
||||||
|
http.StatusConflict,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, req.Name, req.BaseBranch)
|
||||||
|
if err != nil {
|
||||||
|
if writeWorktreeAPIError(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"error":"dispose failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"action": "dispose",
|
||||||
|
"name": req.Name,
|
||||||
|
"result": res,
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
|
@ -159,4 +266,17 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeWorktreeAPIError(w http.ResponseWriter, err error) bool {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, git.ErrInvalidWorktreeName):
|
||||||
|
http.Error(w, `{"error":"invalid worktree name"}`, http.StatusBadRequest)
|
||||||
|
return true
|
||||||
|
case errors.Is(err, git.ErrWorktreeNotFound):
|
||||||
|
http.Error(w, `{"error":"worktree not found"}`, http.StatusNotFound)
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// apiDevConsole receives console output from dev preview iframes.
|
// apiDevConsole receives console output from dev preview iframes.
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
||||||
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
||||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||||
|
mux.HandleFunc("/miniapp/api/worktrees", h.requireAuth(h.apiWorktrees))
|
||||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
||||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||||
mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs))
|
mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -19,6 +22,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
gitpkg "github.com/sipeed/picoclaw/pkg/git"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/stats"
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
)
|
)
|
||||||
|
|
@ -2072,3 +2076,135 @@ func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Du
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initMiniAppGitRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
repo := t.TempDir()
|
||||||
|
|
||||||
|
runGit := func(dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %s: %s: %v", strings.Join(args, " "), strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runGit(repo, "init")
|
||||||
|
runGit(repo, "config", "user.email", "test@test.com")
|
||||||
|
runGit(repo, "config", "user.name", "Test")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Test\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
runGit(repo, "add", "-A")
|
||||||
|
runGit(repo, "commit", "-m", "initial")
|
||||||
|
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIWorktrees_List(t *testing.T) {
|
||||||
|
repo := initMiniAppGitRepo(t)
|
||||||
|
wtPath := filepath.Join(repo, ".worktrees", "api-list")
|
||||||
|
if _, err := gitpkg.CreateWorktree(repo, wtPath, "plan/api-list"); err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, repo)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 worktree, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Name != "api-list" {
|
||||||
|
t.Errorf("Name = %q, want %q", items[0].Name, "api-list")
|
||||||
|
}
|
||||||
|
if items[0].Branch != "plan/api-list" {
|
||||||
|
t.Errorf("Branch = %q, want %q", items[0].Branch, "plan/api-list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIWorktrees_MergeAndDispose(t *testing.T) {
|
||||||
|
repo := initMiniAppGitRepo(t)
|
||||||
|
|
||||||
|
mergePath := filepath.Join(repo, ".worktrees", "api-merge")
|
||||||
|
if _, err := gitpkg.CreateWorktree(repo, mergePath, "plan/api-merge"); err != nil {
|
||||||
|
t.Fatalf("CreateWorktree merge: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(mergePath, "merged.txt"), []byte("from worktree"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile merge: %v", err)
|
||||||
|
}
|
||||||
|
if err := gitpkg.AutoCommit(mergePath, "add merged.txt"); err != nil {
|
||||||
|
t.Fatalf("AutoCommit merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disposePath := filepath.Join(repo, ".worktrees", "api-dispose")
|
||||||
|
if _, err := gitpkg.CreateWorktree(repo, disposePath, "plan/api-dispose"); err != nil {
|
||||||
|
t.Fatalf("CreateWorktree dispose: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(disposePath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile dispose: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, repo)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
mergeReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
||||||
|
strings.NewReader(`{"action":"merge","name":"api-merge"}`),
|
||||||
|
)
|
||||||
|
mergeReq.Header.Set("Content-Type", "application/json")
|
||||||
|
mergeW := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(mergeW, mergeReq)
|
||||||
|
if mergeW.Code != http.StatusOK {
|
||||||
|
t.Fatalf("merge expected 200, got %d: %s", mergeW.Code, mergeW.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(repo, "merged.txt")); os.IsNotExist(err) {
|
||||||
|
t.Fatal("merged.txt should exist after merge")
|
||||||
|
}
|
||||||
|
|
||||||
|
disposeReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
||||||
|
strings.NewReader(`{"action":"dispose","name":"api-dispose"}`),
|
||||||
|
)
|
||||||
|
disposeReq.Header.Set("Content-Type", "application/json")
|
||||||
|
disposeW := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(disposeW, disposeReq)
|
||||||
|
if disposeW.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("dispose without force expected 409, got %d: %s", disposeW.Code, disposeW.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
disposeForceReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
||||||
|
strings.NewReader(`{"action":"dispose","name":"api-dispose","force":true}`),
|
||||||
|
)
|
||||||
|
disposeForceReq.Header.Set("Content-Type", "application/json")
|
||||||
|
disposeForceW := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(disposeForceW, disposeForceReq)
|
||||||
|
if disposeForceW.Code != http.StatusOK {
|
||||||
|
t.Fatalf("dispose with force expected 200, got %d: %s", disposeForceW.Code, disposeForceW.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(disposePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,90 @@
|
||||||
}
|
}
|
||||||
.git-back-btn:active { opacity: 0.6; }
|
.git-back-btn:active { opacity: 0.6; }
|
||||||
|
|
||||||
|
.worktree-list {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.worktree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
}
|
||||||
|
.worktree-item.dirty {
|
||||||
|
border-color: rgba(255, 152, 0, 0.45);
|
||||||
|
}
|
||||||
|
.worktree-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.worktree-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.worktree-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.worktree-branch {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--hint);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.worktree-last {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--hint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.worktree-dirty,
|
||||||
|
.worktree-clean {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.worktree-dirty {
|
||||||
|
color: #c26b00;
|
||||||
|
background: rgba(255, 152, 0, 0.2);
|
||||||
|
}
|
||||||
|
.worktree-clean {
|
||||||
|
color: #1b8f3a;
|
||||||
|
background: rgba(76, 175, 80, 0.18);
|
||||||
|
}
|
||||||
|
.worktree-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.worktree-btn {
|
||||||
|
border: 1px solid var(--glass-border-interactive);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 74px;
|
||||||
|
}
|
||||||
|
.worktree-btn.merge { color: var(--btn); }
|
||||||
|
.worktree-btn.dispose { color: #d14b4b; }
|
||||||
|
.worktree-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
/* Dev header */
|
/* Dev header */
|
||||||
.dev-header {
|
.dev-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1498,23 +1582,72 @@ var gitSelectedRepo = null;
|
||||||
|
|
||||||
function loadGit() {
|
function loadGit() {
|
||||||
gitSelectedRepo = null;
|
gitSelectedRepo = null;
|
||||||
return loadTab('git-loading', 'git-content', 'repositories',
|
return loadTab('git-loading', 'git-content', 'git',
|
||||||
function() { return apiFetch('/miniapp/api/git'); },
|
function() {
|
||||||
renderGitRepos);
|
return Promise.all([
|
||||||
|
apiFetch('/miniapp/api/git'),
|
||||||
|
apiFetch('/miniapp/api/worktrees').catch(function() { return []; }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
function(results) {
|
||||||
|
renderGitRepos(results[0], results[1]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGitRepos(repos) {
|
function renderWorktrees(worktrees) {
|
||||||
|
var items = Array.isArray(worktrees) ? worktrees : [];
|
||||||
|
var html = '<div class="card glass"><div class="card-title">Worktrees</div>';
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
html += '<div class="empty-state" style="padding:12px 0 4px">No active worktrees.</div>';
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="worktree-list">';
|
||||||
|
items.forEach(function(wt) {
|
||||||
|
var dirtyClass = wt.has_uncommitted ? ' dirty' : '';
|
||||||
|
var dirtyBadge = wt.has_uncommitted ? '<span class="worktree-dirty">DIRTY</span>' : '<span class="worktree-clean">CLEAN</span>';
|
||||||
|
var last = '(no commits)';
|
||||||
|
if (wt.last_commit_hash) {
|
||||||
|
last = wt.last_commit_hash + ' ' + (wt.last_commit_subject || '');
|
||||||
|
if (wt.last_commit_age) last += ' (' + wt.last_commit_age + ')';
|
||||||
|
}
|
||||||
|
html += '<div class="worktree-item' + dirtyClass + '">' +
|
||||||
|
'<div class="worktree-main">' +
|
||||||
|
'<div class="worktree-name-row">' +
|
||||||
|
'<span class="worktree-name">' + escapeHtml(wt.name) + '</span>' +
|
||||||
|
dirtyBadge +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="worktree-branch">' + escapeHtml(wt.branch || '?') + '</div>' +
|
||||||
|
'<div class="worktree-last">' + escapeHtml(last) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="worktree-actions">' +
|
||||||
|
'<button class="worktree-btn merge" data-wt-action="merge" data-wt-name="' + escapeAttr(wt.name) + '">Merge</button>' +
|
||||||
|
'<button class="worktree-btn dispose" data-wt-action="dispose" data-wt-name="' + escapeAttr(wt.name) + '" data-wt-dirty="' + (wt.has_uncommitted ? '1' : '0') + '">Dispose</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += '</div></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGitRepos(repos, worktrees) {
|
||||||
var loading = document.getElementById('git-loading');
|
var loading = document.getElementById('git-loading');
|
||||||
var el = document.getElementById('git-content');
|
var el = document.getElementById('git-content');
|
||||||
loading.classList.add('hidden');
|
loading.classList.add('hidden');
|
||||||
el.classList.remove('hidden');
|
el.classList.remove('hidden');
|
||||||
|
|
||||||
|
var html = renderWorktrees(worktrees);
|
||||||
|
|
||||||
if (!repos || repos.length === 0) {
|
if (!repos || repos.length === 0) {
|
||||||
el.innerHTML = '<div class="empty-state">No git repositories found.</div>';
|
html += '<div class="empty-state" style="margin-top:12px">No git repositories found.</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = repos.map(function(r) {
|
html += '<div style="padding:10px 4px 8px;font-size:12px;color:var(--hint)">Repositories</div>';
|
||||||
|
html += repos.map(function(r) {
|
||||||
return '<div class="git-repo-item glass glass-interactive" data-repo="' + escapeAttr(r.name) + '">' +
|
return '<div class="git-repo-item glass glass-interactive" data-repo="' + escapeAttr(r.name) + '">' +
|
||||||
'<div class="git-repo-body">' +
|
'<div class="git-repo-body">' +
|
||||||
'<div class="git-repo-name">' + escapeHtml(r.name) + '</div>' +
|
'<div class="git-repo-name">' + escapeHtml(r.name) + '</div>' +
|
||||||
|
|
@ -1523,9 +1656,57 @@ function renderGitRepos(repos) {
|
||||||
'<span class="git-repo-arrow">\u203A</span>' +
|
'<span class="git-repo-arrow">\u203A</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postWorktreeAction(action, name, force) {
|
||||||
|
var res = await fetch(API_BASE + '/miniapp/api/worktrees?initData=' + encodeURIComponent(initData), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: action, name: name, force: !!force }),
|
||||||
|
});
|
||||||
|
var data = {};
|
||||||
|
try { data = await res.json(); } catch (e) {}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || ('API error: ' + res.status));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('git-content').addEventListener('click', async function(e) {
|
||||||
|
var wtBtn = e.target.closest('[data-wt-action]');
|
||||||
|
if (wtBtn) {
|
||||||
|
var action = wtBtn.dataset.wtAction;
|
||||||
|
var name = wtBtn.dataset.wtName;
|
||||||
|
var isDirty = wtBtn.dataset.wtDirty === '1';
|
||||||
|
var force = false;
|
||||||
|
|
||||||
|
if (action === 'merge') {
|
||||||
|
if (!confirm('Merge "' + name + '" into base branch?')) return;
|
||||||
|
} else if (action === 'dispose') {
|
||||||
|
if (isDirty) {
|
||||||
|
if (!confirm('"' + name + '" has uncommitted changes. Force dispose and auto-commit before removal?')) return;
|
||||||
|
force = true;
|
||||||
|
} else if (!confirm('Dispose worktree "' + name + '"?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalText = wtBtn.textContent;
|
||||||
|
wtBtn.disabled = true;
|
||||||
|
wtBtn.textContent = action === 'merge' ? 'Merging...' : 'Disposing...';
|
||||||
|
try {
|
||||||
|
await postWorktreeAction(action, name, force);
|
||||||
|
await loadGit();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Action failed');
|
||||||
|
wtBtn.disabled = false;
|
||||||
|
wtBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('git-content').addEventListener('click', function(e) {
|
|
||||||
var item = e.target.closest('.git-repo-item');
|
var item = e.target.closest('.git-repo-item');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
loadGitDetail(item.dataset.repo);
|
loadGitDetail(item.dataset.repo);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue