diff --git a/CLAUDE.md b/CLAUDE.md index 26e08fa1d..a409cb3ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,4 +50,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-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-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) | diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 82d9dde26..88f23fd6b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -3860,6 +3860,9 @@ func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string phase := agent.ContextBuilder.GetCurrentPhase() return fmt.Sprintf("Advanced to phase %d.", phase), true + case "worktrees": + return al.handlePlanWorktreesCommand(agent, args[1:]), true + default: // /plan — start new plan // Block if a plan is already active (fast-path error). @@ -3874,6 +3877,163 @@ func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string // isPlanPreExecution returns true if the plan is in a pre-execution state // (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 \n") + sb.WriteString("/plan worktrees merge \n") + sb.WriteString("/plan worktrees dispose [force]") + return sb.String() + + case "inspect": + if len(args) < 2 { + return "Usage: /plan worktrees inspect " + } + 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 := 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 [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 |merge |dispose [force]]" +} + func isPlanPreExecution(status string) bool { return status == "interviewing" || status == "review" } @@ -4009,7 +4169,7 @@ func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, // Known subcommands are handled by handlePlanCommand (fast path). firstWord := strings.Fields(task)[0] switch firstWord { - case "clear", "done", "add", "start", "next": + case "clear", "done", "add", "start", "next", "worktrees": return "", "", false } diff --git a/pkg/git/worktree.go b/pkg/git/worktree.go index d733d1428..9e75f3c5f 100644 --- a/pkg/git/worktree.go +++ b/pkg/git/worktree.go @@ -1,14 +1,18 @@ 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. @@ -19,6 +23,24 @@ type WorktreeInfo struct { 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 @@ -221,30 +243,258 @@ func MergeWorktreeBranch(repoDir string, wt *WorktreeInfo) MergeResult { return result } -// PruneOrphaned runs git worktree prune and removes dirs in worktreesDir -// that aren't valid git worktrees. -func PruneOrphaned(repoDir, worktreesDir string) { - pruneCmd := exec.Command("git", "worktree", "prune") - pruneCmd.Dir = repoDir - pruneCmd.Run() // best-effort - +// 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/ + 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.Join(worktreesDir, entry.Name()) - // Check if it's still a valid git worktree - checkCmd := exec.Command("git", "rev-parse", "--git-dir") - checkCmd.Dir = wtPath - if err := checkCmd.Run(); err != nil { - // Not a valid git worktree — remove - os.RemoveAll(wtPath) + 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))}) } } @@ -266,3 +516,100 @@ func BranchBaseName(branch string) 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 +} diff --git a/pkg/git/worktree_test.go b/pkg/git/worktree_test.go index a80db5376..ef0c1fe09 100644 --- a/pkg/git/worktree_test.go +++ b/pkg/git/worktree_test.go @@ -1,6 +1,7 @@ package git import ( + "errors" "os" "os/exec" "path/filepath" @@ -297,3 +298,135 @@ func TestPruneOrphaned(t *testing.T) { 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) + } +} diff --git a/pkg/miniapp/api.go b/pkg/miniapp/api.go index a385c8a54..69c90e56a 100644 --- a/pkg/miniapp/api.go +++ b/pkg/miniapp/api.go @@ -3,11 +3,15 @@ package miniapp import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" + "path/filepath" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/git" ) 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) { if r.Method != http.MethodPost { 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) } +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. diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 1a0e05e04..7ed3c0403 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -76,6 +76,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext)) mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt)) 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/events", h.requireAuth(h.apiEvents)) mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs)) diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index f1dc1e74a..218927756 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -11,6 +11,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "os/exec" + "path/filepath" "runtime" "sort" "strconv" @@ -19,6 +22,7 @@ import ( "testing" "time" + gitpkg "github.com/sipeed/picoclaw/pkg/git" "github.com/sipeed/picoclaw/pkg/skills" "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 } + +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) + } +} diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 410daa7db..13e46de78 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -633,6 +633,90 @@ } .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 { display: flex; @@ -1498,23 +1582,72 @@ var gitSelectedRepo = null; function loadGit() { gitSelectedRepo = null; - return loadTab('git-loading', 'git-content', 'repositories', - function() { return apiFetch('/miniapp/api/git'); }, - renderGitRepos); + return loadTab('git-loading', 'git-content', 'git', + function() { + 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 = '
Worktrees
'; + + if (items.length === 0) { + html += '
No active worktrees.
'; + html += '
'; + return html; + } + + html += '
'; + items.forEach(function(wt) { + var dirtyClass = wt.has_uncommitted ? ' dirty' : ''; + var dirtyBadge = wt.has_uncommitted ? 'DIRTY' : 'CLEAN'; + 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 += '
' + + '
' + + '
' + + '' + escapeHtml(wt.name) + '' + + dirtyBadge + + '
' + + '
' + escapeHtml(wt.branch || '?') + '
' + + '
' + escapeHtml(last) + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }); + html += '
'; + return html; +} + +function renderGitRepos(repos, worktrees) { var loading = document.getElementById('git-loading'); var el = document.getElementById('git-content'); loading.classList.add('hidden'); el.classList.remove('hidden'); + var html = renderWorktrees(worktrees); + if (!repos || repos.length === 0) { - el.innerHTML = '
No git repositories found.
'; + html += '
No git repositories found.
'; + el.innerHTML = html; return; } - el.innerHTML = repos.map(function(r) { + html += '
Repositories
'; + html += repos.map(function(r) { return '
' + '
' + '
' + escapeHtml(r.name) + '
' + @@ -1523,9 +1656,57 @@ function renderGitRepos(repos) { '\u203A' + '
'; }).join(''); + + el.innerHTML = html; } -document.getElementById('git-content').addEventListener('click', function(e) { +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; + } + var item = e.target.closest('.git-repo-item'); if (!item) return; loadGitDetail(item.dataset.repo);