Files
kandev/apps/backend/internal/agentctl/server/process/git_test.go
T

986 lines
33 KiB
Go

package process
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
func TestGitOperatorRemoteContributionRoutesPushesAndPreflightToSource(t *testing.T) {
cases := []struct {
name string
provider string
kind string
canonicalURL string
sourceHost string
sourcePath string
sourceRemote string
}{
{
name: "github pull request",
provider: taskmodels.RemoteContributionProviderGitHub,
kind: taskmodels.RemoteContributionKindPullRequest,
canonicalURL: "https://github.com/acme/widget/pull/7",
sourceHost: "github.com",
sourcePath: "contributor/widget",
sourceRemote: "https://github.com/contributor/widget.git",
},
{
name: "gitlab merge request",
provider: taskmodels.RemoteContributionProviderGitLab,
kind: taskmodels.RemoteContributionKindMergeRequest,
canonicalURL: "https://gitlab.example.test/acme/widget/-/merge_requests/7",
sourceHost: "gitlab.example.test",
sourcePath: "contributor/widget",
sourceRemote: "https://gitlab.example.test/contributor/widget.git",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
t.Cleanup(cleanup)
originDir := strings.TrimSpace(runGit(t, repoDir, "remote", "get-url", "origin"))
runGit(t, repoDir, "checkout", "-b", "feature/contribution")
writeFile(t, repoDir, "contribution.txt", "contribution\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "contribution")
headSHA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
binding := &taskmodels.RemoteContribution{
Version: taskmodels.RemoteContributionVersion,
Provider: tc.provider,
Kind: tc.kind,
CanonicalURL: tc.canonicalURL,
Number: 7,
State: taskmodels.RemoteContributionStateOpen,
BaseBranch: "main",
HeadBranch: "feature/contribution",
HeadSHA: headSHA,
SourceRepository: taskmodels.RemoteContributionRepository{
Host: tc.sourceHost,
Path: tc.sourcePath,
RemoteURL: tc.sourceRemote,
},
CollaborationAllowed: true,
}
if err := binding.Validate(); err != nil {
t.Fatalf("binding.Validate() = %v", err)
}
// Keep the test hermetic while exercising the credential-free source URL
// that the operator receives from runtime materialization.
runGit(t, repoDir, "config", "url."+originDir+".insteadOf", binding.SourceRepository.RemoteURL)
runGit(t, repoDir, "remote", "add", binding.ContributionRemoteName(), binding.SourceRepository.RemoteURL)
runGit(t, repoDir, "push", binding.ContributionRemoteName(), "HEAD:refs/heads/feature/contribution")
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
operator.setRemoteContribution(binding)
preflight, err := operator.PushPreflight(context.Background())
if err != nil {
t.Fatalf("PushPreflight returned error: %v", err)
}
if !preflight.Success {
t.Fatalf("PushPreflight failed: %+v", preflight)
}
if got := strings.TrimSpace(runGit(t, originDir, "rev-parse", "refs/heads/feature/contribution")); got != headSHA {
t.Fatalf("preflight changed source branch: %q != %q", got, headSHA)
}
forced, err := operator.Push(context.Background(), true, false)
if err != nil {
t.Fatalf("forced Push returned error: %v", err)
}
if forced.Success || !strings.Contains(forced.Error, "force push is not allowed") {
t.Fatalf("forced Push = %+v, want a contribution force-push rejection", forced)
}
pushed, err := operator.Push(context.Background(), false, false)
if err != nil {
t.Fatalf("Push returned error: %v", err)
}
if !pushed.Success {
t.Fatalf("Push failed: %+v", pushed)
}
remoteHead := strings.TrimSpace(runGit(t, originDir, "rev-parse", "refs/heads/feature/contribution"))
if remoteHead != headSHA {
t.Fatalf("source branch head = %q, want %q", remoteHead, headSHA)
}
if got := strings.TrimSpace(runGit(t, repoDir, "remote", "get-url", "origin")); got != originDir {
t.Fatalf("origin URL = %q, want %q", got, originDir)
}
result, err := operator.CreatePR(context.Background(), "untrusted title", "untrusted body", "main", false)
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
if !result.Success || !result.BranchPushed || result.PRURL != binding.CanonicalURL || result.Provider != binding.Provider {
t.Fatalf("CreatePR = %+v, want reuse of existing contribution", result)
}
})
}
}
func TestGitOperatorRemoteContributionPullsFromSourceRemote(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
t.Cleanup(cleanup)
targetDir := strings.TrimSpace(runGit(t, repoDir, "remote", "get-url", "origin"))
sourceRoot := t.TempDir()
sourceBare := filepath.Join(sourceRoot, "source.git")
sourceWork := filepath.Join(sourceRoot, "source-work")
runGit(t, sourceRoot, "init", "--bare", "--initial-branch=main", sourceBare)
runGit(t, sourceRoot, "clone", targetDir, sourceWork)
runGit(t, sourceWork, "config", "user.email", "test@test.com")
runGit(t, sourceWork, "config", "user.name", "Test User")
runGit(t, sourceWork, "checkout", "-b", "feature/contribution")
writeFile(t, sourceWork, "source-change.txt", "source change\n")
runGit(t, sourceWork, "add", "source-change.txt")
runGit(t, sourceWork, "commit", "-m", "source contribution")
sourceSHA := strings.TrimSpace(runGit(t, sourceWork, "rev-parse", "HEAD"))
runGit(t, sourceWork, "remote", "add", "source", sourceBare)
runGit(t, sourceWork, "push", "source", "main", "feature/contribution")
sourceURL := "https://github.com/contributor/widget.git"
runGit(t, repoDir, "config", "url.file://"+sourceBare+".insteadOf", sourceURL)
runGit(t, repoDir, "checkout", "-b", "feature/contribution")
binding := &taskmodels.RemoteContribution{
Version: taskmodels.RemoteContributionVersion,
Provider: taskmodels.RemoteContributionProviderGitHub,
Kind: taskmodels.RemoteContributionKindPullRequest,
CanonicalURL: "https://github.com/acme/widget/pull/7",
Number: 7,
State: taskmodels.RemoteContributionStateOpen,
BaseBranch: "main",
HeadBranch: "feature/contribution",
HeadSHA: sourceSHA,
SourceRepository: taskmodels.RemoteContributionRepository{
Host: "github.com", Path: "contributor/widget", RemoteURL: sourceURL,
},
CollaborationAllowed: true,
}
runGit(t, repoDir, "remote", "add", binding.ContributionRemoteName(), sourceURL)
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
operator.setRemoteContribution(binding)
result, err := operator.Pull(context.Background(), false)
if err != nil {
t.Fatalf("Pull returned error: %v", err)
}
if !result.Success {
t.Fatalf("Pull failed: %+v", result)
}
if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")); got != sourceSHA {
t.Fatalf("pulled HEAD = %q, want source SHA %q", got, sourceSHA)
}
if got := strings.TrimSpace(runGit(t, repoDir, "remote", "get-url", "origin")); got != targetDir {
t.Fatalf("origin URL = %q, want target %q", got, targetDir)
}
}
func TestGitOperatorContributionDestinationPushesWithoutChangingPullRemote(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
targetDir := t.TempDir()
runGit(t, targetDir, "init", "--bare")
runGit(t, repoDir, "remote", "add", "contrib-destination", targetDir)
runGit(t, repoDir, "checkout", "-b", "feature/destination")
writeFile(t, repoDir, "destination.txt", "destination\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "destination")
destination := &taskmodels.ContributionDestination{
Version: taskmodels.ContributionDestinationVersion,
Provider: taskmodels.ContributionDestinationProviderGitHub,
SourceRepository: taskmodels.ContributionDestinationRepository{
Host: "github.com", Path: "kdlbs/kandev", ProviderID: "100", RemoteURL: "https://github.com/kdlbs/kandev.git",
},
TargetRepository: taskmodels.ContributionDestinationRepository{
Host: "github.com", Path: "agent/kandev", ProviderID: "200", RemoteURL: "https://github.com/agent/kandev.git",
},
}
if err := destination.Validate(); err != nil {
t.Fatal(err)
}
runGit(t, repoDir, "config", "url."+targetDir+".insteadOf", destination.TargetRepository.RemoteURL)
// The model derives the actual deterministic remote name. Rename the test
// remote after binding so the operator exercises the server-authored name.
runGit(t, repoDir, "remote", "rename", "contrib-destination", destination.ContributionRemoteName())
runGit(t, repoDir, "remote", "set-url", "--push", destination.ContributionRemoteName(), destination.TargetRepository.RemoteURL)
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
operator.setContributionDestination(destination)
pushed, err := operator.Push(context.Background(), false, false)
if err != nil || !pushed.Success {
t.Fatalf("Push = %+v, err = %v", pushed, err)
}
if got := strings.TrimSpace(runGit(t, targetDir, "rev-parse", "refs/heads/feature/destination")); got == "" {
t.Fatal("destination branch was not pushed")
}
if got := strings.TrimSpace(runGit(t, repoDir, "remote", "get-url", "origin")); got == "" {
t.Fatal("origin remote was removed or rewritten")
}
}
func TestGitOperatorPush_PreservesExistingUpstream(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
runGit(t, repoDir, "checkout", "-b", "feature/pr-branch")
writeFile(t, repoDir, "feature.txt", "feature branch\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "feature commit")
runGit(t, repoDir, "push", "-u", "origin", "feature/pr-branch")
runGit(t, repoDir, "checkout", "main")
worktreeBase := t.TempDir()
directDir := filepath.Join(worktreeBase, "wt-direct")
suffixedDir := filepath.Join(worktreeBase, "wt-sfx")
runGit(t, repoDir, "worktree", "add", directDir, "feature/pr-branch")
runGit(t, repoDir, "worktree", "add", "-b", "feature/pr-branch-sfx", suffixedDir, "origin/feature/pr-branch")
runGit(t, suffixedDir, "branch", "--set-upstream-to=origin/feature/pr-branch", "feature/pr-branch-sfx")
writeFile(t, suffixedDir, "feature.txt", "feature branch\nlocal change\n")
runGit(t, suffixedDir, "add", ".")
runGit(t, suffixedDir, "commit", "-m", "local change")
before := strings.TrimSpace(runGit(t, suffixedDir, "rev-parse", "--abbrev-ref", "@{upstream}"))
if before != "origin/feature/pr-branch" {
t.Fatalf("upstream before push = %q, want %q", before, "origin/feature/pr-branch")
}
gitOp := NewGitOperator(suffixedDir, log, nil)
result, err := gitOp.Push(context.Background(), false, false)
if err != nil {
t.Fatalf("Push returned error: %v", err)
}
if !result.Success {
t.Fatalf("Push failed: %+v", result)
}
after := strings.TrimSpace(runGit(t, suffixedDir, "rev-parse", "--abbrev-ref", "@{upstream}"))
if after != "origin/feature/pr-branch" {
t.Fatalf("upstream after push = %q, want %q", after, "origin/feature/pr-branch")
}
}
func TestParseCommitDiff_PathsWithSpaces(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
// Create a file with spaces in its path, commit it, then verify parseCommitDiff
// extracts the correct unquoted file path.
dir := filepath.Join(repoDir, "path with spaces")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("failed to create dir: %v", err)
}
writeFile(t, dir, "file.md", "hello world\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "add spaced path")
sha := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.ShowCommit(context.Background(), sha)
if err != nil {
t.Fatalf("ShowCommit error: %v", err)
}
if !result.Success {
t.Fatalf("ShowCommit failed: %+v", result)
}
expectedPath := "path with spaces/file.md"
if _, exists := result.Files[expectedPath]; !exists {
keys := make([]string, 0, len(result.Files))
for k := range result.Files {
keys = append(keys, k)
}
t.Errorf("expected Files to contain key %q, got keys: %v", expectedPath, keys)
}
}
func TestShowCommit_MergeCommitUsesFirstParentDiff(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
t.Cleanup(cleanup)
log := newTestLogger(t)
ctx := context.Background()
runGit(t, repoDir, "checkout", "-b", "feature/merge-diff")
writeFile(t, repoDir, "feature-only.txt", "feature branch\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "feature work")
runGit(t, repoDir, "checkout", "main")
writeFile(t, repoDir, "incoming.txt", "merged branch\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "main work")
runGit(t, repoDir, "checkout", "feature/merge-diff")
runGit(t, repoDir, "merge", "--no-ff", "-m", "Merge main into feature/merge-diff", "main")
mergeSHA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.ShowCommit(ctx, mergeSHA)
if err != nil {
t.Fatalf("ShowCommit error: %v", err)
}
if !result.Success {
t.Fatalf("ShowCommit failed: %+v", result)
}
if _, ok := result.Files["incoming.txt"]; !ok {
t.Fatalf("merge diff missing incoming first-parent change; files=%v", fileKeys(result.Files))
}
entry, ok := result.Files["incoming.txt"].(map[string]interface{})
if !ok {
t.Fatalf("incoming.txt entry type = %T, want map[string]interface{}", result.Files["incoming.txt"])
}
if status := entry["status"]; status != "added" {
t.Errorf("incoming.txt status = %v, want added", status)
}
if _, ok := result.Files["feature-only.txt"]; ok {
t.Fatalf("merge diff incorrectly included a file already present in the first parent")
}
if result.FilesChanged != 1 {
t.Errorf("FilesChanged = %d, want 1", result.FilesChanged)
}
if result.Insertions != 1 || result.Deletions != 0 {
t.Errorf("stats = +%d/-%d, want +1/-0", result.Insertions, result.Deletions)
}
diff, skipReason := fileEntryDiff(t, result.Files, "incoming.txt")
if skipReason != "" {
t.Errorf("incoming diff skip reason = %q, want empty", skipReason)
}
if !strings.Contains(diff, "+merged branch") {
t.Errorf("incoming diff = %q, want merged file content", diff)
}
}
func TestGitOperatorCreatePR_UsesAzureCLIForAzureRepos(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell wrapper test is Unix-only")
}
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
runGit(t, repoDir, "checkout", "-b", "feature/azure-pr")
writeFile(t, repoDir, "azure.txt", "azure repos\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "add azure change")
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("find git: %v", err)
}
scriptDir := t.TempDir()
azArgsPath := filepath.Join(scriptDir, "az-args.txt")
writeExecutable(t, filepath.Join(scriptDir, "git"), fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%s\\n' 'git@ssh.dev.azure.com:v3/acme/platform/widgets'\n exit 0\nfi\nexec %q \"$@\"\n", "%s", realGit))
writeExecutable(t, filepath.Join(scriptDir, "az"), fmt.Sprintf(`#!/bin/sh
if [ "$1" = "extension" ] && [ "$2" = "show" ]; then
exit 0
fi
printf '%%s\n' "$@" > %q
printf 'WARNING: preview command group\n' >&2
printf 'Creating pull request...\n'
cat <<'EOF'
{"pullRequestId":42,"repository":{"remoteUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}
EOF
`, azArgsPath))
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.CreatePR(context.Background(), "Add Azure support", "PR body", "origin/main", true)
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
if !result.Success {
t.Fatalf("CreatePR failed: %+v", result)
}
if got, want := result.PRURL, "https://dev.azure.com/acme/platform/_git/widgets/pullrequest/42"; got != want {
t.Fatalf("PRURL = %q, want %q", got, want)
}
gotArgs := readScriptArgs(t, azArgsPath)
wantArgs := []string{
"repos",
"pr",
"create",
"--organization",
"https://dev.azure.com/acme",
"--project",
"platform",
"--repository",
"widgets",
"--source-branch",
"feature/azure-pr",
"--target-branch",
"main",
"--title",
"Add Azure support",
"--description",
"PR body",
"--draft",
"true",
"-o",
"json",
}
if strings.Join(gotArgs, "\n") != strings.Join(wantArgs, "\n") {
t.Fatalf("az args mismatch\n got: %q\nwant: %q", gotArgs, wantArgs)
}
}
func writeExecutable(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("write executable %s: %v", path, err)
}
}
func writeGitRemoteWrapper(t *testing.T, scriptDir, originURL string) {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("find git: %v", err)
}
writeExecutable(
t,
filepath.Join(scriptDir, "git"),
fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%%s\\n' %q\n exit 0\nfi\nexec %q \"$@\"\n", originURL, realGit),
)
}
func TestGitOperatorCreatePR_GitLabUsesGLab(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell wrapper test is Unix-only")
}
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
runGit(t, repoDir, "checkout", "-b", "feature/gitlab-pr")
writeFile(t, repoDir, "gitlab.txt", "gitlab\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "add gitlab change")
scriptDir := t.TempDir()
writeGitRemoteWrapper(t, scriptDir, "git@gitlab.com:acme/widgets.git")
writeExecutable(t, filepath.Join(scriptDir, "glab"), `#!/bin/sh
if [ "$1" = "mr" ] && [ "$2" = "list" ]; then
printf '[]\n'
exit 0
fi
printf 'https://gitlab.com/acme/widgets/-/merge_requests/42\n'
`)
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
if !result.Success {
t.Fatalf("CreatePR should succeed for GitLab remote: %+v", result)
}
if result.PRURL != "https://gitlab.com/acme/widgets/-/merge_requests/42" {
t.Fatalf("PRURL = %q", result.PRURL)
}
if result.Provider != "gitlab" {
t.Fatalf("Provider = %q, want gitlab", result.Provider)
}
}
func TestGitOperatorCreatePR_AzureMissingCLI(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell wrapper test is Unix-only")
}
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-cli")
writeFile(t, repoDir, "azure.txt", "azure\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "add azure change")
scriptDir := t.TempDir()
writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets")
// Isolate PATH so CI's preinstalled az is not visible to LookPath.
t.Setenv("PATH", scriptDir)
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
if result.Success {
t.Fatalf("CreatePR should fail without az: %+v", result)
}
if !result.BranchPushed || result.Error != "branch was pushed; retry pull request creation" {
t.Fatalf("partial result = %+v", result)
}
}
func TestGitOperatorCreatePR_AzureMissingExtension(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell wrapper test is Unix-only")
}
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-ext")
writeFile(t, repoDir, "azure.txt", "azure\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "add azure change")
scriptDir := t.TempDir()
writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets")
writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 1; fi\nexit 0\n")
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
if result.Success {
t.Fatalf("CreatePR should fail without azure-devops extension: %+v", result)
}
if !result.BranchPushed || result.Error != "branch was pushed; retry pull request creation" {
t.Fatalf("partial result = %+v", result)
}
}
func TestEnsureAzureDevOpsCLI(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell wrapper test is Unix-only")
}
scriptDir := t.TempDir()
writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 0; fi\nexit 1\n")
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := ensureAzureDevOpsCLI(context.Background()); err != nil {
t.Fatalf("ensureAzureDevOpsCLI() error = %v", err)
}
t.Setenv("PATH", "")
if err := ensureAzureDevOpsCLI(context.Background()); err == nil {
t.Fatal("expected error when az is missing")
} else if err.Error() != errAzureCLIMissing {
t.Fatalf("error = %q, want %q", err.Error(), errAzureCLIMissing)
}
}
func TestDetectPRProvider(t *testing.T) {
tests := []struct {
name string
remote string
want prProvider
}{
{name: "azure https", remote: "https://dev.azure.com/acme/platform/_git/widgets", want: prProviderAzureRepos},
{name: "azure ssh", remote: "git@ssh.dev.azure.com:v3/acme/platform/widgets", want: prProviderAzureRepos},
{
name: "azure ssh url scheme",
remote: "ssh://ssh.dev.azure.com:22/v3/acme/platform/widgets",
want: prProviderAzureRepos,
},
{name: "visualstudio", remote: "https://acme.visualstudio.com/platform/_git/widgets", want: prProviderAzureRepos},
{name: "github", remote: "https://github.com/acme/widgets.git", want: prProviderGitHub},
{name: "gitlab https", remote: "https://gitlab.com/acme/widgets.git", want: prProviderGitLab},
{name: "gitlab ssh", remote: "git@gitlab.com:acme/widgets.git", want: prProviderGitLab},
{
name: "github path must not match azure substring",
remote: "git@github.com:acme/dev.azure.com-docs.git",
want: prProviderGitHub,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := detectPRProvider(tt.remote); got != tt.want {
t.Fatalf("detectPRProvider(%q) = %q, want %q", tt.remote, got, tt.want)
}
})
}
}
func TestDetectPRProviderSelfManagedGitLabRequiresMatchingConfiguredHost(t *testing.T) {
t.Setenv("KANDEV_GITLAB_HOST", "http://gitlab.internal:8080")
if got := detectPRProvider("git@gitlab.internal:acme/widgets.git"); got != prProviderGitLab {
t.Fatalf("matching self-managed provider = %q, want gitlab", got)
}
if got := detectPRProvider("git@other.internal:acme/widgets.git"); got == prProviderGitLab {
t.Fatalf("mismatched provider = %q, must not be gitlab", got)
}
}
func TestRemoteHostFromURL(t *testing.T) {
tests := []struct {
name string
remote string
want string
}{
{name: "https", remote: "https://dev.azure.com/acme/platform/_git/widgets", want: "dev.azure.com"},
{name: "scp", remote: "git@ssh.dev.azure.com:v3/acme/platform/widgets", want: "ssh.dev.azure.com"},
{
name: "ssh url scheme",
remote: "ssh://ssh.dev.azure.com:22/v3/acme/platform/widgets",
want: "ssh.dev.azure.com",
},
{name: "github", remote: "https://github.com/acme/widgets.git", want: "github.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := remoteHostFromURL(tt.remote); got != tt.want {
t.Fatalf("remoteHostFromURL(%q) = %q, want %q", tt.remote, got, tt.want)
}
})
}
}
func TestParseAzurePRCreateResponse(t *testing.T) {
payload := `{"pullRequestId":42,"repository":{"remoteUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}`
stdout := "Creating pull request...\n" + payload
response, err := parseAzurePRCreateResponse(stdout)
if err != nil {
t.Fatalf("parseAzurePRCreateResponse() error = %v", err)
}
if response.PullRequestID != 42 {
t.Fatalf("PullRequestID = %d, want 42", response.PullRequestID)
}
}
func TestParseAzurePRCreateResponse_multilineJSON(t *testing.T) {
stdout := "Creating pull request...\n{\n \"pullRequestId\": 9,\n \"repository\": {\n \"remoteUrl\": \"https://dev.azure.com/acme/platform/_git/widgets\"\n }\n}\n"
response, err := parseAzurePRCreateResponse(stdout)
if err != nil {
t.Fatalf("parseAzurePRCreateResponse() error = %v", err)
}
if response.PullRequestID != 9 {
t.Fatalf("PullRequestID = %d, want 9", response.PullRequestID)
}
}
func TestParseAzurePRCreateResponse_bracesInString(t *testing.T) {
// Braces inside JSON string values must not break line-based parsing.
stdout := "status line\n" + `{"pullRequestId":7,"repository":{"remoteUrl":"https://dev.azure.com/o/p/_git/r","note":"} not end"}}`
response, err := parseAzurePRCreateResponse(stdout)
if err != nil {
t.Fatalf("parseAzurePRCreateResponse() error = %v", err)
}
if response.PullRequestID != 7 {
t.Fatalf("PullRequestID = %d, want 7", response.PullRequestID)
}
}
func TestParseAzureRepoInfo(t *testing.T) {
tests := []struct {
name string
remote string
wantOrg string
wantProj string
wantRepo string
}{
{
name: "dev azure https",
remote: "https://dev.azure.com/acme/platform/_git/widgets.git",
wantOrg: "https://dev.azure.com/acme",
wantProj: "platform",
wantRepo: "widgets",
},
{
name: "visualstudio https",
remote: "https://acme.visualstudio.com/platform/_git/widgets",
wantOrg: "https://acme.visualstudio.com",
wantProj: "platform",
wantRepo: "widgets",
},
{
name: "azure ssh",
remote: "git@ssh.dev.azure.com:v3/acme/platform/widgets",
wantOrg: "https://dev.azure.com/acme",
wantProj: "platform",
wantRepo: "widgets",
},
{
name: "azure ssh url scheme",
remote: "ssh://ssh.dev.azure.com:22/v3/acme/platform/widgets",
wantOrg: "https://dev.azure.com/acme",
wantProj: "platform",
wantRepo: "widgets",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info, err := parseAzureRepoInfo(tt.remote)
if err != nil {
t.Fatalf("parseAzureRepoInfo(%q) error = %v", tt.remote, err)
}
if info.OrganizationURL != tt.wantOrg || info.Project != tt.wantProj || info.Repository != tt.wantRepo {
t.Fatalf("parseAzureRepoInfo(%q) = %+v, want org=%q project=%q repo=%q", tt.remote, info, tt.wantOrg, tt.wantProj, tt.wantRepo)
}
})
}
}
func TestSanitizeRepositoryArgs(t *testing.T) {
got := sanitizeRepositoryArgs([]string{
"pr",
"create",
"--title",
"real title",
"--body=secret body",
"--description",
"secret description",
"--head",
"feature/branch",
})
want := []string{
"pr",
"create",
"--title",
"[REDACTED]",
"--body=[REDACTED]",
"--description",
"[REDACTED]",
"--head",
"feature/branch",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("sanitizeRepositoryArgs() = %q, want %q", got, want)
}
}
func TestRedactRemoteURL(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "https credentials",
url: "https://token:x-oauth-basic@github.com/acme/widgets.git",
want: "https://github.com/acme/widgets.git",
},
{
name: "scp style",
url: "git@ssh.dev.azure.com:v3/acme/platform/widgets",
want: "ssh.dev.azure.com:v3/acme/platform/widgets",
},
{
name: "plain https",
url: "https://dev.azure.com/acme/platform/_git/widgets",
want: "https://dev.azure.com/acme/platform/_git/widgets",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := redactRemoteURL(tt.url); got != tt.want {
t.Fatalf("redactRemoteURL(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
}
func readScriptArgs(t *testing.T, path string) []string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read args file: %v", err)
}
trimmed := strings.TrimSpace(string(content))
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "\n")
}
// fileEntryDiff returns the diff string and skip reason for a file entry from a
// CumulativeDiffResult/CommitDiffResult Files map.
func fileEntryDiff(t *testing.T, files map[string]interface{}, path string) (diff, skipReason string) {
t.Helper()
raw, ok := files[path]
if !ok {
t.Fatalf("Files missing key %q; keys=%v", path, fileKeys(files))
}
entry, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("Files[%q] is %T, want map[string]interface{}", path, raw)
}
diff, _ = entry["diff"].(string)
skipReason, _ = entry["diff_skip_reason"].(string)
return diff, skipReason
}
func fileKeys(files map[string]interface{}) []string {
keys := make([]string, 0, len(files))
for k := range files {
keys = append(keys, k)
}
return keys
}
// TestGetCumulativeDiff_TruncatesLargeFile verifies a single file whose diff
// exceeds maxDiffOutputSize is truncated and flagged.
func TestGetCumulativeDiff_TruncatesLargeFile(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
base := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
// README.md is already tracked; overwrite it with >256 KB of new content so
// `git diff <base>` produces a single large per-file diff.
large := strings.Repeat("an additional line of content for the big diff\n", 8000) // ~376 KB
writeFile(t, repoDir, "README.md", large)
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.GetCumulativeDiff(context.Background(), base)
if err != nil {
t.Fatalf("GetCumulativeDiff error: %v", err)
}
if !result.Success {
t.Fatalf("GetCumulativeDiff failed: %+v", result)
}
diff, skipReason := fileEntryDiff(t, result.Files, "README.md")
if skipReason != diffSkipReasonTruncated {
t.Errorf("diff_skip_reason = %q, want %q", skipReason, diffSkipReasonTruncated)
}
if len(diff) > maxDiffOutputSize {
t.Errorf("truncated diff len = %d, want <= %d", len(diff), maxDiffOutputSize)
}
}
// TestGetCumulativeDiff_BudgetExceeded verifies that once the cumulative diff
// budget is crossed, later files get an empty diff flagged budget_exceeded.
func TestGetCumulativeDiff_BudgetExceeded(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
base := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
// 20 files of ~200 KB each (~4 MB total) so the 2 MB budget trips partway.
body := strings.Repeat("line of content for the budget test\n", 5700) // ~200 KB
for i := 0; i < 20; i++ {
writeFile(t, repoDir, fmt.Sprintf("file_%02d.txt", i), body)
}
runGit(t, repoDir, "add", ".")
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.GetCumulativeDiff(context.Background(), base)
if err != nil {
t.Fatalf("GetCumulativeDiff error: %v", err)
}
if !result.Success {
t.Fatalf("GetCumulativeDiff failed: %+v", result)
}
budgetExceeded := 0
for path := range result.Files {
diff, skipReason := fileEntryDiff(t, result.Files, path)
if skipReason == diffSkipReasonBudgetExceeded {
budgetExceeded++
if diff != "" {
t.Errorf("budget_exceeded file %q has non-empty diff (len %d)", path, len(diff))
}
}
}
if budgetExceeded == 0 {
t.Errorf("expected at least one budget_exceeded file, got none across %d files", len(result.Files))
}
}
// TestGetCumulativeDiff_CapsFileCount verifies the file-count cap drops excess
// entries and reports the count.
func TestGetCumulativeDiff_CapsFileCount(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
base := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
const totalFiles = maxCumulativeDiffFiles + 100
for i := 0; i < totalFiles; i++ {
writeFile(t, repoDir, fmt.Sprintf("f%04d.txt", i), "small change\n")
}
runGit(t, repoDir, "add", ".")
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.GetCumulativeDiff(context.Background(), base)
if err != nil {
t.Fatalf("GetCumulativeDiff error: %v", err)
}
if !result.Success {
t.Fatalf("GetCumulativeDiff failed: %+v", result)
}
if len(result.Files) != maxCumulativeDiffFiles {
t.Errorf("len(Files) = %d, want %d", len(result.Files), maxCumulativeDiffFiles)
}
if result.TruncatedFilesCount != totalFiles-maxCumulativeDiffFiles {
t.Errorf("TruncatedFilesCount = %d, want %d", result.TruncatedFilesCount, totalFiles-maxCumulativeDiffFiles)
}
}
// TestShowCommit_NotCapped is a regression test: ShowCommit must return the full
// diff for a large file (no per-file truncation), unlike GetCumulativeDiff.
func TestShowCommit_NotCapped(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
log := newTestLogger(t)
large := strings.Repeat("an additional line of content for the big diff\n", 8000) // ~376 KB
writeFile(t, repoDir, "big.txt", large)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "add big file")
sha := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
gitOp := NewGitOperator(repoDir, log, nil)
result, err := gitOp.ShowCommit(context.Background(), sha)
if err != nil {
t.Fatalf("ShowCommit error: %v", err)
}
if !result.Success {
t.Fatalf("ShowCommit failed: %+v", result)
}
diff, skipReason := fileEntryDiff(t, result.Files, "big.txt")
if skipReason != "" {
t.Errorf("ShowCommit diff_skip_reason = %q, want empty (uncapped)", skipReason)
}
if len(diff) <= maxDiffOutputSize {
t.Errorf("ShowCommit diff len = %d, want > %d (full, uncapped)", len(diff), maxDiffOutputSize)
}
}