github.com/argoproj/argo-cd/v3@v3.2.1/util/git/client_test.go (about) 1 package git 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "net/mail" 9 "os" 10 "os/exec" 11 "path" 12 "path/filepath" 13 "regexp" 14 "strings" 15 "sync" 16 "testing" 17 "time" 18 19 log "github.com/sirupsen/logrus" 20 21 "github.com/go-git/go-git/v5/plumbing" 22 "github.com/go-git/go-git/v5/plumbing/transport" 23 githttp "github.com/go-git/go-git/v5/plumbing/transport/http" 24 "github.com/stretchr/testify/assert" 25 "github.com/stretchr/testify/require" 26 27 "github.com/argoproj/argo-cd/v3/util/workloadidentity" 28 "github.com/argoproj/argo-cd/v3/util/workloadidentity/mocks" 29 ) 30 31 func runCmd(workingDir string, name string, args ...string) error { 32 cmd := exec.Command(name, args...) 33 cmd.Dir = workingDir 34 cmd.Stdout = os.Stdout 35 cmd.Stderr = os.Stderr 36 return cmd.Run() 37 } 38 39 func outputCmd(workingDir string, name string, args ...string) ([]byte, error) { 40 cmd := exec.Command(name, args...) 41 cmd.Dir = workingDir 42 cmd.Stderr = os.Stderr 43 return cmd.Output() 44 } 45 46 func _createEmptyGitRepo() (string, error) { 47 tempDir, err := os.MkdirTemp("", "") 48 if err != nil { 49 return tempDir, err 50 } 51 52 err = runCmd(tempDir, "git", "init") 53 if err != nil { 54 return tempDir, err 55 } 56 57 err = runCmd(tempDir, "git", "commit", "-m", "Initial commit", "--allow-empty") 58 return tempDir, err 59 } 60 61 func Test_nativeGitClient_Fetch(t *testing.T) { 62 tempDir, err := _createEmptyGitRepo() 63 require.NoError(t, err) 64 65 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 66 require.NoError(t, err) 67 68 err = client.Init() 69 require.NoError(t, err) 70 71 err = client.Fetch("") 72 require.NoError(t, err) 73 } 74 75 func Test_nativeGitClient_Fetch_Prune(t *testing.T) { 76 tempDir, err := _createEmptyGitRepo() 77 require.NoError(t, err) 78 79 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 80 require.NoError(t, err) 81 82 err = client.Init() 83 require.NoError(t, err) 84 85 err = runCmd(tempDir, "git", "branch", "test/foo") 86 require.NoError(t, err) 87 88 err = client.Fetch("") 89 require.NoError(t, err) 90 91 err = runCmd(tempDir, "git", "branch", "-d", "test/foo") 92 require.NoError(t, err) 93 err = runCmd(tempDir, "git", "branch", "test/foo/bar") 94 require.NoError(t, err) 95 96 err = client.Fetch("") 97 require.NoError(t, err) 98 } 99 100 func Test_IsAnnotatedTag(t *testing.T) { 101 tempDir := t.TempDir() 102 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 103 require.NoError(t, err) 104 105 err = client.Init() 106 require.NoError(t, err) 107 108 p := path.Join(client.Root(), "README") 109 f, err := os.Create(p) 110 require.NoError(t, err) 111 _, err = f.WriteString("Hello.") 112 require.NoError(t, err) 113 err = f.Close() 114 require.NoError(t, err) 115 116 err = runCmd(client.Root(), "git", "add", "README") 117 require.NoError(t, err) 118 119 err = runCmd(client.Root(), "git", "commit", "-m", "Initial commit", "-a") 120 require.NoError(t, err) 121 122 atag := client.IsAnnotatedTag("master") 123 assert.False(t, atag) 124 125 err = runCmd(client.Root(), "git", "tag", "some-tag", "-a", "-m", "Create annotated tag") 126 require.NoError(t, err) 127 atag = client.IsAnnotatedTag("some-tag") 128 assert.True(t, atag) 129 130 // Tag effectually points to HEAD, so it's considered the same 131 atag = client.IsAnnotatedTag("HEAD") 132 assert.True(t, atag) 133 134 err = runCmd(client.Root(), "git", "rm", "README") 135 require.NoError(t, err) 136 err = runCmd(client.Root(), "git", "commit", "-m", "remove README", "-a") 137 require.NoError(t, err) 138 139 // We moved on, so tag doesn't point to HEAD anymore 140 atag = client.IsAnnotatedTag("HEAD") 141 assert.False(t, atag) 142 } 143 144 func Test_resolveTagReference(t *testing.T) { 145 // Setup 146 commitHash := plumbing.NewHash("0123456789abcdef0123456789abcdef01234567") 147 tagRef := plumbing.NewReferenceFromStrings("refs/tags/v1.0.0", "sometaghash") 148 149 // Test single function 150 resolvedRef := plumbing.NewHashReference(tagRef.Name(), commitHash) 151 152 // Verify 153 assert.Equal(t, commitHash, resolvedRef.Hash()) 154 assert.Equal(t, tagRef.Name(), resolvedRef.Name()) 155 } 156 157 func Test_ChangedFiles(t *testing.T) { 158 tempDir := t.TempDir() 159 160 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 161 require.NoError(t, err) 162 163 err = client.Init() 164 require.NoError(t, err) 165 166 err = runCmd(client.Root(), "git", "commit", "-m", "Initial commit", "--allow-empty") 167 require.NoError(t, err) 168 169 // Create a tag to have a second ref 170 err = runCmd(client.Root(), "git", "tag", "some-tag") 171 require.NoError(t, err) 172 173 p := path.Join(client.Root(), "README") 174 f, err := os.Create(p) 175 require.NoError(t, err) 176 _, err = f.WriteString("Hello.") 177 require.NoError(t, err) 178 err = f.Close() 179 require.NoError(t, err) 180 181 err = runCmd(client.Root(), "git", "add", "README") 182 require.NoError(t, err) 183 184 err = runCmd(client.Root(), "git", "commit", "-m", "Changes", "-a") 185 require.NoError(t, err) 186 187 previousSHA, err := client.LsRemote("some-tag") 188 require.NoError(t, err) 189 190 commitSHA, err := client.LsRemote("HEAD") 191 require.NoError(t, err) 192 193 // Invalid commits, error 194 _, err = client.ChangedFiles("0000000000000000000000000000000000000000", "1111111111111111111111111111111111111111") 195 require.Error(t, err) 196 197 // Not SHAs, error 198 _, err = client.ChangedFiles(previousSHA, "HEAD") 199 require.Error(t, err) 200 201 // Same commit, no changes 202 changedFiles, err := client.ChangedFiles(commitSHA, commitSHA) 203 require.NoError(t, err) 204 assert.ElementsMatch(t, []string{}, changedFiles) 205 206 // Different ref, with changes 207 changedFiles, err = client.ChangedFiles(previousSHA, commitSHA) 208 require.NoError(t, err) 209 assert.ElementsMatch(t, []string{"README"}, changedFiles) 210 } 211 212 func Test_SemverTags(t *testing.T) { 213 tempDir := t.TempDir() 214 215 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 216 require.NoError(t, err) 217 218 err = client.Init() 219 require.NoError(t, err) 220 221 mapTagRefs := map[string]string{} 222 for _, tag := range []string{ 223 "v1.0.0-rc1", 224 "v1.0.0-rc2", 225 "v1.0.0", 226 "v1.0", 227 "v1.0.1", 228 "v1.1.0", 229 "2024-apple", 230 "2024-banana", 231 } { 232 err = runCmd(client.Root(), "git", "commit", "-m", tag+" commit", "--allow-empty") 233 require.NoError(t, err) 234 235 // Create an rc semver tag 236 err = runCmd(client.Root(), "git", "tag", tag) 237 require.NoError(t, err) 238 239 sha, err := client.LsRemote("HEAD") 240 require.NoError(t, err) 241 242 mapTagRefs[tag] = sha 243 } 244 245 for _, tc := range []struct { 246 name string 247 ref string 248 expected string 249 error bool 250 }{{ 251 name: "pinned rc version", 252 ref: "v1.0.0-rc1", 253 expected: mapTagRefs["v1.0.0-rc1"], 254 }, { 255 name: "lt rc constraint", 256 ref: "< v1.0.0-rc3", 257 expected: mapTagRefs["v1.0.0-rc2"], 258 }, { 259 name: "pinned major version", 260 ref: "v1.0.0", 261 expected: mapTagRefs["v1.0.0"], 262 }, { 263 name: "pinned patch version", 264 ref: "v1.0.1", 265 expected: mapTagRefs["v1.0.1"], 266 }, { 267 name: "pinned minor version", 268 ref: "v1.1.0", 269 expected: mapTagRefs["v1.1.0"], 270 }, { 271 name: "patch wildcard constraint", 272 ref: "v1.0.*", 273 expected: mapTagRefs["v1.0.1"], 274 }, { 275 name: "patch tilde constraint", 276 ref: "~v1.0.0", 277 expected: mapTagRefs["v1.0.1"], 278 }, { 279 name: "minor wildcard constraint", 280 ref: "v1.*", 281 expected: mapTagRefs["v1.1.0"], 282 }, { 283 // The semver library allows for using both * and x as the wildcard modifier. 284 name: "alternative minor wildcard constraint", 285 ref: "v1.x", 286 expected: mapTagRefs["v1.1.0"], 287 }, { 288 name: "minor gte constraint", 289 ref: ">= v1.0.0", 290 expected: mapTagRefs["v1.1.0"], 291 }, { 292 name: "multiple constraints", 293 ref: "> v1.0.0 < v1.1.0", 294 expected: mapTagRefs["v1.0.1"], 295 }, { 296 // We treat non-specific semver versions as regular tags, rather than constraints. 297 name: "non-specific version", 298 ref: "v1.0", 299 expected: mapTagRefs["v1.0"], 300 }, { 301 // Which means a missing tag will raise an error. 302 name: "missing non-specific version", 303 ref: "v1.1", 304 error: true, 305 }, { 306 // This is NOT a semver constraint, so it should always resolve to itself - because specifying a tag should 307 // return the commit for that tag. 308 // semver/v3 has the unfortunate semver-ish behaviour where any tag starting with a number is considered to be 309 // "semver-ish", where that number is the semver major version, and the rest then gets coerced into a beta 310 // version string. This can cause unexpected behaviour with constraints logic. 311 // In this case, if the tag is being incorrectly coerced into semver (for being semver-ish), it will incorrectly 312 // return the commit for the 2024-banana tag; which we want to avoid. 313 name: "apple non-semver tag", 314 ref: "2024-apple", 315 expected: mapTagRefs["2024-apple"], 316 }, { 317 name: "banana non-semver tag", 318 ref: "2024-banana", 319 expected: mapTagRefs["2024-banana"], 320 }, { 321 // A semver version (without constraints) should ONLY match itself. 322 // We do not want "2024-apple" to get "semver-ish'ed" into matching "2024.0.0-apple"; they're different tags. 323 name: "no semver tag coercion", 324 ref: "2024.0.0-apple", 325 error: true, 326 }, { 327 // No minor versions are specified, so we would expect a major version of 2025 or more. 328 // This is because if we specify > 11 in semver, we would not expect 11.1.0 to pass; it should be 12.0.0 or more. 329 // Similarly, if we were to specify > 11.0, we would expect 11.1.0 or more. 330 name: "semver constraints on non-semver tags", 331 ref: "> 2024-apple", 332 error: true, 333 }, { 334 // However, if one specifies the minor/patch versions, semver constraints can be used to match non-semver tags. 335 // 2024-banana is considered as "2024.0.0-banana" in semver-ish, and banana > apple, so it's a match. 336 // Note: this is more for documentation and future reference than real testing, as it seems like quite odd behaviour. 337 name: "semver constraints on semver tags", 338 ref: "> 2024.0.0-apple", 339 expected: mapTagRefs["2024-banana"], 340 }} { 341 t.Run(tc.name, func(t *testing.T) { 342 commitSHA, err := client.LsRemote(tc.ref) 343 if tc.error { 344 require.Error(t, err) 345 return 346 } 347 require.NoError(t, err) 348 assert.True(t, IsCommitSHA(commitSHA)) 349 assert.Equal(t, tc.expected, commitSHA) 350 }) 351 } 352 } 353 354 func Test_nativeGitClient_Submodule(t *testing.T) { 355 tempDir, err := os.MkdirTemp("", "") 356 require.NoError(t, err) 357 358 foo := filepath.Join(tempDir, "foo") 359 err = os.Mkdir(foo, 0o755) 360 require.NoError(t, err) 361 362 err = runCmd(foo, "git", "init") 363 require.NoError(t, err) 364 365 bar := filepath.Join(tempDir, "bar") 366 err = os.Mkdir(bar, 0o755) 367 require.NoError(t, err) 368 369 err = runCmd(bar, "git", "init") 370 require.NoError(t, err) 371 372 err = runCmd(bar, "git", "commit", "-m", "Initial commit", "--allow-empty") 373 require.NoError(t, err) 374 375 // Embed repository bar into repository foo 376 t.Setenv("GIT_ALLOW_PROTOCOL", "file") 377 err = runCmd(foo, "git", "submodule", "add", bar) 378 require.NoError(t, err) 379 380 err = runCmd(foo, "git", "commit", "-m", "Initial commit") 381 require.NoError(t, err) 382 383 tempDir, err = os.MkdirTemp("", "") 384 require.NoError(t, err) 385 386 // Clone foo 387 err = runCmd(tempDir, "git", "clone", foo) 388 require.NoError(t, err) 389 390 client, err := NewClient("file://"+foo, NopCreds{}, true, false, "", "") 391 require.NoError(t, err) 392 393 err = client.Init() 394 require.NoError(t, err) 395 396 err = client.Fetch("") 397 require.NoError(t, err) 398 399 commitSHA, err := client.LsRemote("HEAD") 400 require.NoError(t, err) 401 402 // Call Checkout() with submoduleEnabled=false. 403 _, err = client.Checkout(commitSHA, false) 404 require.NoError(t, err) 405 406 // Check if submodule url does not exist in .git/config 407 err = runCmd(client.Root(), "git", "config", "submodule.bar.url") 408 require.Error(t, err) 409 410 // Call Submodule() via Checkout() with submoduleEnabled=true. 411 _, err = client.Checkout(commitSHA, true) 412 require.NoError(t, err) 413 414 // Check if the .gitmodule URL is reflected in .git/config 415 cmd := exec.Command("git", "config", "submodule.bar.url") 416 cmd.Dir = client.Root() 417 result, err := cmd.Output() 418 require.NoError(t, err) 419 assert.Equal(t, bar+"\n", string(result)) 420 421 // Change URL of submodule bar 422 err = runCmd(client.Root(), "git", "config", "--file=.gitmodules", "submodule.bar.url", bar+"baz") 423 require.NoError(t, err) 424 425 // Call Submodule() 426 err = client.Submodule() 427 require.NoError(t, err) 428 429 // Check if the URL change in .gitmodule is reflected in .git/config 430 cmd = exec.Command("git", "config", "submodule.bar.url") 431 cmd.Dir = client.Root() 432 result, err = cmd.Output() 433 require.NoError(t, err) 434 assert.Equal(t, bar+"baz\n", string(result)) 435 } 436 437 func TestNewClient_invalidSSHURL(t *testing.T) { 438 client, err := NewClient("ssh://bitbucket.org:org/repo", NopCreds{}, false, false, "", "") 439 assert.Nil(t, client) 440 assert.ErrorIs(t, err, ErrInvalidRepoURL) 441 } 442 443 func Test_IsRevisionPresent(t *testing.T) { 444 tempDir := t.TempDir() 445 446 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 447 require.NoError(t, err) 448 449 err = client.Init() 450 require.NoError(t, err) 451 452 p := path.Join(client.Root(), "README") 453 f, err := os.Create(p) 454 require.NoError(t, err) 455 _, err = f.WriteString("Hello.") 456 require.NoError(t, err) 457 err = f.Close() 458 require.NoError(t, err) 459 460 err = runCmd(client.Root(), "git", "add", "README") 461 require.NoError(t, err) 462 463 err = runCmd(client.Root(), "git", "commit", "-m", "Initial Commit", "-a") 464 require.NoError(t, err) 465 466 commitSHA, err := client.LsRemote("HEAD") 467 require.NoError(t, err) 468 469 // Ensure revision for HEAD is present locally. 470 revisionPresent := client.IsRevisionPresent(commitSHA) 471 assert.True(t, revisionPresent) 472 473 // Ensure invalid revision is not returned. 474 revisionPresent = client.IsRevisionPresent("invalid-revision") 475 assert.False(t, revisionPresent) 476 } 477 478 func Test_nativeGitClient_RevisionMetadata(t *testing.T) { 479 tempDir := t.TempDir() 480 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 481 require.NoError(t, err) 482 483 err = client.Init() 484 require.NoError(t, err) 485 486 p := path.Join(client.Root(), "README") 487 f, err := os.Create(p) 488 require.NoError(t, err) 489 _, err = f.WriteString("Hello.") 490 require.NoError(t, err) 491 err = f.Close() 492 require.NoError(t, err) 493 494 err = runCmd(client.Root(), "git", "config", "user.name", "FooBar ||| something\nelse") 495 require.NoError(t, err) 496 err = runCmd(client.Root(), "git", "config", "user.email", "foo@foo.com") 497 require.NoError(t, err) 498 499 err = runCmd(client.Root(), "git", "add", "README") 500 require.NoError(t, err) 501 now := time.Now() 502 err = runCmd(client.Root(), "git", "commit", "--date=\"Sat Jun 5 20:00:00 2021 +0000 UTC\"", "-m", `| Initial commit | 503 504 505 (╯°□°)╯︵ ┻━┻ 506 `, "-a", 507 "--trailer", "Argocd-reference-commit-author: test-author <test@email.com>", 508 "--trailer", "Argocd-reference-commit-date: "+now.Format(time.RFC3339), 509 "--trailer", "Argocd-reference-commit-subject: chore: make a change", 510 "--trailer", "Argocd-reference-commit-sha: abc123", 511 "--trailer", "Argocd-reference-commit-repourl: https://git.example.com/test/repo.git", 512 ) 513 require.NoError(t, err) 514 515 metadata, err := client.RevisionMetadata("HEAD") 516 require.NoError(t, err) 517 require.Equal(t, &RevisionMetadata{ 518 Author: `FooBar ||| somethingelse <foo@foo.com>`, 519 Date: time.Date(2021, time.June, 5, 20, 0, 0, 0, time.UTC).Local(), 520 Tags: []string{}, 521 Message: fmt.Sprintf(`| Initial commit | 522 523 (╯°□°)╯︵ ┻━┻ 524 525 Argocd-reference-commit-author: test-author <test@email.com> 526 Argocd-reference-commit-date: %s 527 Argocd-reference-commit-subject: chore: make a change 528 Argocd-reference-commit-sha: abc123 529 Argocd-reference-commit-repourl: https://git.example.com/test/repo.git`, now.Format(time.RFC3339)), 530 References: []RevisionReference{ 531 { 532 Commit: &CommitMetadata{ 533 Author: mail.Address{ 534 Name: "test-author", 535 Address: "test@email.com", 536 }, 537 Date: now.Format(time.RFC3339), 538 Subject: "chore: make a change", 539 SHA: "abc123", 540 RepoURL: "https://git.example.com/test/repo.git", 541 }, 542 }, 543 }, 544 }, metadata) 545 } 546 547 func Test_nativeGitClient_SetAuthor(t *testing.T) { 548 expectedName := "Tester" 549 expectedEmail := "test@example.com" 550 551 tempDir, err := _createEmptyGitRepo() 552 require.NoError(t, err) 553 554 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 555 require.NoError(t, err) 556 557 err = client.Init() 558 require.NoError(t, err) 559 560 out, err := client.SetAuthor(expectedName, expectedEmail) 561 require.NoError(t, err, "error output: ", out) 562 563 // Check git user.name 564 gitUserName, err := outputCmd(client.Root(), "git", "config", "--local", "user.name") 565 require.NoError(t, err) 566 actualName := strings.TrimSpace(string(gitUserName)) 567 require.Equal(t, expectedName, actualName) 568 569 // Check git user.email 570 gitUserEmail, err := outputCmd(client.Root(), "git", "config", "--local", "user.email") 571 require.NoError(t, err) 572 actualEmail := strings.TrimSpace(string(gitUserEmail)) 573 require.Equal(t, expectedEmail, actualEmail) 574 } 575 576 func Test_nativeGitClient_CheckoutOrOrphan(t *testing.T) { 577 t.Run("checkout to an existing branch", func(t *testing.T) { 578 // not main or master 579 expectedBranch := "feature" 580 581 tempDir, err := _createEmptyGitRepo() 582 require.NoError(t, err) 583 584 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 585 require.NoError(t, err) 586 587 err = client.Init() 588 require.NoError(t, err) 589 590 // set the author for the initial commit of the orphan branch 591 out, err := client.SetAuthor("test", "test@example.com") 592 require.NoError(t, err, "error output: %s", out) 593 594 // get base branch 595 gitCurrentBranch, err := outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 596 require.NoError(t, err) 597 baseBranch := strings.TrimSpace(string(gitCurrentBranch)) 598 599 // get base commit 600 gitCurrentCommitHash, err := outputCmd(tempDir, "git", "rev-parse", "HEAD") 601 require.NoError(t, err) 602 expectedCommitHash := strings.TrimSpace(string(gitCurrentCommitHash)) 603 604 // make expected branch 605 err = runCmd(tempDir, "git", "checkout", "-b", expectedBranch) 606 require.NoError(t, err) 607 608 // checkout to base branch, ready to test 609 err = runCmd(tempDir, "git", "checkout", baseBranch) 610 require.NoError(t, err) 611 612 out, err = client.CheckoutOrOrphan(expectedBranch, false) 613 require.NoError(t, err, "error output: ", out) 614 615 // get current branch, verify current branch 616 gitCurrentBranch, err = outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 617 require.NoError(t, err) 618 actualBranch := strings.TrimSpace(string(gitCurrentBranch)) 619 require.Equal(t, expectedBranch, actualBranch) 620 621 // get current commit hash, verify current commit hash 622 // equal -> not orphan 623 gitCurrentCommitHash, err = outputCmd(tempDir, "git", "rev-parse", "HEAD") 624 require.NoError(t, err) 625 actualCommitHash := strings.TrimSpace(string(gitCurrentCommitHash)) 626 require.Equal(t, expectedCommitHash, actualCommitHash) 627 }) 628 629 t.Run("orphan", func(t *testing.T) { 630 // not main or master 631 expectedBranch := "feature" 632 633 // make origin git repository 634 tempDir, err := _createEmptyGitRepo() 635 require.NoError(t, err) 636 originGitRepoURL := "file://" + tempDir 637 err = runCmd(tempDir, "git", "commit", "-m", "Second commit", "--allow-empty") 638 require.NoError(t, err) 639 640 // get base branch 641 gitCurrentBranch, err := outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 642 require.NoError(t, err) 643 baseBranch := strings.TrimSpace(string(gitCurrentBranch)) 644 645 // make test dir 646 tempDir, err = os.MkdirTemp("", "") 647 require.NoError(t, err) 648 649 client, err := NewClientExt(originGitRepoURL, tempDir, NopCreds{}, true, false, "", "") 650 require.NoError(t, err) 651 652 err = client.Init() 653 require.NoError(t, err) 654 655 // set the author for the initial commit of the orphan branch 656 out, err := client.SetAuthor("test", "test@example.com") 657 require.NoError(t, err, "error output: %s", out) 658 659 err = client.Fetch("") 660 require.NoError(t, err) 661 662 // checkout to origin base branch 663 err = runCmd(tempDir, "git", "checkout", baseBranch) 664 require.NoError(t, err) 665 666 // get base commit 667 gitCurrentCommitHash, err := outputCmd(tempDir, "git", "rev-parse", "HEAD") 668 require.NoError(t, err) 669 baseCommitHash := strings.TrimSpace(string(gitCurrentCommitHash)) 670 671 out, err = client.CheckoutOrOrphan(expectedBranch, false) 672 require.NoError(t, err, "error output: ", out) 673 674 // get current branch, verify current branch 675 gitCurrentBranch, err = outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 676 require.NoError(t, err) 677 actualBranch := strings.TrimSpace(string(gitCurrentBranch)) 678 require.Equal(t, expectedBranch, actualBranch) 679 680 // check orphan branch 681 682 // get current commit hash, verify current commit hash 683 // not equal -> orphan 684 gitCurrentCommitHash, err = outputCmd(tempDir, "git", "rev-parse", "HEAD") 685 require.NoError(t, err) 686 currentCommitHash := strings.TrimSpace(string(gitCurrentCommitHash)) 687 require.NotEqual(t, baseCommitHash, currentCommitHash) 688 689 // get commit count on current branch, verify 1 -> orphan 690 gitCommitCount, err := outputCmd(tempDir, "git", "rev-list", "--count", actualBranch) 691 require.NoError(t, err) 692 require.Equal(t, "1", strings.TrimSpace(string(gitCommitCount))) 693 }) 694 } 695 696 func Test_nativeGitClient_CheckoutOrNew(t *testing.T) { 697 t.Run("checkout to an existing branch", func(t *testing.T) { 698 // Example status 699 // * 57aef63 (feature) Second commit 700 // * a4fad22 (main) Initial commit 701 702 // Test scenario 703 // given : main branch (w/ Initial commit) 704 // when : try to check out [main -> feature] 705 // then : feature branch (w/ Second commit) 706 707 // not main or master 708 expectedBranch := "feature" 709 710 tempDir, err := _createEmptyGitRepo() 711 require.NoError(t, err) 712 713 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 714 require.NoError(t, err) 715 716 err = client.Init() 717 require.NoError(t, err) 718 719 out, err := client.SetAuthor("test", "test@example.com") 720 require.NoError(t, err, "error output: %s", out) 721 722 // get base branch 723 gitCurrentBranch, err := outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 724 require.NoError(t, err) 725 baseBranch := strings.TrimSpace(string(gitCurrentBranch)) 726 727 // make expected branch 728 err = runCmd(tempDir, "git", "checkout", "-b", expectedBranch) 729 require.NoError(t, err) 730 731 // make expected commit 732 err = runCmd(tempDir, "git", "commit", "-m", "Second commit", "--allow-empty") 733 require.NoError(t, err) 734 735 // get expected commit 736 expectedCommitHash, err := client.CommitSHA() 737 require.NoError(t, err) 738 739 // checkout to base branch, ready to test 740 err = runCmd(tempDir, "git", "checkout", baseBranch) 741 require.NoError(t, err) 742 743 out, err = client.CheckoutOrNew(expectedBranch, baseBranch, false) 744 require.NoError(t, err, "error output: ", out) 745 746 // get current branch, verify current branch 747 gitCurrentBranch, err = outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 748 require.NoError(t, err) 749 actualBranch := strings.TrimSpace(string(gitCurrentBranch)) 750 require.Equal(t, expectedBranch, actualBranch) 751 752 // get current commit hash, verify current commit hash 753 actualCommitHash, err := client.CommitSHA() 754 require.NoError(t, err) 755 require.Equal(t, expectedCommitHash, actualCommitHash) 756 }) 757 758 t.Run("new", func(t *testing.T) { 759 // Test scenario 760 // given : main branch (w/ Initial commit) 761 // * a4fad22 (main) Initial commit 762 // when : try to check out [main -> feature] 763 // then : feature branch (w/ Initial commit) 764 // * a4fad22 (feature, main) Initial commit 765 766 // not main or master 767 expectedBranch := "feature" 768 769 tempDir, err := _createEmptyGitRepo() 770 require.NoError(t, err) 771 772 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 773 require.NoError(t, err) 774 775 err = client.Init() 776 require.NoError(t, err) 777 778 out, err := client.SetAuthor("test", "test@example.com") 779 require.NoError(t, err, "error output: %s", out) 780 781 // get base branch 782 gitCurrentBranch, err := outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 783 require.NoError(t, err) 784 baseBranch := strings.TrimSpace(string(gitCurrentBranch)) 785 786 // get expected commit 787 expectedCommitHash, err := client.CommitSHA() 788 require.NoError(t, err) 789 790 out, err = client.CheckoutOrNew(expectedBranch, baseBranch, false) 791 require.NoError(t, err, "error output: ", out) 792 793 // get current branch, verify current branch 794 gitCurrentBranch, err = outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 795 require.NoError(t, err) 796 actualBranch := strings.TrimSpace(string(gitCurrentBranch)) 797 require.Equal(t, expectedBranch, actualBranch) 798 799 // get current commit hash, verify current commit hash 800 actualCommitHash, err := client.CommitSHA() 801 require.NoError(t, err) 802 require.Equal(t, expectedCommitHash, actualCommitHash) 803 }) 804 } 805 806 func Test_nativeGitClient_RemoveContents_SpecificPath(t *testing.T) { 807 // given 808 tempDir, err := _createEmptyGitRepo() 809 require.NoError(t, err) 810 811 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 812 require.NoError(t, err) 813 814 err = client.Init() 815 require.NoError(t, err) 816 817 _, err = client.SetAuthor("test", "test@example.com") 818 require.NoError(t, err) 819 820 err = runCmd(client.Root(), "touch", "README.md") 821 require.NoError(t, err) 822 823 err = runCmd(client.Root(), "mkdir", "scripts") 824 require.NoError(t, err) 825 err = runCmd(client.Root(), "touch", "scripts/startup.sh") 826 require.NoError(t, err) 827 828 err = runCmd(client.Root(), "git", "add", "--all") 829 require.NoError(t, err) 830 err = runCmd(client.Root(), "git", "commit", "-m", "Make files") 831 require.NoError(t, err) 832 833 // when: remove only "scripts" directory 834 _, err = client.RemoveContents([]string{"scripts"}) 835 require.NoError(t, err) 836 837 // then: "scripts" should be gone, "README.md" should still exist 838 _, err = os.Stat(filepath.Join(client.Root(), "README.md")) 839 require.NoError(t, err, "README.md should not be removed") 840 841 _, err = os.Stat(filepath.Join(client.Root(), "scripts")) 842 require.Error(t, err, "scripts directory should be removed") 843 844 // and: listing should only show README.md 845 ls, err := outputCmd(client.Root(), "ls") 846 require.NoError(t, err) 847 require.Equal(t, "README.md", strings.TrimSpace(string(ls))) 848 } 849 850 func Test_nativeGitClient_CommitAndPush(t *testing.T) { 851 tempDir, err := _createEmptyGitRepo() 852 require.NoError(t, err) 853 854 // config receive.denyCurrentBranch updateInstead 855 // because local git init make a non-bare repository which cannot be pushed normally 856 err = runCmd(tempDir, "git", "config", "--local", "receive.denyCurrentBranch", "updateInstead") 857 require.NoError(t, err) 858 859 // get branch 860 gitCurrentBranch, err := outputCmd(tempDir, "git", "rev-parse", "--abbrev-ref", "HEAD") 861 require.NoError(t, err) 862 branch := strings.TrimSpace(string(gitCurrentBranch)) 863 864 client, err := NewClient("file://"+tempDir, NopCreds{}, true, false, "", "") 865 require.NoError(t, err) 866 867 err = client.Init() 868 require.NoError(t, err) 869 870 out, err := client.SetAuthor("test", "test@example.com") 871 require.NoError(t, err, "error output: ", out) 872 873 err = client.Fetch(branch) 874 require.NoError(t, err) 875 876 out, err = client.Checkout(branch, false) 877 require.NoError(t, err, "error output: ", out) 878 879 // make a file then commit and push 880 err = runCmd(client.Root(), "touch", "README.md") 881 require.NoError(t, err) 882 883 out, err = client.CommitAndPush(branch, "docs: README") 884 require.NoError(t, err, "error output: %s", out) 885 886 // get current commit hash of the cloned repository 887 expectedCommitHash, err := client.CommitSHA() 888 require.NoError(t, err) 889 890 // get origin repository's current commit hash 891 gitCurrentCommitHash, err := outputCmd(tempDir, "git", "rev-parse", "HEAD") 892 require.NoError(t, err) 893 actualCommitHash := strings.TrimSpace(string(gitCurrentCommitHash)) 894 require.Equal(t, expectedCommitHash, actualCommitHash) 895 } 896 897 func Test_newAuth_AzureWorkloadIdentity(t *testing.T) { 898 tokenprovider := new(mocks.TokenProvider) 899 tokenprovider.On("GetToken", azureDevopsEntraResourceId).Return(&workloadidentity.Token{AccessToken: "accessToken"}, nil) 900 901 creds := AzureWorkloadIdentityCreds{store: NoopCredsStore{}, tokenProvider: tokenprovider} 902 903 auth, err := newAuth("", creds) 904 require.NoError(t, err) 905 _, ok := auth.(*githttp.TokenAuth) 906 require.Truef(t, ok, "expected TokenAuth but got %T", auth) 907 } 908 909 func TestNewAuth(t *testing.T) { 910 tests := []struct { 911 name string 912 repoURL string 913 creds Creds 914 expected transport.AuthMethod 915 wantErr bool 916 }{ 917 { 918 name: "HTTPSCreds with bearer token", 919 repoURL: "https://github.com/org/repo.git", 920 creds: HTTPSCreds{ 921 bearerToken: "test-token", 922 }, 923 expected: &githttp.TokenAuth{Token: "test-token"}, 924 wantErr: false, 925 }, 926 { 927 name: "HTTPSCreds with basic auth", 928 repoURL: "https://github.com/org/repo.git", 929 creds: HTTPSCreds{ 930 username: "test-user", 931 password: "test-password", 932 }, 933 expected: &githttp.BasicAuth{Username: "test-user", Password: "test-password"}, 934 wantErr: false, 935 }, 936 { 937 name: "HTTPSCreds with basic auth no username", 938 repoURL: "https://github.com/org/repo.git", 939 creds: HTTPSCreds{ 940 password: "test-password", 941 }, 942 expected: &githttp.BasicAuth{Username: "x-access-token", Password: "test-password"}, 943 wantErr: false, 944 }, 945 } 946 947 for _, tt := range tests { 948 t.Run(tt.name, func(t *testing.T) { 949 auth, err := newAuth(tt.repoURL, tt.creds) 950 if (err != nil) != tt.wantErr { 951 t.Errorf("newAuth() error = %v, wantErr %v", err, tt.wantErr) 952 return 953 } 954 assert.Equal(t, tt.expected, auth) 955 }) 956 } 957 } 958 959 func Test_nativeGitClient_runCredentialedCmd(t *testing.T) { 960 tests := []struct { 961 name string 962 creds Creds 963 environ []string 964 expectedArgs []string 965 expectedEnv []string 966 expectedErr bool 967 }{ 968 { 969 name: "basic auth header set", 970 creds: &mockCreds{ 971 environ: []string{forceBasicAuthHeaderEnv + "=Basic dGVzdDp0ZXN0"}, 972 }, 973 expectedArgs: []string{"--config-env", "http.extraHeader=" + forceBasicAuthHeaderEnv, "status"}, 974 expectedEnv: []string{forceBasicAuthHeaderEnv + "=Basic dGVzdDp0ZXN0"}, 975 expectedErr: false, 976 }, 977 { 978 name: "bearer auth header set", 979 creds: &mockCreds{ 980 environ: []string{bearerAuthHeaderEnv + "=Bearer test-token"}, 981 }, 982 expectedArgs: []string{"--config-env", "http.extraHeader=" + bearerAuthHeaderEnv, "status"}, 983 expectedEnv: []string{bearerAuthHeaderEnv + "=Bearer test-token"}, 984 expectedErr: false, 985 }, 986 { 987 name: "no auth header set", 988 creds: &mockCreds{ 989 environ: []string{}, 990 }, 991 expectedArgs: []string{"status"}, 992 expectedEnv: []string{}, 993 expectedErr: false, 994 }, 995 { 996 name: "error getting environment", 997 creds: &mockCreds{ 998 environErr: true, 999 }, 1000 expectedArgs: []string{}, 1001 expectedEnv: []string{}, 1002 expectedErr: true, 1003 }, 1004 } 1005 1006 for _, tt := range tests { 1007 t.Run(tt.name, func(t *testing.T) { 1008 client := &nativeGitClient{ 1009 creds: tt.creds, 1010 } 1011 1012 err := client.runCredentialedCmd("status") 1013 if (err != nil) != tt.expectedErr { 1014 t.Errorf("runCredentialedCmd() error = %v, expectedErr %v", err, tt.expectedErr) 1015 return 1016 } 1017 1018 if tt.expectedErr { 1019 return 1020 } 1021 1022 cmd := exec.Command("git", tt.expectedArgs...) 1023 cmd.Env = append(os.Environ(), tt.expectedEnv...) 1024 output, err := cmd.CombinedOutput() 1025 if err != nil { 1026 t.Errorf("runCredentialedCmd() command error = %v, output = %s", err, output) 1027 } 1028 }) 1029 } 1030 } 1031 1032 func Test_LsFiles_RaceCondition(t *testing.T) { 1033 // Create two temporary directories and initialize them as git repositories 1034 tempDir1 := t.TempDir() 1035 tempDir2 := t.TempDir() 1036 1037 client1, err := NewClient("file://"+tempDir1, NopCreds{}, true, false, "", "") 1038 require.NoError(t, err) 1039 client2, err := NewClient("file://"+tempDir2, NopCreds{}, true, false, "", "") 1040 require.NoError(t, err) 1041 1042 err = client1.Init() 1043 require.NoError(t, err) 1044 err = client2.Init() 1045 require.NoError(t, err) 1046 1047 // Add different files to each repository 1048 file1 := filepath.Join(client1.Root(), "file1.txt") 1049 err = os.WriteFile(file1, []byte("content1"), 0o644) 1050 require.NoError(t, err) 1051 err = runCmd(client1.Root(), "git", "add", "file1.txt") 1052 require.NoError(t, err) 1053 err = runCmd(client1.Root(), "git", "commit", "-m", "Add file1") 1054 require.NoError(t, err) 1055 1056 file2 := filepath.Join(client2.Root(), "file2.txt") 1057 err = os.WriteFile(file2, []byte("content2"), 0o644) 1058 require.NoError(t, err) 1059 err = runCmd(client2.Root(), "git", "add", "file2.txt") 1060 require.NoError(t, err) 1061 err = runCmd(client2.Root(), "git", "commit", "-m", "Add file2") 1062 require.NoError(t, err) 1063 1064 // Assert that LsFiles returns the correct files when called sequentially 1065 files1, err := client1.LsFiles("*", true) 1066 require.NoError(t, err) 1067 require.Contains(t, files1, "file1.txt") 1068 1069 files2, err := client2.LsFiles("*", true) 1070 require.NoError(t, err) 1071 require.Contains(t, files2, "file2.txt") 1072 1073 // Define a function to call LsFiles multiple times in parallel 1074 var wg sync.WaitGroup 1075 callLsFiles := func(client Client, expectedFile string) { 1076 defer wg.Done() 1077 for i := 0; i < 100; i++ { 1078 files, err := client.LsFiles("*", true) 1079 require.NoError(t, err) 1080 require.Contains(t, files, expectedFile) 1081 } 1082 } 1083 1084 // Call LsFiles in parallel for both clients 1085 wg.Add(2) 1086 go callLsFiles(client1, "file1.txt") 1087 go callLsFiles(client2, "file2.txt") 1088 wg.Wait() 1089 } 1090 1091 type mockCreds struct { 1092 environ []string 1093 environErr bool 1094 } 1095 1096 func (m *mockCreds) Environ() (io.Closer, []string, error) { 1097 if m.environErr { 1098 return nil, nil, errors.New("error getting environment") 1099 } 1100 return io.NopCloser(nil), m.environ, nil 1101 } 1102 1103 func (m *mockCreds) GetUserInfo(_ context.Context) (string, string, error) { 1104 return "", "", nil 1105 } 1106 1107 func Test_GetReferences(t *testing.T) { 1108 t.Parallel() 1109 1110 now := time.Now() 1111 1112 tests := []struct { 1113 name string 1114 input string 1115 expectedReferences []RevisionReference 1116 expectedMessage string 1117 }{ 1118 { 1119 name: "No trailers", 1120 input: "This is a commit message without trailers.", 1121 expectedReferences: nil, 1122 expectedMessage: "This is a commit message without trailers.\n", 1123 }, 1124 { 1125 name: "Invalid trailers", 1126 input: `Argocd-reference-commit-repourl: % invalid % 1127 Argocd-reference-commit-date: invalid-date 1128 Argocd-reference-commit-sha: xyz123 1129 Argocd-reference-commit-body: this isn't json 1130 Argocd-reference-commit-author: % not email % 1131 Argocd-reference-commit-bogus:`, 1132 expectedReferences: nil, 1133 expectedMessage: `Argocd-reference-commit-repourl: % invalid % 1134 Argocd-reference-commit-date: invalid-date 1135 Argocd-reference-commit-sha: xyz123 1136 Argocd-reference-commit-body: this isn't json 1137 Argocd-reference-commit-author: % not email % 1138 Argocd-reference-commit-bogus: 1139 `, 1140 }, 1141 { 1142 name: "Unknown trailers", 1143 input: "Argocd-reference-commit-unknown: foobar", 1144 expectedReferences: nil, 1145 expectedMessage: "Argocd-reference-commit-unknown: foobar\n", 1146 }, 1147 { 1148 name: "Some valid and Invalid trailers", 1149 input: `Argocd-reference-commit-sha: abc123 1150 Argocd-reference-commit-repourl: % invalid % 1151 Argocd-reference-commit-date: invalid-date`, 1152 expectedReferences: []RevisionReference{ 1153 { 1154 Commit: &CommitMetadata{ 1155 SHA: "abc123", 1156 }, 1157 }, 1158 }, 1159 expectedMessage: `Argocd-reference-commit-repourl: % invalid % 1160 Argocd-reference-commit-date: invalid-date 1161 `, 1162 }, 1163 { 1164 name: "Valid trailers", 1165 input: fmt.Sprintf(`Argocd-reference-commit-repourl: https://github.com/org/repo.git 1166 Argocd-reference-commit-author: John Doe <john.doe@example.com> 1167 Argocd-reference-commit-date: %s 1168 Argocd-reference-commit-subject: Fix bug 1169 Argocd-reference-commit-body: "Fix bug\n\nSome: trailer" 1170 Argocd-reference-commit-sha: abc123`, now.Format(time.RFC3339)), 1171 expectedReferences: []RevisionReference{ 1172 { 1173 Commit: &CommitMetadata{ 1174 Author: mail.Address{ 1175 Name: "John Doe", 1176 Address: "john.doe@example.com", 1177 }, 1178 Date: now.Format(time.RFC3339), 1179 Body: "Fix bug\n\nSome: trailer", 1180 Subject: "Fix bug", 1181 SHA: "abc123", 1182 RepoURL: "https://github.com/org/repo.git", 1183 }, 1184 }, 1185 }, 1186 expectedMessage: "", 1187 }, 1188 { 1189 name: "Duplicate trailers", 1190 input: `Argocd-reference-commit-repourl: https://github.com/org/repo.git 1191 Argocd-reference-commit-repourl: https://github.com/another/repo.git`, 1192 expectedReferences: []RevisionReference{ 1193 { 1194 Commit: &CommitMetadata{ 1195 RepoURL: "https://github.com/another/repo.git", 1196 }, 1197 }, 1198 }, 1199 expectedMessage: "", 1200 }, 1201 } 1202 1203 for _, tt := range tests { 1204 t.Run(tt.name, func(t *testing.T) { 1205 t.Parallel() 1206 1207 logCtx := log.WithFields(log.Fields{}) 1208 result, message := GetReferences(logCtx, tt.input) 1209 assert.Equal(t, tt.expectedReferences, result) 1210 assert.Equal(t, tt.expectedMessage, message) 1211 }) 1212 } 1213 } 1214 1215 func Test_BuiltinConfig(t *testing.T) { 1216 tempDir := t.TempDir() 1217 for _, enabled := range []bool{false, true} { 1218 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "", WithBuiltinGitConfig(enabled)) 1219 require.NoError(t, err) 1220 native := client.(*nativeGitClient) 1221 1222 configOut, err := native.config("--list", "--show-origin") 1223 require.NoError(t, err) 1224 for k, v := range builtinGitConfig { 1225 r := regexp.MustCompile(fmt.Sprintf("(?m)^command line:\\s+%s=%s$", strings.ToLower(k), regexp.QuoteMeta(v))) 1226 matches := r.FindString(configOut) 1227 if enabled { 1228 assert.NotEmpty(t, matches, "missing builtin configuration option: %s=%s", k, v) 1229 } else { 1230 assert.Empty(t, matches, "unexpected builtin configuration when builtin config is disabled: %s=%s", k, v) 1231 } 1232 } 1233 } 1234 } 1235 1236 func Test_GitNoDetachedMaintenance(t *testing.T) { 1237 tempDir := t.TempDir() 1238 ctx := t.Context() 1239 1240 client, err := NewClientExt("file://"+tempDir, tempDir, NopCreds{}, true, false, "", "") 1241 require.NoError(t, err) 1242 native := client.(*nativeGitClient) 1243 1244 err = client.Init() 1245 require.NoError(t, err) 1246 1247 cmd := exec.CommandContext(ctx, "git", "fetch") 1248 // trace execution of Git subcommands and their arguments to stderr 1249 cmd.Env = append(cmd.Env, "GIT_TRACE=true") 1250 // Ignore system config in case it disables auto maintenance 1251 cmd.Env = append(cmd.Env, "GIT_CONFIG_NOSYSTEM=true") 1252 output, err := native.runCmdOutput(cmd, runOpts{CaptureStderr: true}) 1253 require.NoError(t, err) 1254 1255 lines := strings.Split(output, "\n") 1256 for _, line := range lines { 1257 if strings.Contains(line, "git maintenance run") { 1258 assert.NotContains(t, output, "--detach", "Unexpected --detach when running git maintenance") 1259 return 1260 } 1261 } 1262 assert.Fail(t, "Expected to see `git maintenance` run after `git fetch`") 1263 }