feat: add git worktree isolation for per-session plan/heartbeat execution

Each session (user plan, heartbeat) gets an independent git worktree,
preventing concurrent sessions from conflicting on the same working tree.

Key changes:
- pkg/git/worktree.go: Git worktree CRUD with safe disposal (auto-commit
  before removing, branch retained if it has unique commits)
- pkg/tools/workspace_ctx.go: Context-based filesystem redirect that
  transparently routes file/edit/shell tools to the session's worktree
  while keeping memory/ paths on the original workspace
- Per-session worktree state on AgentInstance with activate/deactivate
- SessionTracker extended with project coordination (purpose, branch)
  and GetPeerPurposes for lightweight cross-session awareness
- System prompt injection of peer session info to avoid conflicts
- Loop integration: worktree activation on /plan start, lazy creation
  for heartbeat on first write-tool, safe disposal on plan completion
  with merge instructions, cleanup after heartbeat
- Shell guard: block git checkout/switch to prevent branch escapes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 12:24:51 +09:00
parent 615a1fabc5
commit 53c4398487
14 changed files with 845 additions and 36 deletions

View file

@ -381,12 +381,15 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
result := make([]miniapp.SessionInfo, len(entries))
for i, e := range entries {
result[i] = miniapp.SessionInfo{
SessionKey: e.SessionKey,
Channel: e.Channel,
ChatID: e.ChatID,
TouchDir: e.TouchDir,
LastSeenAt: e.LastSeenAt.Format(time.RFC3339),
AgeSec: int(time.Since(e.LastSeenAt).Seconds()),
SessionKey: e.SessionKey,
Channel: e.Channel,
ChatID: e.ChatID,
TouchDir: e.TouchDir,
ProjectPath: e.ProjectPath,
Purpose: e.Purpose,
Branch: e.Branch,
LastSeenAt: e.LastSeenAt.Format(time.RFC3339),
AgeSec: int(time.Since(e.LastSeenAt).Seconds()),
}
}
return result

View file

