Files
kandev/apps/backend/internal/agentctl/server/api/git_handlers_test.go
T
2026-08-12 19:12:35 +01:00

978 lines
36 KiB
Go

package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
)
// gitAPIFixture is a single-repo workspace wired to a bare `origin` remote.
// The remote matters: it gives `origin/main` a real ref so merge-base anchoring
// in handleGitLog / handleGitCumulativeDiff behaves the way it does in
// production rather than falling through the no-upstream recovery paths.
type gitAPIFixture struct {
server *Server
repo string
bare string
}
// newGitAPIFixture seeds main (one commit, pushed to origin) plus a
// feature/work branch carrying two more commits, and returns a Server whose
// workspace root is that repository.
func newGitAPIFixture(t *testing.T) *gitAPIFixture {
t.Helper()
root := t.TempDir()
bare := filepath.Join(root, "origin.git")
runGitAPI(t, root, "init", "--bare", "--initial-branch=main", bare)
repo := filepath.Join(root, "work")
if err := os.Mkdir(repo, 0o755); err != nil {
t.Fatalf("mkdir work: %v", err)
}
runGitAPI(t, repo, "init", "--initial-branch=main")
runGitAPI(t, repo, "config", "user.email", "test@test.com")
runGitAPI(t, repo, "config", "user.name", "Test User")
writeFileAPI(t, repo, "README.md", "base\n")
runGitAPI(t, repo, "add", ".")
runGitAPI(t, repo, "commit", "-m", "initial")
runGitAPI(t, repo, "remote", "add", "origin", bare)
runGitAPI(t, repo, "push", "-u", "origin", "main")
runGitAPI(t, repo, "checkout", "-b", "feature/work")
writeFileAPI(t, repo, "one.txt", "one\n")
runGitAPI(t, repo, "add", ".")
runGitAPI(t, repo, "commit", "-m", "feature one")
writeFileAPI(t, repo, "two.txt", "two\n")
runGitAPI(t, repo, "add", ".")
runGitAPI(t, repo, "commit", "-m", "feature two")
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error"})
cfg := &config.InstanceConfig{WorkDir: repo}
return &gitAPIFixture{
server: NewServer(cfg, process.NewManager(cfg, log), nil, nil, log),
repo: repo,
bare: bare,
}
}
func (f *gitAPIFixture) head(t *testing.T) string {
t.Helper()
return strings.TrimSpace(runGitAPI(t, f.repo, "rev-parse", "HEAD"))
}
func (f *gitAPIFixture) branch(t *testing.T) string {
t.Helper()
return strings.TrimSpace(runGitAPI(t, f.repo, "rev-parse", "--abbrev-ref", "HEAD"))
}
// postGitAPI issues a POST with a JSON body (or a raw string body verbatim,
// which is how the malformed-payload cases are expressed).
func postGitAPI(t *testing.T, srv *Server, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var payload []byte
switch v := body.(type) {
case string:
payload = []byte(v)
default:
encoded, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
payload = encoded
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Router().ServeHTTP(rec, req)
return rec
}
func getGitAPI(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
srv.Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
func decodeGitOperationResult(t *testing.T, rec *httptest.ResponseRecorder) process.GitOperationResult {
t.Helper()
var result process.GitOperationResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode git operation result from %q: %v", rec.Body.String(), err)
}
return result
}
// TestGitHandlers_RejectMalformedBody pins the shape of the 400 every git POST
// handler writes for an unparseable body: the operation label the caller sent
// the request for, and an "invalid request: " prefixed error. The operation
// label is what the frontend keys its error toast off, so a copy-paste slip
// between handlers is a user-visible bug that no status-code-only test catches.
func TestGitHandlers_RejectMalformedBody(t *testing.T) {
fixture := newGitAPIFixture(t)
cases := []struct {
path string
operation string
}{
{"/api/v1/git/pull", "pull"},
{"/api/v1/git/push", "push"},
{"/api/v1/git/push-preflight", "push_preflight"},
{"/api/v1/git/contribution/replace", "replace_remote_contribution"},
{"/api/v1/git/contribution/use", "use_remote_contribution"},
{"/api/v1/git/rebase", "rebase"},
{"/api/v1/git/merge", "merge"},
{"/api/v1/git/abort", "abort"},
{"/api/v1/git/commit", "commit"},
{"/api/v1/git/rename-branch", "rename_branch"},
{"/api/v1/git/stage", "stage"},
{"/api/v1/git/unstage", "unstage"},
{"/api/v1/git/discard", "discard"},
{"/api/v1/git/revert-commit", "revert_commit"},
{"/api/v1/git/reset", "reset"},
}
for _, tc := range cases {
t.Run(tc.operation, func(t *testing.T) {
rec := postGitAPI(t, fixture.server, tc.path, "{not json")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Success {
t.Errorf("success = true, want false")
}
if result.Operation != tc.operation {
t.Errorf("operation = %q, want %q", result.Operation, tc.operation)
}
if !strings.HasPrefix(result.Error, "invalid request: ") {
t.Errorf("error = %q, want an %q prefix", result.Error, "invalid request: ")
}
})
}
}
// TestGitHandlers_RejectMissingRequiredFields pins each handler's own
// pre-flight validation message. These are the only guards standing between an
// empty form field and a git invocation with a missing argument.
func TestGitHandlers_RejectMissingRequiredFields(t *testing.T) {
fixture := newGitAPIFixture(t)
cases := []struct {
name string
path string
body any
operation string
wantError string
}{
{"rebase without base", "/api/v1/git/rebase", GitRebaseRequest{}, "rebase", "base_branch is required"},
{"merge without base", "/api/v1/git/merge", GitMergeRequest{}, "merge", "base_branch is required"},
{
"abort with unknown operation",
"/api/v1/git/abort",
GitAbortRequest{Operation: "cherry-pick"},
"abort",
"operation must be 'merge' or 'rebase'",
},
{"commit without message", "/api/v1/git/commit", GitCommitRequest{}, "commit", "message is required"},
{
"rename-branch without name",
"/api/v1/git/rename-branch",
GitRenameBranchRequest{},
"rename_branch",
"new_name is required",
},
{"discard without paths", "/api/v1/git/discard", GitDiscardRequest{}, "discard", "paths are required"},
{
"revert-commit without sha",
"/api/v1/git/revert-commit",
GitRevertCommitRequest{},
"revert_commit",
"commit_sha is required",
},
{"reset without sha", "/api/v1/git/reset", GitResetRequest{}, "reset", "commit_sha is required"},
{"replace without expected head", "/api/v1/git/contribution/replace", GitContributionRequest{}, "replace_remote_contribution", "expected_remote_head is required"},
{"use without expected head", "/api/v1/git/contribution/use", GitContributionRequest{}, "use_remote_contribution", "expected_remote_head is required"},
{
"reset with unknown mode",
"/api/v1/git/reset",
GitResetRequest{CommitSHA: "HEAD", Mode: "squash"},
"reset",
"invalid reset mode: squash (must be soft, mixed, or hard)",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := postGitAPI(t, fixture.server, tc.path, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Success {
t.Errorf("success = true, want false")
}
if result.Operation != tc.operation {
t.Errorf("operation = %q, want %q", result.Operation, tc.operation)
}
if result.Error != tc.wantError {
t.Errorf("error = %q, want %q", result.Error, tc.wantError)
}
})
}
}
// TestGitOpForRepo_RejectsUnresolvableSubpath covers the shared repo-scope
// barrier. A bad `repo` must be refused before any git command runs, and the
// refusal has to carry the operation the caller asked for so the failure is
// attributable in a multi-repo UI.
func TestGitOpForRepo_RejectsUnresolvableSubpath(t *testing.T) {
fixture := newGitAPIFixture(t)
cases := []struct {
name string
path string
body any
operation string
wantError string
}{
{"missing subpath", "/api/v1/git/pull", GitPullRequest{Repo: "nope"}, "pull", "repo subpath not found"},
{
"traversal",
"/api/v1/git/commit",
GitCommitRequest{Message: "m", Repo: "../escape"},
"commit",
"invalid repo subpath",
},
{
"absolute",
"/api/v1/git/stage",
GitStageRequest{Repo: filepath.Join(t.TempDir(), "abs")},
"stage",
"repo subpath must be relative",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := postGitAPI(t, fixture.server, tc.path, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Operation != tc.operation {
t.Errorf("operation = %q, want %q", result.Operation, tc.operation)
}
if !strings.Contains(result.Error, tc.wantError) {
t.Errorf("error = %q, want it to contain %q", result.Error, tc.wantError)
}
})
}
}
// TestHandleGitCommit_CreatesCommitInWorktree asserts the endpoint's real
// effect rather than only its response: the working-tree change is staged and
// committed with exactly the requested message.
func TestHandleGitCommit_CreatesCommitInWorktree(t *testing.T) {
fixture := newGitAPIFixture(t)
writeFileAPI(t, fixture.repo, "three.txt", "three\n")
rec := postGitAPI(t, fixture.server, "/api/v1/git/commit", GitCommitRequest{
Message: "handler commit",
StageAll: true,
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if !result.Success {
t.Fatalf("commit failed: %+v", result)
}
if got := strings.TrimSpace(runGitAPI(t, fixture.repo, "log", "-1", "--format=%s")); got != "handler commit" {
t.Errorf("HEAD subject = %q, want %q", got, "handler commit")
}
if got := strings.TrimSpace(runGitAPI(t, fixture.repo, "status", "--porcelain")); got != "" {
t.Errorf("worktree not clean after stage_all commit: %q", got)
}
}
// TestHandleGitStageAndUnstage checks both directions of the index against
// `git status --porcelain`, which is the only place the difference between
// staged and unstaged is observable.
func TestHandleGitStageAndUnstage(t *testing.T) {
fixture := newGitAPIFixture(t)
writeFileAPI(t, fixture.repo, "staged.txt", "staged\n")
stage := postGitAPI(t, fixture.server, "/api/v1/git/stage", GitStageRequest{Paths: []string{"staged.txt"}})
if stage.Code != http.StatusOK {
t.Fatalf("stage status = %d (body %s)", stage.Code, stage.Body.String())
}
if result := decodeGitOperationResult(t, stage); !result.Success {
t.Fatalf("stage failed: %+v", result)
}
if got := strings.TrimSpace(runGitAPI(t, fixture.repo, "status", "--porcelain", "staged.txt")); got != "A staged.txt" {
t.Fatalf("porcelain after stage = %q, want %q", got, "A staged.txt")
}
unstage := postGitAPI(t, fixture.server, "/api/v1/git/unstage", GitUnstageRequest{Paths: []string{"staged.txt"}})
if unstage.Code != http.StatusOK {
t.Fatalf("unstage status = %d (body %s)", unstage.Code, unstage.Body.String())
}
if result := decodeGitOperationResult(t, unstage); !result.Success {
t.Fatalf("unstage failed: %+v", result)
}
if got := strings.TrimSpace(runGitAPI(t, fixture.repo, "status", "--porcelain", "staged.txt")); got != "?? staged.txt" {
t.Errorf("porcelain after unstage = %q, want %q", got, "?? staged.txt")
}
}
// TestHandleGitDiscard_RestoresTrackedFile asserts the destructive path
// actually restores the committed bytes.
func TestHandleGitDiscard_RestoresTrackedFile(t *testing.T) {
fixture := newGitAPIFixture(t)
writeFileAPI(t, fixture.repo, "one.txt", "locally edited\n")
rec := postGitAPI(t, fixture.server, "/api/v1/git/discard", GitDiscardRequest{Paths: []string{"one.txt"}})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
if result := decodeGitOperationResult(t, rec); !result.Success {
t.Fatalf("discard failed: %+v", result)
}
content, err := os.ReadFile(filepath.Join(fixture.repo, "one.txt"))
if err != nil {
t.Fatalf("read one.txt: %v", err)
}
if string(content) != "one\n" {
t.Errorf("one.txt = %q, want the committed %q", string(content), "one\n")
}
}
// TestHandleGitRenameBranch_MovesCurrentBranch verifies the rename lands on the
// checked-out branch rather than merely returning success.
func TestHandleGitRenameBranch_MovesCurrentBranch(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := postGitAPI(t, fixture.server, "/api/v1/git/rename-branch", GitRenameBranchRequest{NewName: "feature/renamed"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
if result := decodeGitOperationResult(t, rec); !result.Success {
t.Fatalf("rename failed: %+v", result)
}
if got := fixture.branch(t); got != "feature/renamed" {
t.Errorf("current branch = %q, want %q", got, "feature/renamed")
}
}
// TestHandleGitReset_HardMovesHead asserts the mode is forwarded: a hard reset
// has to move HEAD *and* drop the working-tree file, which a soft reset would
// not do.
func TestHandleGitReset_HardMovesHead(t *testing.T) {
fixture := newGitAPIFixture(t)
target := strings.TrimSpace(runGitAPI(t, fixture.repo, "rev-parse", "HEAD~1"))
rec := postGitAPI(t, fixture.server, "/api/v1/git/reset", GitResetRequest{CommitSHA: target, Mode: "hard"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
if result := decodeGitOperationResult(t, rec); !result.Success {
t.Fatalf("reset failed: %+v", result)
}
if got := fixture.head(t); got != target {
t.Errorf("HEAD = %q, want %q", got, target)
}
if _, err := os.Stat(filepath.Join(fixture.repo, "two.txt")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("two.txt still present after a hard reset (stat err = %v)", err)
}
}
// TestHandleGitRevertCommit_UncommitsHeadKeepingWork pins what revert_commit
// actually is: a soft un-commit of HEAD, which rewinds history one commit while
// leaving the files staged. Losing the staged work — or rewinding further than
// one commit — would be silent data loss for the user who clicked "undo commit".
func TestHandleGitRevertCommit_UncommitsHeadKeepingWork(t *testing.T) {
fixture := newGitAPIFixture(t)
target := fixture.head(t)
parent := strings.TrimSpace(runGitAPI(t, fixture.repo, "rev-parse", "HEAD~1"))
rec := postGitAPI(t, fixture.server, "/api/v1/git/revert-commit", GitRevertCommitRequest{CommitSHA: target})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
if result := decodeGitOperationResult(t, rec); !result.Success {
t.Fatalf("revert failed: %+v", result)
}
if got := fixture.head(t); got != parent {
t.Errorf("HEAD = %q, want the parent commit %q", got, parent)
}
if got := strings.TrimSpace(runGitAPI(t, fixture.repo, "status", "--porcelain", "two.txt")); got != "A two.txt" {
t.Errorf("porcelain for two.txt = %q, want it staged as %q", got, "A two.txt")
}
}
// TestHandleGitRevertCommit_RefusesOlderCommit covers the guard that keeps the
// soft un-commit from rewinding past HEAD, which would drop the commits in
// between without the caller asking for it.
func TestHandleGitRevertCommit_RefusesOlderCommit(t *testing.T) {
fixture := newGitAPIFixture(t)
head := fixture.head(t)
older := strings.TrimSpace(runGitAPI(t, fixture.repo, "rev-parse", "HEAD~1"))
rec := postGitAPI(t, fixture.server, "/api/v1/git/revert-commit", GitRevertCommitRequest{CommitSHA: older})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Success {
t.Errorf("success = true; only the latest commit may be reverted")
}
if result.Error != "can only revert the latest commit" {
t.Errorf("error = %q, want %q", result.Error, "can only revert the latest commit")
}
if got := fixture.head(t); got != head {
t.Errorf("HEAD moved to %q despite the refusal; want it left at %q", got, head)
}
}
// TestHandleGitShowCommit_ReturnsRequestedCommit pins the URI binding: the sha
// travels through the path parameter, and the response must be about that
// commit specifically.
func TestHandleGitShowCommit_ReturnsRequestedCommit(t *testing.T) {
fixture := newGitAPIFixture(t)
sha := fixture.head(t)
rec := getGitAPI(t, fixture.server, "/api/v1/git/commit/"+sha)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
var result process.CommitDiffResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode commit diff: %v", err)
}
if !result.Success {
t.Fatalf("show commit failed: %+v", result)
}
if result.CommitSHA != sha {
t.Errorf("commit_sha = %q, want %q", result.CommitSHA, sha)
}
if _, ok := result.Files["two.txt"]; !ok {
t.Errorf("files = %v, want it to contain two.txt", mapKeys(result.Files))
}
}
// TestHandleGitShowCommit_UnknownCommitReportsInBody covers the failure branch.
// A missing object is a per-commit result failure, not a transport error, so it
// answers 200 with success=false — the frontend renders the message inline
// rather than treating the request as broken.
func TestHandleGitShowCommit_UnknownCommitReportsInBody(t *testing.T) {
fixture := newGitAPIFixture(t)
const missing = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
rec := getGitAPI(t, fixture.server, "/api/v1/git/commit/"+missing)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var result process.CommitDiffResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode commit diff: %v", err)
}
if result.Success {
t.Errorf("success = true, want false")
}
if result.CommitSHA != missing {
t.Errorf("commit_sha = %q, want the requested sha echoed back", result.CommitSHA)
}
if !strings.Contains(result.Error, "bad object") {
t.Errorf("error = %q, want it to name the bad object", result.Error)
}
}
// TestHandleGitShowCommit_RejectsUnresolvableRepo covers the `?repo=` branch of
// the same handler, which has its own CommitDiffResult error shape.
func TestHandleGitShowCommit_RejectsUnresolvableRepo(t *testing.T) {
fixture := newGitAPIFixture(t)
sha := fixture.head(t)
rec := getGitAPI(t, fixture.server, "/api/v1/git/commit/"+sha+"?repo=nope")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
var result process.CommitDiffResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode commit diff: %v", err)
}
if !strings.Contains(result.Error, "repo subpath not found") {
t.Errorf("error = %q, want a repo-subpath failure", result.Error)
}
}
// TestHandleGitPushAndPull_RoundTripThroughOrigin drives both remote-facing
// handlers against a real bare repository so the assertions are about the
// remote's state, not just a success flag.
func TestHandleGitPushAndPull_RoundTripThroughOrigin(t *testing.T) {
fixture := newGitAPIFixture(t)
head := fixture.head(t)
push := postGitAPI(t, fixture.server, "/api/v1/git/push", GitPushRequest{SetUpstream: true})
if push.Code != http.StatusOK {
t.Fatalf("push status = %d (body %s)", push.Code, push.Body.String())
}
if result := decodeGitOperationResult(t, push); !result.Success {
t.Fatalf("push failed: %+v", result)
}
remoteHead := strings.TrimSpace(runGitAPI(t, fixture.bare, "rev-parse", "refs/heads/feature/work"))
if remoteHead != head {
t.Errorf("origin feature/work = %q, want the pushed HEAD %q", remoteHead, head)
}
pull := postGitAPI(t, fixture.server, "/api/v1/git/pull", GitPullRequest{})
if pull.Code != http.StatusOK {
t.Fatalf("pull status = %d (body %s)", pull.Code, pull.Body.String())
}
if result := decodeGitOperationResult(t, pull); !result.Success {
t.Fatalf("pull failed: %+v", result)
}
if got := fixture.head(t); got != head {
t.Errorf("HEAD moved during a no-op pull: %q != %q", got, head)
}
}
// TestHandleGitPushPreflight_ReportsRemoteState exercises the preflight
// endpoint, which the changes panel calls before offering a push.
func TestHandleGitPushPreflight_ReportsRemoteState(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := postGitAPI(t, fixture.server, "/api/v1/git/push-preflight", GitPushPreflightRequest{})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Operation != "push_preflight" {
t.Errorf("operation = %q, want %q", result.Operation, "push_preflight")
}
}
// advanceOriginMain adds a commit to main and publishes it, so the rebase and
// merge handlers — which both fetch and then operate on `origin/<base>` rather
// than the local ref — have something to integrate.
func (f *gitAPIFixture) advanceOriginMain(t *testing.T) string {
t.Helper()
runGitAPI(t, f.repo, "checkout", "main")
writeFileAPI(t, f.repo, "main-only.txt", "main\n")
runGitAPI(t, f.repo, "add", ".")
runGitAPI(t, f.repo, "commit", "-m", "main moves")
runGitAPI(t, f.repo, "push", "origin", "main")
mainHead := strings.TrimSpace(runGitAPI(t, f.repo, "rev-parse", "HEAD"))
runGitAPI(t, f.repo, "checkout", "feature/work")
return mainHead
}
// TestHandleGitRebase_ReplaysOntoBase asserts the branch is genuinely replayed:
// after rebasing onto a moved main, main's new commit is an ancestor of HEAD.
func TestHandleGitRebase_ReplaysOntoBase(t *testing.T) {
fixture := newGitAPIFixture(t)
mainHead := fixture.advanceOriginMain(t)
rec := postGitAPI(t, fixture.server, "/api/v1/git/rebase", GitRebaseRequest{BaseBranch: "main"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
if result := decodeGitOperationResult(t, rec); !result.Success {
t.Fatalf("rebase failed: %+v", result)
}
if got := strings.TrimSpace(runGitAPI(t, fixture.repo, "merge-base", "HEAD", "main")); got != mainHead {
t.Errorf("merge-base with main = %q, want main's tip %q — the branch was not replayed", got, mainHead)
}
}
// TestHandleGitMerge_BringsBaseCommitIn covers the merge counterpart, which
// leaves main's commit reachable from HEAD without rewriting the branch.
func TestHandleGitMerge_BringsBaseCommitIn(t *testing.T) {
fixture := newGitAPIFixture(t)
featureHead := fixture.head(t)
mainHead := fixture.advanceOriginMain(t)
rec := postGitAPI(t, fixture.server, "/api/v1/git/merge", GitMergeRequest{BaseBranch: "main"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
if result := decodeGitOperationResult(t, rec); !result.Success {
t.Fatalf("merge failed: %+v", result)
}
for _, want := range []string{featureHead, mainHead} {
runGitAPI(t, fixture.repo, "merge-base", "--is-ancestor", want, "HEAD")
}
}
// TestHandleGitAbort_WithNothingInProgress covers the abort handler's runtime
// path: the guard accepts "rebase", git refuses because nothing is in flight,
// and the failure is reported inside a 200 GitOperationResult rather than as a
// transport error.
func TestHandleGitAbort_WithNothingInProgress(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := postGitAPI(t, fixture.server, "/api/v1/git/abort", GitAbortRequest{Operation: "rebase"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Operation != "abort" {
t.Errorf("operation = %q, want %q", result.Operation, "abort")
}
if result.Success {
t.Errorf("success = true, but no rebase was in progress")
}
}
// TestHandleGitCreatePR_UnsupportedRemoteReportsProviderError pins the
// create-PR success path's error shape. The fixture's origin is a local path,
// which no PR provider claims; the refusal has to reach the caller inside a
// PRCreateResult (not as a transport failure) so the changes panel can explain
// which remotes are supported.
func TestHandleGitCreatePR_UnsupportedRemoteReportsProviderError(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := postGitAPI(t, fixture.server, "/api/v1/git/create-pr", GitCreatePRRequest{
Title: "add feature",
Body: "body",
BaseBranch: "main",
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var result process.PRCreateResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode PR result: %v", err)
}
if result.Success {
t.Errorf("success = true, want false")
}
if !strings.Contains(result.Error, "unsupported git remote for PR creation") {
t.Errorf("error = %q, want it to name the unsupported remote", result.Error)
}
}
// TestHandleGitCreatePR_ValidationErrors covers the two 400 branches, both of
// which answer with a PRCreateResult (not the GitOperationResult shape the
// neighbouring handlers use).
func TestHandleGitCreatePR_ValidationErrors(t *testing.T) {
fixture := newGitAPIFixture(t)
cases := []struct {
name string
body any
wantError string
}{
{"malformed body", "{not json", "invalid request: "},
{"missing title", GitCreatePRRequest{Body: "b"}, "title is required"},
{"unresolvable repo", GitCreatePRRequest{Title: "t", Repo: "nope"}, "repo subpath not found"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := postGitAPI(t, fixture.server, "/api/v1/git/create-pr", tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
var result process.PRCreateResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode PR result: %v", err)
}
if result.Success {
t.Errorf("success = true, want false")
}
if !strings.Contains(result.Error, tc.wantError) {
t.Errorf("error = %q, want it to contain %q", result.Error, tc.wantError)
}
})
}
}
// TestHandleGitLog_BoundsLimit pins both ends of the limit guard: an
// over-large limit is refused outright, and an unparseable one fails binding.
func TestHandleGitLog_BoundsLimit(t *testing.T) {
fixture := newGitAPIFixture(t)
cases := []struct {
name string
query string
wantError string
}{
{"above maximum", "?limit=501", "limit must be between 1 and 500"},
{"not a number", "?limit=many", "invalid request: "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := getGitAPI(t, fixture.server, "/api/v1/git/log"+tc.query)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
var result process.GitLogResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode git log: %v", err)
}
if result.Success {
t.Errorf("success = true, want false")
}
if !strings.Contains(result.Error, tc.wantError) {
t.Errorf("error = %q, want it to contain %q", result.Error, tc.wantError)
}
})
}
}
// TestHandleGitLog_RejectsUnsafeTargetBranch is the sanitiser test. A valid
// target anchors the log at the merge-base with origin/main, so only the
// branch's own two commits come back. An injection-shaped ref must be dropped
// rather than interpolated into the git arguments — which is observable as the
// handler falling back to the open-ended "recent commits" walk that also
// includes main's initial commit.
func TestHandleGitLog_RejectsUnsafeTargetBranch(t *testing.T) {
fixture := newGitAPIFixture(t)
anchored := gitLogSubjects(t, fixture.server, "/api/v1/git/log?target_branch=main")
if len(anchored) != 2 || anchored[0] != "feature two" || anchored[1] != "feature one" {
t.Fatalf("anchored log = %v, want exactly the branch's own commits", anchored)
}
for _, unsafe := range []string{"main;whoami", "main..HEAD", "/etc/passwd", "main.lock", "-main"} {
t.Run(unsafe, func(t *testing.T) {
got := gitLogSubjects(t, fixture.server, "/api/v1/git/log?target_branch="+unsafe)
if len(got) != 3 {
t.Fatalf("log for %q = %v, want the unanchored 3-commit history "+
"(the ref must be dropped, not passed to git)", unsafe, got)
}
if got[2] != "initial" {
t.Errorf("oldest commit = %q, want %q", got[2], "initial")
}
})
}
}
// TestHandleGitLog_HonoursSince covers the explicit-base path, where the caller
// supplies the anchor instead of a branch to merge-base against.
func TestHandleGitLog_HonoursSince(t *testing.T) {
fixture := newGitAPIFixture(t)
base := strings.TrimSpace(runGitAPI(t, fixture.repo, "rev-parse", "HEAD~1"))
got := gitLogSubjects(t, fixture.server, "/api/v1/git/log?since="+base)
if len(got) != 1 || got[0] != "feature two" {
t.Errorf("log since HEAD~1 = %v, want just [feature two]", got)
}
}
func gitLogSubjects(t *testing.T, srv *Server, path string) []string {
t.Helper()
rec := getGitAPI(t, srv, path)
if rec.Code != http.StatusOK {
t.Fatalf("git log %s: status = %d (body %s)", path, rec.Code, rec.Body.String())
}
var result process.GitLogResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode git log: %v", err)
}
if !result.Success {
t.Fatalf("git log %s failed: %+v", path, result)
}
subjects := make([]string, 0, len(result.Commits))
for _, commit := range result.Commits {
subjects = append(subjects, commit.CommitMessage)
}
return subjects
}
// TestHandleGitCumulativeDiff_RequiresBase covers the query binding, which is
// the only required parameter on the endpoint.
func TestHandleGitCumulativeDiff_RequiresBase(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := getGitAPI(t, fixture.server, "/api/v1/git/cumulative-diff")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
var result process.CumulativeDiffResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode cumulative diff: %v", err)
}
if !strings.HasPrefix(result.Error, "invalid request: ") {
t.Errorf("error = %q, want an %q prefix", result.Error, "invalid request: ")
}
}
// TestHandleGitCumulativeDiff_AnchorsOnTargetBranch checks the merge-base
// recomputation: the caller's stale base is replaced by the live divergence
// with origin/main, so the diff carries exactly the branch's own files.
func TestHandleGitCumulativeDiff_AnchorsOnTargetBranch(t *testing.T) {
fixture := newGitAPIFixture(t)
stale := fixture.head(t) // HEAD as base would produce an empty diff
rec := getGitAPI(t, fixture.server, "/api/v1/git/cumulative-diff?base="+stale+"&target_branch=main")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
var result process.CumulativeDiffResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode cumulative diff: %v", err)
}
if !result.Success {
t.Fatalf("cumulative diff failed: %+v", result)
}
for _, want := range []string{"one.txt", "two.txt"} {
if _, ok := result.Files[want]; !ok {
t.Errorf("files = %v, want it to contain %q", mapKeys(result.Files), want)
}
}
if _, ok := result.Files["README.md"]; ok {
t.Errorf("files include README.md; the diff is not anchored at the merge-base")
}
}
// TestHandleGitCumulativeDiff_RejectsUnresolvableRepo covers the 400 branch
// that runGitCumulativeDiffForRepo returns before touching git.
func TestHandleGitCumulativeDiff_RejectsUnresolvableRepo(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := getGitAPI(t, fixture.server, "/api/v1/git/cumulative-diff?base=HEAD&repo=nope")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
var result process.CumulativeDiffResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode cumulative diff: %v", err)
}
if !strings.Contains(result.Error, "repo subpath not found") {
t.Errorf("error = %q, want a repo-subpath failure", result.Error)
}
}
// TestHandleGitStatus_ReportsWorktreeState asserts the single-repo status
// endpoint reflects the real worktree, including the branch name and the
// classification of each dirty path.
func TestHandleGitStatus_ReportsWorktreeState(t *testing.T) {
fixture := newGitAPIFixture(t)
writeFileAPI(t, fixture.repo, "untracked.txt", "new\n")
writeFileAPI(t, fixture.repo, "one.txt", "edited\n")
rec := getGitAPI(t, fixture.server, "/api/v1/git/status?fresh=true")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
var result GitStatusResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode git status: %v", err)
}
if !result.Success {
t.Fatalf("git status failed: %+v", result)
}
if result.Branch != "feature/work" {
t.Errorf("branch = %q, want %q", result.Branch, "feature/work")
}
if !slices.Contains(result.Untracked, "untracked.txt") {
t.Errorf("untracked = %v, want it to contain untracked.txt", result.Untracked)
}
if !slices.Contains(result.Modified, "one.txt") {
t.Errorf("modified = %v, want it to contain one.txt", result.Modified)
}
if result.Timestamp == "" {
t.Errorf("timestamp is empty; the response must carry a snapshot time")
}
}
// TestHandleGitStatus_RejectsUnresolvableRepo covers the tracker-lookup 400.
func TestHandleGitStatus_RejectsUnresolvableRepo(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := getGitAPI(t, fixture.server, "/api/v1/git/status?repo=nope")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
var result GitStatusResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode git status: %v", err)
}
if result.Success {
t.Errorf("success = true, want false")
}
if !strings.Contains(result.Error, "repo subpath not found") {
t.Errorf("error = %q, want a repo-subpath failure", result.Error)
}
}
// TestHandleGitStatusMulti_SingleRepoUsesEmptyRepositoryName pins the uniform
// response shape callers rely on: a single-repo workspace still answers with
// the multi shape, tagged with an empty repository name.
func TestHandleGitStatusMulti_SingleRepoUsesEmptyRepositoryName(t *testing.T) {
fixture := newGitAPIFixture(t)
rec := getGitAPI(t, fixture.server, "/api/v1/git/status/multi")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String())
}
var result MultiRepoGitStatusResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode multi status: %v", err)
}
if !result.Success || len(result.Repos) != 1 {
t.Fatalf("multi status = %+v, want exactly one repo entry", result)
}
if result.Repos[0].RepositoryName != "" {
t.Errorf("repository_name = %q, want empty for a single-repo workspace", result.Repos[0].RepositoryName)
}
if result.Repos[0].Status.Branch != "feature/work" {
t.Errorf("branch = %q, want %q", result.Repos[0].Status.Branch, "feature/work")
}
}
// TestHandleGitError_ClassifiesOperationInProgress pins the one branch of
// handleGitError that is not a generic 500: a busy GitOperator must answer 409
// with a stable, non-leaking message, because the frontend retries on that
// code and shows the raw error on every other one.
func TestHandleGitError_ClassifiesOperationInProgress(t *testing.T) {
fixture := newGitAPIFixture(t)
cases := []struct {
name string
err error
wantStatus int
wantError string
}{
{
"busy operator",
process.ErrOperationInProgress,
http.StatusConflict,
"another git operation is already in progress",
},
{"other failure", errors.New("fatal: not a git repository"), http.StatusInternalServerError, "fatal: not a git repository"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
fixture.server.handleGitError(c, "rebase", tc.err)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
result := decodeGitOperationResult(t, rec)
if result.Success {
t.Errorf("success = true, want false")
}
if result.Operation != "rebase" {
t.Errorf("operation = %q, want %q", result.Operation, "rebase")
}
if result.Error != tc.wantError {
t.Errorf("error = %q, want %q", result.Error, tc.wantError)
}
})
}
}