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/` 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) } }) } }