feat: auto-merge heartbeat worktree branch into main on completion

Heartbeat worktrees were auto-committed but never merged back, so their
changes (e.g. project scaffolding) were effectively lost. Now the
heartbeat defer attempts a fast-forward merge into the base branch after
auto-commit. On conflict, the merge is aborted and the user is notified
to merge manually.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-01 06:05:50 +09:00
parent eda12fdae4
commit ac44bf2f3c
3 changed files with 146 additions and 12 deletions

View file

@ -25,6 +25,7 @@ import (
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/git"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/orch"
@ -944,25 +945,52 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// Guarantee heartbeat worktree cleanup on ALL exit paths (error, panic, normal). // Guarantee heartbeat worktree cleanup on ALL exit paths (error, panic, normal).
// Wait for spawned subagents first so they aren't killed mid-flight. // Wait for spawned subagents first so they aren't killed mid-flight.
// After auto-commit, attempt to merge the worktree branch into main.
defer func() { defer func() {
if opts.Background { if opts.Background {
if agent.SubagentMgr != nil { if agent.SubagentMgr != nil {
agent.SubagentMgr.WaitAll(35 * time.Minute) // slightly above spawnTimeout agent.SubagentMgr.WaitAll(35 * time.Minute) // slightly above spawnTimeout
} }
if agent.IsInWorktree(opts.SessionKey) { wt := agent.GetWorktree(opts.SessionKey)
commitMsg := "heartbeat: auto-save" if wt != nil {
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false) // 1. Auto-commit uncommitted changes in worktree
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) { if git.HasUncommittedChanges(wt.Path) {
_ = git.AutoCommit(wt.Path, "heartbeat: auto-save")
}
// 2. Check if there are unique commits worth merging
repoRoot := git.FindRepoRoot(agent.Workspace)
ahead := git.CommitsAhead(repoRoot, wt.BaseBranch, wt.Branch)
if ahead > 0 && repoRoot != "" {
// 3. Try fast-forward merge into base branch
mr := git.MergeWorktreeBranch(repoRoot, wt)
// 4. Notify based on merge result
if !constants.IsInternalChannel(opts.Channel) {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
if mr.Merged {
_ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
ChatID: opts.ChatID, ChatID: opts.ChatID,
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).", Content: fmt.Sprintf("Heartbeat: merged %d commit(s) to %s.",
wtResult.Branch, wtResult.CommitsAhead), ahead, wt.BaseBranch),
}) })
} else if mr.Conflict {
_ = al.bus.PublishOutbound(cleanupCtx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: fmt.Sprintf("Heartbeat: merge conflict on branch `%s` — manual merge needed.",
mr.Branch),
})
}
cleanupCancel() cleanupCancel()
} }
} }
// 5. Dispose worktree (branch auto-deleted if merged, kept if conflict)
agent.DeactivateWorktree(opts.SessionKey, "", false)
}
} }
}() }()

View file

@ -193,6 +193,34 @@ func SafeDispose(repoDir string, wt *WorktreeInfo) DisposeResult {
return result return result
} }
// MergeResult describes the outcome of a worktree branch merge attempt.
type MergeResult struct {
Merged bool // true if merge succeeded
Branch string // branch name that was merged
Conflict bool // true if merge failed due to conflict
}
// MergeWorktreeBranch attempts to merge the worktree branch into the base branch.
// On conflict, it aborts the merge and returns Conflict=true.
// Must be called AFTER auto-commit and BEFORE SafeDispose.
func MergeWorktreeBranch(repoDir string, wt *WorktreeInfo) MergeResult {
result := MergeResult{Branch: wt.Branch}
mergeCmd := exec.Command("git", "merge", "--no-edit", wt.Branch)
mergeCmd.Dir = repoDir
if err := mergeCmd.Run(); err != nil {
// Merge failed — abort and report conflict
abortCmd := exec.Command("git", "merge", "--abort")
abortCmd.Dir = repoDir
abortCmd.Run() // best-effort
result.Conflict = true
return result
}
result.Merged = true
return result
}
// PruneOrphaned runs git worktree prune and removes dirs in worktreesDir // PruneOrphaned runs git worktree prune and removes dirs in worktreesDir
// that aren't valid git worktrees. // that aren't valid git worktrees.
func PruneOrphaned(repoDir, worktreesDir string) { func PruneOrphaned(repoDir, worktreesDir string) {

View file

@ -201,6 +201,84 @@ func TestCommitsAhead(t *testing.T) {
} }
} }
func TestMergeWorktreeBranch_Success(t *testing.T) {
dir := initTestRepo(t)
baseBranch := CurrentBranch(dir)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "merge-ok")
wt, err := CreateWorktree(dir, wtPath, "plan/merge-ok")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
// Make a change in the worktree and commit
os.WriteFile(filepath.Join(wtPath, "merged-file.txt"), []byte("hello from worktree"), 0o644)
if err := AutoCommit(wtPath, "add merged-file"); err != nil {
t.Fatalf("AutoCommit: %v", err)
}
// Merge into base branch
mr := MergeWorktreeBranch(dir, wt)
if !mr.Merged {
t.Fatal("expected Merged=true")
}
if mr.Conflict {
t.Fatal("expected Conflict=false")
}
if mr.Branch != "plan/merge-ok" {
t.Errorf("Branch = %q, want %q", mr.Branch, "plan/merge-ok")
}
// Verify the file exists on the base branch
checkoutCmd := exec.Command("git", "checkout", baseBranch)
checkoutCmd.Dir = dir
checkoutCmd.Run()
if _, err := os.Stat(filepath.Join(dir, "merged-file.txt")); os.IsNotExist(err) {
t.Fatal("merged-file.txt should exist on base branch after merge")
}
}
func TestMergeWorktreeBranch_Conflict(t *testing.T) {
dir := initTestRepo(t)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "merge-conflict")
wt, err := CreateWorktree(dir, wtPath, "plan/merge-conflict")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
// Make a change on the base branch
os.WriteFile(filepath.Join(dir, "conflict.txt"), []byte("base content"), 0o644)
if err := AutoCommit(dir, "add conflict.txt on base"); err != nil {
t.Fatalf("AutoCommit base: %v", err)
}
// Make a conflicting change in the worktree
os.WriteFile(filepath.Join(wtPath, "conflict.txt"), []byte("worktree content"), 0o644)
if err := AutoCommit(wtPath, "add conflict.txt on worktree"); err != nil {
t.Fatalf("AutoCommit worktree: %v", err)
}
// Attempt merge — should conflict
mr := MergeWorktreeBranch(dir, wt)
if mr.Merged {
t.Fatal("expected Merged=false on conflict")
}
if !mr.Conflict {
t.Fatal("expected Conflict=true")
}
// Verify base branch file is unchanged (merge was aborted)
content, err := os.ReadFile(filepath.Join(dir, "conflict.txt"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(content) != "base content" {
t.Errorf("conflict.txt = %q, want %q (merge should have been aborted)", string(content), "base content")
}
}
func TestPruneOrphaned(t *testing.T) { func TestPruneOrphaned(t *testing.T) {
dir := initTestRepo(t) dir := initTestRepo(t)