@ -19,6 +19,7 @@ type ContextBuilder struct {
skillsLoader *skills.SkillsLoader
memory *MemoryStore
tools *tools.ToolRegistry // Direct reference to tool registry
peerNote string // set per-call from loop.go for peer session awareness
}
func getGlobalConfigDir() string {
@ -48,6 +49,11 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
cb.tools = registry
}
// SetPeerNote sets the peer session awareness note for the current call.
func (cb *ContextBuilder) SetPeerNote(note string) {
cb.peerNote = note
}
func (cb *ContextBuilder) getIdentity() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
@ -169,6 +175,11 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
}
}
// Peer session coordination
if cb.peerNote != "" {
parts = append(parts, "## Active Sessions\n\n"+cb.peerNote)
}
// Memory context
memoryContext := cb.memory.GetMemoryContext()
if memoryContext != "" {
@ -449,6 +460,11 @@ func (cb *ContextBuilder) GetPlanWorkDir() string {
return cb.memory.GetPlanWorkDir()
}
// GetPlanTaskName returns the task description from the plan metadata, or "".
func (cb *ContextBuilder) GetPlanTaskName() string {
return cb.memory.GetPlanTaskName()
}
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()

View file

@ -1,11 +1,14 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/git"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
@ -39,6 +42,10 @@ type AgentInstance struct {
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
interviewStaleCount int
interviewMemoryLen int
// Per-session worktree isolation
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
worktreeMu sync.RWMutex
}
// NewAgentInstance creates an agent instance from config.
@ -123,6 +130,12 @@ func NewAgentInstance(
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
}
// Startup cleanup: prune orphaned worktrees
worktreesDir := filepath.Join(workspace, ".picoclaw", "worktrees")
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
git.PruneOrphaned(repoRoot, worktreesDir)
}
return &AgentInstance{
ID: agentID,
Name: agentName,
@ -192,6 +205,88 @@ func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDe
return defaults.PlanModelFallbacks
}
// ActivateWorktree creates a worktree for a session.
// Path: <workspace>/.picoclaw/worktrees/<branch-basename>/
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName string) (*git.WorktreeInfo, error) {
repoRoot := git.FindRepoRoot(ai.Workspace)
if repoRoot == "" {
return nil, fmt.Errorf("workspace is not a git repository")
}
branchName := git.SanitizeBranchName(taskName)
baseName := git.BranchBaseName(branchName)
wtPath := filepath.Join(ai.Workspace, ".picoclaw", "worktrees", baseName)
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
if err != nil {
return nil, err
}
ai.worktreeMu.Lock()
if ai.worktrees == nil {
ai.worktrees = make(map[string]*git.WorktreeInfo)
}
ai.worktrees[sessionKey] = wt
ai.worktreeMu.Unlock()
return wt, nil
}
// DeactivateWorktree safe-disposes the session's worktree.
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
ai.worktreeMu.Lock()
wt, ok := ai.worktrees[sessionKey]
if ok {
delete(ai.worktrees, sessionKey)
}
ai.worktreeMu.Unlock()
if !ok || wt == nil {
return nil, nil
}
repoRoot := git.FindRepoRoot(ai.Workspace)
if repoRoot == "" {
return nil, fmt.Errorf("workspace is not a git repository")
}
// Even on discard, SafeDispose auto-commits first for safety
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
_ = git.AutoCommit(wt.Path, commitMsg)
}
result := git.SafeDispose(repoRoot, wt)
return &result, nil
}
// GetWorktree returns the session's active worktree, or nil.
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
ai.worktreeMu.RLock()
defer ai.worktreeMu.RUnlock()
return ai.worktrees[sessionKey]
}
// IsInWorktree returns true if the session has an active worktree.
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
return ai.GetWorktree(sessionKey) != nil
}
// EffectiveWorkspace returns worktree path for session, or original Workspace.
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
if wt := ai.GetWorktree(sessionKey); wt != nil {
return wt.Path
}
return ai.Workspace
}
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
if wt := ai.GetWorktree(sessionKey); wt != nil {
return wt.Branch
}
return ""
}
func expandHome(path string) string {
if path == "" {
return path

View file

@ -300,6 +300,19 @@ func (al *AgentLoop) Run(ctx context.Context) error {
}
}
// Activate worktree for the session's plan execution
if agent := al.registry.GetDefaultAgent(); agent != nil {
taskName := agent.ContextBuilder.Memory().GetPlanTaskName()
if taskName == "" {
taskName = "plan-execution"
}
if wt, err := agent.ActivateWorktree(msg.SessionKey, taskName); err != nil {
logger.WarnCF("agent", "Worktree activation skipped", map[string]any{"error": err.Error()})
} else {
logger.InfoCF("agent", "Worktree activated", map[string]any{"branch": wt.Branch})
}
}
syntheticMeta := map[string]string{"echoed": "1"}
for k, v := range msg.Metadata {
if k != "source" {
@ -768,6 +781,24 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 1. Update tool contexts
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
// 1b. Inject peer session awareness into system prompt
projectPath := agent.ContextBuilder.GetPlanWorkDir()
if projectPath == "" {
projectPath = agent.Workspace
}
peers := al.sessions.GetPeerPurposes(opts.SessionKey, projectPath)
if len(peers) > 0 {
var peerNote strings.Builder
peerNote.WriteString("Other sessions working on this project:\n")
for _, p := range peers {
peerNote.WriteString(fmt.Sprintf("- %s: %s (branch: %s)\n", p.SessionKey, p.Purpose, p.Branch))
}
peerNote.WriteString("\nAvoid conflicting changes with these sessions.")
agent.ContextBuilder.SetPeerNote(peerNote.String())
} else {
agent.ContextBuilder.SetPeerNote("")
}
// 2. Build messages (skip history for heartbeat)
var history []providers.Message
var summary string
@ -889,11 +920,21 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
_ = agent.ContextBuilder.SetCurrentPhase(total)
if preStatus != "completed" {
_ = agent.ContextBuilder.SetPlanStatus("completed")
// Deactivate worktree on plan completion
commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName()
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
if !constants.IsInternalChannel(opts.Channel) {
msg := "\u2705 Plan completed!"
if wtResult != nil && wtResult.CommitsAhead > 0 {
msg += fmt.Sprintf("\nBranch `%s` retained (%d commits). To merge: `git merge %s`",
wtResult.Branch, wtResult.CommitsAhead, wtResult.Branch)
}
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: "\u2705 Plan completed!",
Content: msg,
SkipPlaceholder: true,
})
}
@ -962,6 +1003,20 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
"final_length": len(finalContent),
})
// 10. Heartbeat worktree cleanup: auto-commit and dispose after background task
if opts.Background && agent.IsInWorktree(opts.SessionKey) {
commitMsg := "heartbeat: auto-save"
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).",
wtResult.Branch, wtResult.CommitsAhead),
})
}
}
return finalContent, nil
}
@ -1950,7 +2005,15 @@ func (al *AgentLoop) runLLMIteration(
}
}
if detectedDir != "" {
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir)
meta := &TouchMeta{
ProjectPath: agent.ContextBuilder.GetPlanWorkDir(),
Purpose: utils.Truncate(opts.UserMessage, 80),
Branch: agent.GetWorktreeBranch(opts.SessionKey),
}
if meta.ProjectPath == "" {
meta.ProjectPath = agent.Workspace
}
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta)
}
}
@ -1998,6 +2061,14 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration,
})
// Heartbeat lazy worktree: create worktree on first write-tool call
if opts.Background && isWriteTool(tc.Name) && !agent.IsInWorktree(opts.SessionKey) {
taskName := "heartbeat-" + time.Now().Format("20060102")
if wt, err := agent.ActivateWorktree(opts.SessionKey, taskName); err == nil {
logger.InfoCF("agent", "Heartbeat worktree created", map[string]any{"branch": wt.Branch})
}
}
// Create async callback for tools that implement AsyncTool
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
// Instead, they notify the agent via PublishInbound, and the agent decides
@ -2015,7 +2086,11 @@ func (al *AgentLoop) runLLMIteration(
}
toolStart := time.Now()
toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
toolCtx := ctx
if wt := agent.GetWorktree(opts.SessionKey); wt != nil {
toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path)
}
toolResult := agent.Tools.ExecuteWithContext(toolCtx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
toolDuration := time.Since(toolStart)
// Update tool log entry with result
@ -2619,7 +2694,7 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
return al.handleSkillsCommand(), true
case "/plan":
resp, handled := al.handlePlanCommand(args)
resp, handled := al.handlePlanCommand(args, msg.SessionKey)
if handled {
al.notifyStateChange()
}
@ -2739,7 +2814,7 @@ func (al *AgentLoop) handleSkillsCommand() string {
// Returns (response, handled). For "/plan <task>" (new plan), it returns
// ("", false) so the message falls through to the LLM queue, where
// expandPlanCommand writes the seed and rewrites the content.
func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) {
func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured.", true
@ -2756,6 +2831,10 @@ func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) {
if agent.ContextBuilder.ReadMemory() == "" {
return "No active plan to clear.", true
}
// Deactivate worktree on plan clear
if sessionKey != "" {
agent.DeactivateWorktree(sessionKey, "", true)
}
if err := agent.ContextBuilder.ClearMemory(); err != nil {
return fmt.Sprintf("Error clearing plan: %v", err), true
}
@ -2945,6 +3024,15 @@ func isReadOnlyCommand(cmd string) bool {
return false
}
// isWriteTool returns true if the tool can modify files.
func isWriteTool(name string) bool {
switch tools.NormalizeToolName(name) {
case "writefile", "editfile", "appendfile", "exec":
return true
}
return false
}
// expandPlanCommand detects "/plan <task>" (new plan start) and:
// - writes the interview seed to MEMORY.md
// - rewrites the message content for the LLM

View file

@ -465,6 +465,19 @@ func (ms *MemoryStore) GetPlanWorkDir() string {
return strings.TrimSpace(m[1])
}
// reTaskLine extracts the task name from "> Task: <description>".
var reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`)
// GetPlanTaskName returns the task description from the plan metadata, or "".
func (ms *MemoryStore) GetPlanTaskName() string {
content := ms.ReadLongTerm()
m := reTaskLine.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
const interviewSeedTemplate = `# Active Plan

View file

@ -9,11 +9,28 @@ import (
// SessionEntry represents an active or recently-active session.
type SessionEntry struct {
SessionKey string `json:"session_key"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
TouchDir string `json:"touch_dir"`
LastSeenAt time.Time `json:"last_seen_at"`
SessionKey string `json:"session_key"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
TouchDir string `json:"touch_dir"`
ProjectPath string `json:"project_path,omitempty"` // canonical project path
Purpose string `json:"purpose,omitempty"` // 1-line task description
Branch string `json:"branch,omitempty"` // git branch name
LastSeenAt time.Time `json:"last_seen_at"`
}
// TouchMeta carries optional metadata for Touch calls.
type TouchMeta struct {
ProjectPath string // canonical project path (always original workspace-relative)
Purpose string // 1-line task description
Branch string // git branch name
}
// PeerInfo is the minimal info shared between sessions on the same project.
type PeerInfo struct {
SessionKey string
Purpose string
Branch string
}
// SessionTracker tracks per-session tool-call activity.
@ -32,7 +49,8 @@ const sessionActivityTimeout = 15 * time.Minute
// Touch records a tool-call activity for a session.
// dir is the workspace-relative directory the tool call targeted.
// If dir is empty, only LastSeenAt is updated.
func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) {
// meta is optional and carries project coordination metadata.
func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string, meta *TouchMeta) {
now := time.Now()
val, loaded := st.entries.Load(sessionKey)
if loaded {
@ -47,15 +65,32 @@ func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) {
if chatID != "" {
entry.ChatID = chatID
}
if meta != nil {
if meta.ProjectPath != "" {
entry.ProjectPath = meta.ProjectPath
}
if meta.Purpose != "" {
entry.Purpose = meta.Purpose
}
if meta.Branch != "" {
entry.Branch = meta.Branch
}
}
return
}
st.entries.Store(sessionKey, &SessionEntry{
entry := &SessionEntry{
SessionKey: sessionKey,
Channel: channel,
ChatID: chatID,
TouchDir: dir,
LastSeenAt: now,
})
}
if meta != nil {
entry.ProjectPath = meta.ProjectPath
entry.Purpose = meta.Purpose
entry.Branch = meta.Branch
}
st.entries.Store(sessionKey, entry)
}
// IsActiveInDir returns true if any session (excluding those matching excludeKey)
@ -96,3 +131,28 @@ func (st *SessionTracker) ListActive() []SessionEntry {
})
return result
}
// GetPeerPurposes returns purposes of other active sessions targeting the same project.
// Used for lightweight coordination without context pollution.
func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo {
if projectPath == "" {
return nil
}
cutoff := time.Now().Add(-sessionActivityTimeout)
var result []PeerInfo
st.entries.Range(func(key, val any) bool {
if key.(string) == sessionKey {
return true
}
entry := val.(*SessionEntry)
if entry.LastSeenAt.After(cutoff) && entry.ProjectPath == projectPath {
result = append(result, PeerInfo{
SessionKey: entry.SessionKey,
Purpose: entry.Purpose,
Branch: entry.Branch,
})
}
return true
})
return result
}

