feat(task5): add worktree management commands and miniapp

This commit is contained in:
dj-oyu 2026-03-04 22:45:44 +09:00
parent e6de7d0d62
commit df451f87b9
8 changed files with 1101 additions and 23 deletions

View file

@ -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-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 |

View file

@ -3860,6 +3860,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).
@ -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 // 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"
} }
@ -4009,7 +4169,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
} }

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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);