Files
zzet--gortex/internal/mcp/tools_overlay_branch_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

777 lines
30 KiB
Go

package mcp
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/daemon"
)
// jsonUnmarshal is a test-local thin wrapper that hides the import
// from the per-call sites and keeps file-top imports tidy.
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// branchBootstrap is the minimal setup for branch tests: an overlay
// server with a single registered MCP session and a context wired
// with the session ID. The returned helpers wrap the verbose
// callToolByName + JSON-text plumbing into a tighter, intention-
// revealing surface.
func branchBootstrap(t *testing.T) (srv *Server, sessID, dir, targetFile, callerFile string, ctx context.Context) {
t.Helper()
srv, dir, targetFile, callerFile = setupOverlayServer(t)
sessID = fmt.Sprintf("branch-test-%d", time.Now().UnixNano())
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
ctx = WithSessionID(context.Background(), sessID)
return srv, sessID, dir, targetFile, callerFile, ctx
}
// invokeTool is the cousin of callToolByName that returns both the
// raw body and the textual content joined into one string — exactly
// what every branch test wants for substring assertions.
func invokeTool(t *testing.T, srv *Server, ctx context.Context, name string, args map[string]any) (*mcplib.CallToolResult, string) {
t.Helper()
res := callToolByName(t, srv, ctx, name, args)
return res, toolText(res)
}
// TestOverlayBranch_ForkPreservesFiles is requirement 1: pushing a
// file on `main`, then forking, leaves both branches seeing the
// file. The fork must deep-copy so subsequent edits don't bleed
// across branches.
func TestOverlayBranch_ForkPreservesFiles(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc MainBranchSentinel() {}\n",
}, nil))
_, forkText := invokeTool(t, srv, ctx, "overlay_fork", map[string]any{
"name": "strategy-a",
})
require.Contains(t, forkText, `"branch":"strategy-a"`)
require.Contains(t, forkText, `"parent":"main"`)
require.Contains(t, forkText, `"files":1`)
// strategy-a sees the file (post-switch).
require.NoError(t, srv.OverlayManager().SwitchBranch(sessID, "strategy-a"))
files, err := srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files, targetFile, "strategy-a must inherit main's file map")
// main still sees the file (post-switch back).
require.NoError(t, srv.OverlayManager().SwitchBranch(sessID, "main"))
files, err = srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files, targetFile, "main must keep its file after the fork")
}
// TestOverlayBranch_BranchesDiverge is requirement 2: after forking
// main→"a" and editing the same path on "a", main's content stays
// unchanged. This is the load-bearing isolation invariant that
// makes branching useful.
func TestOverlayBranch_BranchesDiverge(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc OnMainOnly() {}\n",
}, nil))
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{
"name": "a",
"activate": true,
})
// On branch a, push a different content for the same path.
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc OnBranchAOnly() {}\n",
}, nil))
// Switch back to main and confirm content unchanged.
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "main"})
files, err := srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files[targetFile].Content, "OnMainOnly")
require.NotContains(t, files[targetFile].Content, "OnBranchAOnly")
// And on a the content is the new one.
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "a"})
files, err = srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files[targetFile].Content, "OnBranchAOnly")
require.NotContains(t, files[targetFile].Content, "OnMainOnly")
}
// TestOverlayBranch_List is requirement 3: three forks yield four
// branches (main + 3) and the active flag is correct.
func TestOverlayBranch_List(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
for _, name := range []string{"alpha", "beta", "gamma"} {
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": name})
}
_, listText := invokeTool(t, srv, ctx, "overlay_branches", map[string]any{})
for _, name := range []string{"main", "alpha", "beta", "gamma"} {
require.Contains(t, listText, fmt.Sprintf(`"name":%q`, name), "branch %s should appear in the list", name)
}
require.Contains(t, listText, `"count":4`)
// Active flag: only main is active here (none of the forks
// passed activate:true). The active=true entry must also be
// the main entry — verify by checking the active-row JSON.
require.True(t, listForActiveRowMatches(listText, "main"),
"expected main to be the active branch row: %s", listText)
}
// TestOverlayBranch_Switch is requirement 4: switching changes the
// active branch and subsequent queries reflect that branch.
func TestOverlayBranch_Switch(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "a"})
require.NoError(t, srv.OverlayManager().SwitchBranch(sessID, "a"))
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc AOnly() {}\n",
}, nil))
// Switch via the tool (not just the manager) and confirm a
// subsequent get_file_summary sees the a-only symbol.
_, swText := invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "a"})
require.Contains(t, swText, `"active_branch":"a"`)
_, summary := invokeTool(t, srv, ctx, "get_file_summary", map[string]any{
"path": filepath.Base(targetFile),
})
require.Contains(t, summary, "AOnly", "after switch, active branch buffer must drive the view")
// Switch back to main: AOnly disappears.
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "main"})
_, summary = invokeTool(t, srv, ctx, "get_file_summary", map[string]any{
"path": filepath.Base(targetFile),
})
require.NotContains(t, summary, "AOnly", "main should not see branch-a-only symbol")
}
// TestOverlayBranch_Drop is requirement 5: drop removes the branch
// from the list. Cannot drop the active branch.
func TestOverlayBranch_Drop(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "throwaway"})
_, dropText := invokeTool(t, srv, ctx, "overlay_drop_branch", map[string]any{"name": "throwaway"})
require.Contains(t, dropText, `"ok":true`)
_, listText := invokeTool(t, srv, ctx, "overlay_branches", map[string]any{})
require.NotContains(t, listText, `"name":"throwaway"`)
// Cannot drop active: fork a, switch to it, then try to drop it.
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "active-victim", "activate": true})
res, dropAct := invokeTool(t, srv, ctx, "overlay_drop_branch", map[string]any{"name": "active-victim"})
require.True(t, res.IsError)
require.Contains(t, dropAct, "active overlay branch")
}
// TestOverlayBranch_CannotDropMain is requirement 6: refuse to drop
// the implicit main branch.
func TestOverlayBranch_CannotDropMain(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
res, body := invokeTool(t, srv, ctx, "overlay_drop_branch", map[string]any{"name": "main"})
require.True(t, res.IsError)
require.Contains(t, body, "main overlay branch")
}
// TestOverlayBranch_Merge_NonConflicting is requirement 7: branches
// touching different files merge cleanly; the destination gains
// both file sets.
func TestOverlayBranch_Merge_NonConflicting(t *testing.T) {
srv, sessID, dir, _, _, ctx := branchBootstrap(t)
fileA := filepath.Join(dir, "a_only.go")
fileB := filepath.Join(dir, "b_only.go")
// Both files must exist on disk so the overlay parser can
// resolve them — empty .go files are valid for the parser.
require.NoError(t, os.WriteFile(fileA, []byte("package main\n"), 0o644))
require.NoError(t, os.WriteFile(fileB, []byte("package main\n"), 0o644))
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "a", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: fileA,
Content: "package main\n\nfunc OnA() {}\n",
}, nil))
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "main"})
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "b", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: fileB,
Content: "package main\n\nfunc OnB() {}\n",
}, nil))
_, mergeText := invokeTool(t, srv, ctx, "overlay_merge", map[string]any{
"from": "a",
"into": "b",
})
require.Contains(t, mergeText, `"merged":1`)
// b now has both files.
require.NoError(t, srv.OverlayManager().SwitchBranch(sessID, "b"))
files, err := srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files, fileA, "merge should give b a copy of a's files")
require.Contains(t, files, fileB, "b must keep its own file")
}
// TestOverlayBranch_Merge_Conflict is requirement 8: same-path
// different-content collisions block the merge without force and
// the response carries the conflict list; with force the merge
// proceeds last-writer-wins.
func TestOverlayBranch_Merge_Conflict(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
// Set up two branches that both edit targetFile differently.
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "a", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc FromA() {}\n",
}, nil))
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "main"})
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "b", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc FromB() {}\n",
}, nil))
// Without force: aborts with conflicts list.
_, conflictText := invokeTool(t, srv, ctx, "overlay_merge", map[string]any{
"from": "a",
"into": "b",
})
require.Contains(t, conflictText, "merge conflict")
require.Contains(t, conflictText, `"conflicts":["`+jsonEscape(targetFile)+`"]`)
// Confirm b's content is unchanged.
require.NoError(t, srv.OverlayManager().SwitchBranch(sessID, "b"))
files, err := srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files[targetFile].Content, "FromB", "non-forced merge must leave destination untouched")
require.NotContains(t, files[targetFile].Content, "FromA")
// With force: a wins (last-writer-wins).
_, forceText := invokeTool(t, srv, ctx, "overlay_merge", map[string]any{
"from": "a",
"into": "b",
"force": true,
})
require.Contains(t, forceText, `"merged":1`)
require.Contains(t, forceText, `"resolution":"last-writer-wins"`)
files, err = srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files[targetFile].Content, "FromA", "force merge must overwrite destination with source")
}
// TestOverlayBranch_MergeToDisk is requirement 9: merging a branch
// with to_disk:true writes the file to disk and the on-disk
// content matches.
func TestOverlayBranch_MergeToDisk(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "writer", "activate": true})
newContent := "package main\n\nfunc Target() {}\n\nfunc OnDisk() {}\n"
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: newContent,
}, nil))
_, mergeText := invokeTool(t, srv, ctx, "overlay_merge", map[string]any{
"from": "writer",
"to_disk": true,
})
require.Contains(t, mergeText, `"merged":1`)
require.Contains(t, mergeText, `"dropped_branch":true`)
onDisk, err := os.ReadFile(targetFile)
require.NoError(t, err)
require.Equal(t, newContent, string(onDisk), "to_disk merge must write the overlay content verbatim")
// Branch should be gone after to_disk.
branches, err := srv.OverlayManager().Branches(sessID)
require.NoError(t, err)
for _, b := range branches {
require.NotEqual(t, "writer", b.Name, "writer branch should be dropped after to_disk merge")
}
}
// TestOverlayBranch_MergeToDiskDrift is requirement 10: when the
// on-disk file changes between fork and merge, the merge must
// refuse with the standard drift error (reusing gitBlobSHA).
func TestOverlayBranch_MergeToDiskDrift(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
// Compute the file's current SHA so the overlay carries an
// anchor pointing at the pre-edit state.
originalContent, err := os.ReadFile(targetFile)
require.NoError(t, err)
originalSHA := gitBlobSHAForTest(originalContent)
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "drifter", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc OverlaidEdit() {}\n",
BaseSHA: originalSHA,
}, nil))
// Simulate a concurrent edit on disk between fork and merge.
require.NoError(t, os.WriteFile(targetFile, []byte("package main\n\n// concurrent edit\nfunc Target() {}\n"), 0o644))
res, body := invokeTool(t, srv, ctx, "overlay_merge", map[string]any{
"from": "drifter",
"to_disk": true,
})
require.True(t, res.IsError, "drift on to_disk must surface as a tool error: %s", body)
require.Contains(t, body, "overlay base SHA mismatch", "drift message must echo the existing overlay drift error")
}
// TestOverlayBranch_CompareBranches is requirement 11: edits that
// change the callers of a symbol surface in compare_branches as a
// differing caller set between two branches.
func TestOverlayBranch_CompareBranches(t *testing.T) {
srv, sessID, _, _, callerFile, ctx := branchBootstrap(t)
// Branch a: caller still calls Target.
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "a", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: callerFile,
Content: "package main\n\nfunc Caller() {\n\tTarget()\n}\n",
}, nil))
// Branch b: caller stops calling Target.
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "main"})
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "b", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: callerFile,
Content: "package main\n\nfunc Caller() {\n}\n",
}, nil))
res, body := invokeTool(t, srv, ctx, "compare_branches", map[string]any{
"a": "a",
"b": "b",
"kind": "get_callers",
"id": "target.go::Target",
})
require.False(t, res.IsError, "compare_branches: %s", body)
require.Contains(t, body, `"a":"a"`)
require.Contains(t, body, `"b":"b"`)
require.Contains(t, body, `"delta"`)
require.Contains(t, body, `"a_result"`)
require.Contains(t, body, `"b_result"`)
// One side should have a Caller entry, the other shouldn't.
require.Contains(t, body, "Caller", "compare_branches must surface the differing caller set")
}
// TestOverlayBranch_BackwardCompat is requirement 12: without any
// branch tool calls, every existing overlay tool behaves identically
// to the pre-branching world (push / list / delete / drop /
// compare_with_overlay / preview_edit / simulate_chain).
func TestOverlayBranch_BackwardCompat(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
// 1. overlay_push works as before.
_, pushText := invokeTool(t, srv, ctx, "overlay_push", map[string]any{
"path": targetFile,
"content": "package main\n\nfunc Target() {}\n\nfunc Compat() {}\n",
})
require.Contains(t, pushText, `"path":`)
// 2. overlay_list shows the file.
_, listText := invokeTool(t, srv, ctx, "overlay_list", map[string]any{})
require.Contains(t, listText, `"count":1`)
require.Contains(t, listText, targetFile)
// 3. A query reflects the overlay (Compat is visible).
_, summary := invokeTool(t, srv, ctx, "get_file_summary", map[string]any{
"path": filepath.Base(targetFile),
})
require.Contains(t, summary, "Compat")
// 4. compare_with_overlay still works (uses active branch).
_, cmp := invokeTool(t, srv, ctx, "compare_with_overlay", map[string]any{
"kind": "find_usages",
"id": "target.go::Target",
})
require.Contains(t, cmp, `"overlay_paths"`)
// 5. overlay_delete removes the file.
_, delText := invokeTool(t, srv, ctx, "overlay_delete", map[string]any{"path": targetFile})
require.Contains(t, delText, `"ok":true`)
require.Zero(t, srv.OverlayManager().FileCount(sessID))
// 6. overlay_drop nukes the session.
_, _ = invokeTool(t, srv, ctx, "overlay_push", map[string]any{
"path": targetFile,
"content": "package main\n\nfunc Target() {}\n\nfunc StillCompat() {}\n",
})
_, dropText := invokeTool(t, srv, ctx, "overlay_drop", map[string]any{})
require.Contains(t, dropText, `"ok":true`)
require.False(t, srv.OverlayManager().Has(sessID))
}
// TestOverlayBranch_ConcurrentBranches is requirement 13: spawn
// four goroutines each operating on a different branch
// concurrently for many iterations; the race detector must stay
// clean and the final state must match the expected per-branch
// content. The shared-mutex contract of OverlayManager is what
// makes this safe; this test is the proof.
func TestOverlayBranch_ConcurrentBranches(t *testing.T) {
srv, sessID, dir, _, _, ctx := branchBootstrap(t)
// Disk files so the overlay parser doesn't complain about
// missing files (overlay graph-path resolution still works
// for already-tracked files).
files := []string{}
for i := 0; i < 4; i++ {
p := filepath.Join(dir, fmt.Sprintf("g%d.go", i))
require.NoError(t, os.WriteFile(p, []byte("package main\n"), 0o644))
files = append(files, p)
}
// Fork four named branches off main.
branchNames := []string{"g0", "g1", "g2", "g3"}
for _, name := range branchNames {
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": name})
}
const iterations = 100
var wg sync.WaitGroup
for i, name := range branchNames {
i, name := i, name
wg.Add(1)
go func() {
defer wg.Done()
path := files[i]
content := fmt.Sprintf("package main\n\nfunc OnBranch%d_%%d() {}\n", i)
for j := 0; j < iterations; j++ {
// Push into this goroutine's branch by snapshotting
// onto it via the manager API directly. The manager
// guarantees branch isolation under its mutex.
err := branchPush(srv, sessID, name, daemon.OverlayFile{
Path: path,
Content: fmt.Sprintf(content, j),
})
if err != nil {
t.Errorf("branch %s push %d: %v", name, j, err)
return
}
// Read back to exercise the read path too.
got, err := branchRead(srv, sessID, name, path)
if err != nil {
t.Errorf("branch %s read %d: %v", name, j, err)
return
}
wantSuffix := fmt.Sprintf("OnBranch%d_%d", i, j)
if !strings.Contains(got, wantSuffix) {
t.Errorf("branch %s iter %d: content %q missing %q", name, j, got, wantSuffix)
return
}
}
}()
}
wg.Wait()
// Final state: every branch must hold the file with the last
// iteration's content.
for i, name := range branchNames {
got, err := branchRead(srv, sessID, name, files[i])
require.NoError(t, err)
require.Contains(t, got, fmt.Sprintf("OnBranch%d_%d", i, iterations-1))
}
}
// TestOverlayBranch_ActivateOption verifies the activate:true
// branch of overlay_fork: when set, the new branch becomes the
// session's active branch and subsequent queries route there.
func TestOverlayBranch_ActivateOption(t *testing.T) {
srv, _, _, targetFile, _, ctx := branchBootstrap(t)
_, _ = invokeTool(t, srv, ctx, "overlay_push", map[string]any{
"path": targetFile,
"content": "package main\n\nfunc Target() {}\n\nfunc OnMain() {}\n",
})
_, forkText := invokeTool(t, srv, ctx, "overlay_fork", map[string]any{
"name": "active-fork",
"activate": true,
})
require.Contains(t, forkText, `"active":true`)
// After activate:true, the active branch is the new one.
id, _ := SessionScopeForTest(srv, ctx)
active, err := srv.OverlayManager().ActiveBranch(id)
require.NoError(t, err)
require.Equal(t, "active-fork", active)
// Editing on the fork doesn't propagate back to main.
_, _ = invokeTool(t, srv, ctx, "overlay_push", map[string]any{
"path": targetFile,
"content": "package main\n\nfunc Target() {}\n\nfunc OnFork() {}\n",
})
_, _ = invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "main"})
files, err := srv.OverlayManager().Files(id)
require.NoError(t, err)
require.Contains(t, files[targetFile].Content, "OnMain")
require.NotContains(t, files[targetFile].Content, "OnFork")
}
// TestOverlayBranch_ForkFromExplicitParent verifies the `from`
// parameter: forking off a non-active branch creates a branch
// whose contents match that source, not the session's active
// branch.
func TestOverlayBranch_ForkFromExplicitParent(t *testing.T) {
srv, sessID, _, targetFile, _, ctx := branchBootstrap(t)
// On main, write content A.
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc OnMain() {}\n",
}, nil))
// Fork main→x with different content.
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "x", "activate": true})
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
Path: targetFile,
Content: "package main\n\nfunc Target() {}\n\nfunc OnX() {}\n",
}, nil))
// Now active is x; fork explicit from main (NOT active).
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{
"name": "y",
"from": "main",
})
require.NoError(t, srv.OverlayManager().SwitchBranch(sessID, "y"))
files, err := srv.OverlayManager().Files(sessID)
require.NoError(t, err)
require.Contains(t, files[targetFile].Content, "OnMain", "fork from=main must clone main, not the active branch")
require.NotContains(t, files[targetFile].Content, "OnX")
}
// TestOverlayBranch_InvalidName covers the name-validation surface:
// empty / overlong / illegal-character names must surface a clear
// error before the manager is touched.
func TestOverlayBranch_InvalidName(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
for _, bad := range []string{"", "../escape", "has space", strings.Repeat("a", 65), "-leading-dash"} {
res, body := invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": bad})
require.Truef(t, res.IsError, "invalid name %q should error; got: %s", bad, body)
}
}
// TestOverlayBranch_ForkExisting verifies that forking onto an
// existing branch name is rejected.
func TestOverlayBranch_ForkExisting(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
_, _ = invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "dup"})
res, body := invokeTool(t, srv, ctx, "overlay_fork", map[string]any{"name": "dup"})
require.True(t, res.IsError)
require.Contains(t, body, "already exists")
}
// TestOverlayBranch_SwitchUnknown surfaces a clear error for
// switch-to-nonexistent.
func TestOverlayBranch_SwitchUnknown(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
res, body := invokeTool(t, srv, ctx, "overlay_switch", map[string]any{"name": "ghost"})
require.True(t, res.IsError)
require.Contains(t, body, "not found")
}
// TestOverlayBranch_DropUnknown surfaces a clear error.
func TestOverlayBranch_DropUnknown(t *testing.T) {
srv, _, _, _, _, ctx := branchBootstrap(t)
res, body := invokeTool(t, srv, ctx, "overlay_drop_branch", map[string]any{"name": "ghost"})
require.True(t, res.IsError)
require.Contains(t, body, "not found")
}
// TestOverlayBranch_DaemonAPI_Direct exercises the daemon-side API
// directly (without going through the MCP tool wrappers) to keep
// the daemon-contract tests close to the daemon code. This
// guarantees the branching primitives stay correct even if the
// MCP surface evolves.
func TestOverlayBranch_DaemonAPI_Direct(t *testing.T) {
m := daemon.NewOverlayManager(time.Minute)
require.NoError(t, m.RegisterWithID("sess", "ws"))
// Default: one branch, main, active.
br, err := m.Branches("sess")
require.NoError(t, err)
require.Len(t, br, 1)
require.Equal(t, "main", br[0].Name)
require.True(t, br[0].Active)
// Push into main, fork, confirm child inherits.
require.NoError(t, m.Push("sess", daemon.OverlayFile{Path: "p.go", Content: "X"}, nil))
fr, err := m.Fork("sess", daemon.ForkOptions{Name: "child"})
require.NoError(t, err)
require.Equal(t, "child", fr.Branch)
require.Equal(t, "main", fr.Parent)
require.Equal(t, 1, fr.FileCount)
require.False(t, fr.Active)
// Switch to child, modify, switch back, confirm main unchanged.
require.NoError(t, m.SwitchBranch("sess", "child"))
require.NoError(t, m.Push("sess", daemon.OverlayFile{Path: "p.go", Content: "Y"}, nil))
require.NoError(t, m.SwitchBranch("sess", "main"))
files, err := m.Files("sess")
require.NoError(t, err)
require.Equal(t, "X", files["p.go"].Content)
// Cannot drop main.
require.ErrorIs(t, m.DropBranch("sess", "main"), daemon.ErrCannotDropMainBranch)
// Cannot drop active branch (which is currently main).
require.NoError(t, m.SwitchBranch("sess", "child"))
require.ErrorIs(t, m.DropBranch("sess", "child"), daemon.ErrCannotDropActiveBranch)
// Switch off, drop succeeds.
require.NoError(t, m.SwitchBranch("sess", "main"))
require.NoError(t, m.DropBranch("sess", "child"))
br, err = m.Branches("sess")
require.NoError(t, err)
require.Len(t, br, 1)
// Invalid name shapes.
for _, bad := range []string{"", "with space", "../oops"} {
_, err := m.Fork("sess", daemon.ForkOptions{Name: bad})
require.Error(t, err, "name %q must be rejected", bad)
}
}
// TestOverlayBranch_MergeDaemon_Conflict checks the daemon's
// MergeBranches in isolation (no MCP transport in the loop).
func TestOverlayBranch_MergeDaemon_Conflict(t *testing.T) {
m := daemon.NewOverlayManager(time.Minute)
require.NoError(t, m.RegisterWithID("s", ""))
// Two branches with conflicting content on the same path.
require.NoError(t, m.Push("s", daemon.OverlayFile{Path: "p", Content: "A"}, nil))
_, err := m.Fork("s", daemon.ForkOptions{Name: "b", Activate: true})
require.NoError(t, err)
require.NoError(t, m.Push("s", daemon.OverlayFile{Path: "p", Content: "B"}, nil))
// Merge a (which is main) → b without force.
res, err := m.MergeBranches("s", daemon.MergeOptions{From: "main", Into: "b"}, nil)
require.ErrorIs(t, err, daemon.ErrMergeConflict)
require.Equal(t, []string{"p"}, res.Conflicts)
// b's content unchanged after aborted merge.
files, _ := m.Files("s")
require.Equal(t, "B", files["p"].Content)
// With force, main wins.
_, err = m.MergeBranches("s", daemon.MergeOptions{From: "main", Into: "b", Force: true}, nil)
require.NoError(t, err)
files, _ = m.Files("s")
require.Equal(t, "A", files["p"].Content)
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// branchPush pushes a file onto a *specific* branch without
// changing the session's active branch. Used by the concurrency
// test so four goroutines can hammer four sibling branches under
// one mutex without racing on the active pointer.
func branchPush(srv *Server, sessID, branchName string, f daemon.OverlayFile) error {
return srv.OverlayManager().PushToBranch(sessID, branchName, f, nil)
}
// branchRead reads a single file's content from a specific branch
// without leaking the active-branch state.
func branchRead(srv *Server, sessID, branchName, path string) (string, error) {
mgr := srv.OverlayManager()
files, err := mgr.FilesForBranch(sessID, branchName)
if err != nil {
return "", err
}
for _, f := range files {
if f.Path == path {
return f.Content, nil
}
}
return "", fmt.Errorf("branch %s: path %s not found", branchName, path)
}
// gitBlobSHAForTest is a tiny duplicate of the production
// gitBlobSHA helper that keeps this test file independent of the
// production helper's package-private symbol. Tests must not
// depend on whether the production helper is exported.
func gitBlobSHAForTest(data []byte) string {
h := sha1.New()
fmt.Fprintf(h, "blob %d\x00", len(data))
_, _ = h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
// jsonEscape returns the JSON-string-safe form of s. Used in test
// assertions where the file path embedded in the response carries
// a slash (no escaping needed on POSIX) but the test wants to be
// robust against future shell-path quirks.
func jsonEscape(s string) string {
// JSON-encoding a string adds quote characters; strip them.
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
return r.Replace(s)
}
// listForActiveRowMatches checks whether the overlay_branches JSON
// payload `body` has its `active:true` entry pointing at the named
// branch. JSON-key order is not stable across encoders, so we parse
// the payload defensively rather than asserting on a substring.
func listForActiveRowMatches(body, expectedName string) bool {
var payload struct {
Branches []struct {
Name string `json:"name"`
Active bool `json:"active"`
} `json:"branches"`
}
if err := jsonUnmarshal([]byte(body), &payload); err != nil {
return false
}
for _, b := range payload.Branches {
if b.Active {
return b.Name == expectedName
}
}
return false
}
// SessionScopeForTest exposes the session ID + scope for branch
// tests that need to peek at the manager directly. Avoids exporting
// SessionIDFromContext for production use.
func SessionScopeForTest(srv *Server, ctx context.Context) (string, string) {
id := SessionIDFromContext(ctx)
if srv == nil {
return id, ""
}
if mgr := srv.OverlayManager(); mgr != nil {
_, _ = mgr.ActiveBranch(id) // exercise the path
}
return id, ""
}
// _ daemon.ErrOverlayDrift links the import even if a refactor
// elides the only direct reference; the test file imports the
// daemon package for the OverlayFile / OverlayManager surface.
var _ = errors.Is