View file

@ -9,7 +9,7 @@ func TestTouch(t *testing.T) {
st := NewSessionTracker()
// Basic touch creates entry
st.Touch("sess1", "telegram", "123", "projects/myapp")
st.Touch("sess1", "telegram", "123", "projects/myapp", nil)
entries := st.ListActive()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
@ -25,7 +25,7 @@ func TestTouch(t *testing.T) {
}
// Touch again with new dir overwrites TouchDir
st.Touch("sess1", "", "", "projects/other")
st.Touch("sess1", "", "", "projects/other", nil)
entries = st.ListActive()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
@ -39,7 +39,7 @@ func TestTouch(t *testing.T) {
}
// Touch with empty dir does not overwrite TouchDir
st.Touch("sess1", "", "", "")
st.Touch("sess1", "", "", "", nil)
entries = st.ListActive()
if entries[0].TouchDir != "projects/other" {
t.Errorf("expected touch_dir unchanged, got %s", entries[0].TouchDir)
@ -50,7 +50,7 @@ func TestIsActiveInDir(t *testing.T) {
st := NewSessionTracker()
// Setup: sess1 touches "projects/myapp"
st.Touch("sess1", "telegram", "123", "projects/myapp")
st.Touch("sess1", "telegram", "123", "projects/myapp", nil)
// Same dir, excluding sess1 → false
if st.IsActiveInDir("projects/myapp", "sess1") {
@ -91,9 +91,9 @@ func TestListActive(t *testing.T) {
st := NewSessionTracker()
// Add two sessions
st.Touch("sess1", "telegram", "123", "projects/a")
st.Touch("sess1", "telegram", "123", "projects/a", nil)
time.Sleep(5 * time.Millisecond) // ensure different timestamps
st.Touch("sess2", "discord", "456", "projects/b")
st.Touch("sess2", "discord", "456", "projects/b", nil)
entries := st.ListActive()
if len(entries) != 2 {

241
pkg/git/worktree.go Normal file
View file

@ -0,0 +1,241 @@
package git
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
)
// WorktreeInfo describes an active git worktree.
type WorktreeInfo struct {
Path string // absolute worktree dir
Branch string // e.g. "plan/setup-monitoring"
BaseBranch string // branch forked from
RepoRoot string // main repo root
}
// DisposeResult describes what happened when a worktree was disposed.
type DisposeResult struct {
Branch string
AutoCommitted bool // true if uncommitted changes were saved
BranchDeleted bool // true if branch had no unique commits
CommitsAhead int // unique commits on branch (0 = safe to delete)
}
// FindRepoRoot returns the git repository root for dir, or "" if not a git repo.
func FindRepoRoot(dir string) string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// CurrentBranch returns the current branch name, or "" on error.
func CurrentBranch(dir string) string {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
var unsafeBranchRe = regexp.MustCompile(`[^a-z0-9-]`)
// SanitizeBranchName creates a safe branch name from a task description.
// Returns "plan/<safe-40-chars>".
func SanitizeBranchName(task string) string {
s := strings.ToLower(strings.TrimSpace(task))
s = unsafeBranchRe.ReplaceAllString(s, "-")
// Collapse consecutive hyphens
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
s = strings.Trim(s, "-")
if s == "" {
s = "worktree"
}
// Truncate to 40 chars
runes := []rune(s)
if len(runes) > 40 {
runes = runes[:40]
}
s = strings.TrimRight(string(runes), "-")
return "plan/" + s
}
// CreateWorktree creates a new git worktree at worktreePath with branchName.
// If the branch already exists, it reuses it.
func CreateWorktree(repoDir, worktreePath, branchName string) (*WorktreeInfo, error) {
baseBranch := CurrentBranch(repoDir)
if baseBranch == "" {
baseBranch = "HEAD"
}
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
return nil, fmt.Errorf("create worktree parent: %w", err)
}
// Check if branch already exists
checkCmd := exec.Command("git", "rev-parse", "--verify", branchName)
checkCmd.Dir = repoDir
branchExists := checkCmd.Run() == nil
var cmd *exec.Cmd
if branchExists {
// Reuse existing branch
cmd = exec.Command("git", "worktree", "add", worktreePath, branchName)
} else {
// Create new branch
cmd = exec.Command("git", "worktree", "add", "-b", branchName, worktreePath)
}
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
}
return &WorktreeInfo{
Path: worktreePath,
Branch: branchName,
BaseBranch: baseBranch,
RepoRoot: repoDir,
}, nil
}
// HasUncommittedChanges returns true if the working tree has staged or unstaged changes.
func HasUncommittedChanges(dir string) bool {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return false
}
return len(strings.TrimSpace(string(out))) > 0
}
// AutoCommit stages all changes and commits with the given message.
func AutoCommit(worktreePath, message string) error {
addCmd := exec.Command("git", "add", "-A")
addCmd.Dir = worktreePath
if out, err := addCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add: %s: %w", strings.TrimSpace(string(out)), err)
}
commitCmd := exec.Command("git", "commit", "-m", message, "--allow-empty-message")
commitCmd.Dir = worktreePath
if out, err := commitCmd.CombinedOutput(); err != nil {
// "nothing to commit" is not a real error
if strings.Contains(string(out), "nothing to commit") {
return nil
}
return fmt.Errorf("git commit: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// CommitsAhead returns the number of commits on branch that are not on base.
func CommitsAhead(repoDir, base, branch string) int {
cmd := exec.Command("git", "rev-list", "--count", base+".."+branch)
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
return n
}
// SafeDispose auto-commits uncommitted changes, removes the worktree directory,
// and deletes the branch ONLY if it has no unique commits.
func SafeDispose(repoDir string, wt *WorktreeInfo) DisposeResult {
result := DisposeResult{Branch: wt.Branch}
// 1. Auto-commit if there are uncommitted changes
if HasUncommittedChanges(wt.Path) {
msg := fmt.Sprintf("auto: save from %s", wt.Branch)
if err := AutoCommit(wt.Path, msg); err == nil {
result.AutoCommitted = true
}
}
// 2. Count unique commits
result.CommitsAhead = CommitsAhead(repoDir, wt.BaseBranch, wt.Branch)
// 3. Remove worktree
removeCmd := exec.Command("git", "worktree", "remove", "--force", wt.Path)
removeCmd.Dir = repoDir
removeCmd.Run() // best-effort
// 4. Delete branch if no unique commits
if result.CommitsAhead == 0 {
delCmd := exec.Command("git", "branch", "-D", wt.Branch)
delCmd.Dir = repoDir
if delCmd.Run() == nil {
result.BranchDeleted = true
}
}
// 5. Fallback cleanup
os.RemoveAll(wt.Path)
return result
}
// 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
entries, err := os.ReadDir(worktreesDir)
if err != nil {
return
}
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)
}
}
}
// BranchBaseName extracts the last segment of a branch name.
// "plan/add-auth" → "plan-add-auth"
func BranchBaseName(branch string) string {
s := strings.ReplaceAll(branch, "/", "-")
// Remove leading/trailing hyphens
s = strings.Trim(s, "-")
// Remove non-printable chars
var b strings.Builder
for _, r := range s {
if unicode.IsPrint(r) {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return "worktree"
}
return b.String()
}

218
pkg/git/worktree_test.go Normal file
View file

@ -0,0 +1,218 @@
package git
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestSanitizeBranchName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Add auth module", "plan/add-auth-module"},
{"", "plan/worktree"},
{" spaces ", "plan/spaces"},
{"UPPER-case_Mix", "plan/upper-case-mix"},
{"a/b/c", "plan/a-b-c"},
{"very long task name that exceeds the forty character limit for safety", "plan/very-long-task-name-that-exceeds-the-for"},
{"---leading-trailing---", "plan/leading-trailing"},
{"special!@#$%chars", "plan/special-chars"},
}
for _, tt := range tests {
got := SanitizeBranchName(tt.input)
if got != tt.want {
t.Errorf("SanitizeBranchName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestBranchBaseName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"plan/add-auth", "plan-add-auth"},
{"heartbeat/20260224", "heartbeat-20260224"},
{"main", "main"},
{"", "worktree"},
}
for _, tt := range tests {
got := BranchBaseName(tt.input)
if got != tt.want {
t.Errorf("BranchBaseName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// initTestRepo creates a temporary git repo with an initial commit.
func initTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %s: %v", out, err)
}
}
// Create initial commit
f := filepath.Join(dir, "README.md")
os.WriteFile(f, []byte("# Test\n"), 0o644)
add := exec.Command("git", "add", "-A")
add.Dir = dir
add.Run()
commit := exec.Command("git", "commit", "-m", "initial")
commit.Dir = dir
commit.Run()
return dir
}
func TestFindRepoRoot(t *testing.T) {
dir := initTestRepo(t)
root := FindRepoRoot(dir)
if root == "" {
t.Fatal("FindRepoRoot returned empty for valid repo")
}
// Non-repo should return ""
tmpDir := t.TempDir()
if got := FindRepoRoot(tmpDir); got != "" {
t.Errorf("FindRepoRoot(non-repo) = %q, want empty", got)
}
}
func TestCurrentBranch(t *testing.T) {
dir := initTestRepo(t)
branch := CurrentBranch(dir)
// Should be "main" or "master" depending on git config
if branch == "" {
t.Fatal("CurrentBranch returned empty for valid repo")
}
}
func TestCreateWorktreeAndDispose(t *testing.T) {
dir := initTestRepo(t)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-wt")
wt, err := CreateWorktree(dir, wtPath, "plan/test-feature")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
if wt.Path != wtPath {
t.Errorf("Path = %q, want %q", wt.Path, wtPath)
}
if wt.Branch != "plan/test-feature" {
t.Errorf("Branch = %q, want %q", wt.Branch, "plan/test-feature")
}
// Verify worktree exists
if _, err := os.Stat(wtPath); os.IsNotExist(err) {
t.Fatal("worktree dir was not created")
}
// SafeDispose with no changes — should delete branch
result := SafeDispose(dir, wt)
if result.AutoCommitted {
t.Error("AutoCommitted should be false with no changes")
}
if result.CommitsAhead != 0 {
t.Errorf("CommitsAhead = %d, want 0", result.CommitsAhead)
}
if !result.BranchDeleted {
t.Error("BranchDeleted should be true when no unique commits")
}
}
func TestCreateWorktreeWithChangesAndDispose(t *testing.T) {
dir := initTestRepo(t)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-changes")
wt, err := CreateWorktree(dir, wtPath, "plan/with-changes")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
// Make a change in the worktree
os.WriteFile(filepath.Join(wtPath, "new-file.txt"), []byte("hello"), 0o644)
if !HasUncommittedChanges(wtPath) {
t.Fatal("HasUncommittedChanges should be true after adding file")
}
// SafeDispose should auto-commit
result := SafeDispose(dir, wt)
if !result.AutoCommitted {
t.Error("AutoCommitted should be true")
}
if result.CommitsAhead != 1 {
t.Errorf("CommitsAhead = %d, want 1", result.CommitsAhead)
}
if result.BranchDeleted {
t.Error("BranchDeleted should be false when branch has commits")
}
}
func TestHasUncommittedChanges(t *testing.T) {
dir := initTestRepo(t)
if HasUncommittedChanges(dir) {
t.Fatal("clean repo should have no uncommitted changes")
}
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("data"), 0o644)
if !HasUncommittedChanges(dir) {
t.Fatal("should detect uncommitted changes after adding file")
}
}
func TestCommitsAhead(t *testing.T) {
dir := initTestRepo(t)
base := CurrentBranch(dir)
// Create a branch with a commit
exec.Command("git", "checkout", "-b", "test-ahead").Run()
branchCmd := exec.Command("git", "checkout", "-b", "test-ahead")
branchCmd.Dir = dir
branchCmd.Run()
os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("data"), 0o644)
AutoCommit(dir, "extra commit")
n := CommitsAhead(dir, base, "test-ahead")
if n != 1 {
t.Errorf("CommitsAhead = %d, want 1", n)
}
}
func TestPruneOrphaned(t *testing.T) {
dir := initTestRepo(t)
// Use a separate temp dir for worktrees (outside the repo) to avoid
// git rev-parse finding the parent repo's .git.
worktreesDir := filepath.Join(t.TempDir(), "worktrees")
os.MkdirAll(worktreesDir, 0o755)
// Create a fake dir that's not a worktree
orphanDir := filepath.Join(worktreesDir, "orphan")
os.MkdirAll(orphanDir, 0o755)
PruneOrphaned(dir, worktreesDir)
if _, err := os.Stat(orphanDir); !os.IsNotExist(err) {
t.Error("orphaned dir should have been removed")
}
}

View file

@ -59,12 +59,15 @@ type PlanInfo struct {
// SessionInfo represents an active session entry for the API response.
type SessionInfo struct {
SessionKey string `json:"session_key"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
TouchDir string `json:"touch_dir"`
LastSeenAt string `json:"last_seen_at"`
AgeSec int `json:"age_sec"`
SessionKey string `json:"session_key"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
TouchDir string `json:"touch_dir"`
ProjectPath string `json:"project_path,omitempty"`
Purpose string `json:"purpose,omitempty"`
Branch string `json:"branch,omitempty"`
LastSeenAt string `json:"last_seen_at"`
AgeSec int `json:"age_sec"`
}
// GitRepoSummary represents a lightweight repo entry for the list view.

View file

@ -70,7 +70,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("new_text is required")
}
if err := editFile(t.fs, path, oldText, newText); err != nil {
if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil {
return ErrorResult(err.Error())
}
return SilentResult(fmt.Sprintf("File edited: %s", path))
@ -126,7 +126,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
return ErrorResult("content is required")
}
if err := appendFile(t.fs, path, content); err != nil {
if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil {
return ErrorResult(err.Error())
}
return SilentResult(fmt.Sprintf("Appended to %s", path))

View file

@ -123,7 +123,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("path is required")
}
content, err := t.fs.ReadFile(path)
content, err := resolveFS(ctx, t.fs, path).ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
@ -180,7 +180,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
return ErrorResult("content is required")
}
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
if err := resolveFS(ctx, t.fs, path).WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}
@ -228,7 +228,7 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
path = "."
}
entries, err := t.fs.ReadDir(path)
entries, err := resolveFS(ctx, t.fs, path).ReadDir(path)
if err != nil {
return ErrorResult(err.Error())
}

