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
777 lines
30 KiB
Go
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
|