View file

@ -171,6 +171,8 @@ var defaultDenyPatterns = []*regexp.Regexp{
regexp.MustCompile(`\bdocker\s+exec\b`),
regexp.MustCompile(`\bgit\s+push\b`),
regexp.MustCompile(`\bgit\s+force\b`),
regexp.MustCompile(`\bgit\s+checkout\b`),
regexp.MustCompile(`\bgit\s+switch\b`),
regexp.MustCompile(`\bssh\b.*@`),
regexp.MustCompile(`\beval\b`),
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
@ -277,6 +279,9 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
}
cwd := t.workingDir
if override := WorkspaceOverrideFromCtx(ctx); override != "" {
cwd = override
}
if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" {
resolvedWD, err := validatePath(wd, t.workingDir, true)

View file

@ -0,0 +1,67 @@
package tools
import (
"context"
"path/filepath"
"strings"
)
type workspaceOverrideKey struct{}
// WithWorkspaceOverride returns a context carrying a workspace override path.
// Tools will resolve file operations against this path instead of the original workspace.
func WithWorkspaceOverride(ctx context.Context, workspace string) context.Context {
return context.WithValue(ctx, workspaceOverrideKey{}, workspace)
}
// WorkspaceOverrideFromCtx extracts the workspace override from context, or "".
func WorkspaceOverrideFromCtx(ctx context.Context) string {
if v, ok := ctx.Value(workspaceOverrideKey{}).(string); ok {
return v
}
return ""
}
// resolveFS returns a fileSystem applying workspace override from context.
// Paths under "memory/" are excluded (always use original workspace).
// For sandboxFs: creates a temporary instance with the override workspace.
// For hostFs (unrestricted): returns as-is.
func resolveFS(ctx context.Context, fs fileSystem, path string) fileSystem {
override := WorkspaceOverrideFromCtx(ctx)
if override == "" {
return fs
}
// memory/ paths always use original workspace
if isMemoryPath(path) {
return fs
}
// Only sandboxFs supports workspace override
if sfs, ok := fs.(*sandboxFs); ok {
if sfs.workspace == override {
return fs
}
return &sandboxFs{workspace: override}
}
return fs
}
// isMemoryPath returns true for paths under the memory/ directory.
// Matches: "memory/MEMORY.md", "memory", "/workspace/memory/notes.md"
func isMemoryPath(path string) bool {
p := filepath.ToSlash(filepath.Clean(path))
// Relative path starting with memory/
if strings.HasPrefix(p, "memory/") || p == "memory" {
return true
}
// Absolute path containing /memory/ or ending with /memory
if strings.Contains(p, "/memory/") || strings.HasSuffix(p, "/memory") {
return true
}
return false
}