Add tests for git.go

Adds test coverage for all currently testable functions in
tools/src/git/git.go. Functions that are not currently testable due to
using exec have TODOs added to track adding test coverage.

Bug: 344014313
Change-Id: I6d64237e85d77a756e2deed5b843b86496c965b5
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/273395
Auto-Submit: Brian Sheedy <bsheedy@google.com>
Reviewed-by: Ryan Harrison <rharrison@chromium.org>
Commit-Queue: Ryan Harrison <rharrison@chromium.org>
diff --git a/tools/src/git/git.go b/tools/src/git/git.go
index a620b61..5e203a4 100644
--- a/tools/src/git/git.go
+++ b/tools/src/git/git.go
@@ -78,6 +78,8 @@
 	LogAllActions bool
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // New returns a new Git instance
 func New(exe string, osWrapper oswrapper.OSWrapper) (*Git, error) {
 	if exe == "" {
@@ -140,6 +142,8 @@
 	Credentials Credentials
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Clone performs a clone of the repository at url to path.
 func (g Git) Clone(path, urlStr string, opt *CloneOptions) (*Repository, error) {
 	if err := g.os.MkdirAll(path, 0777); err != nil {
@@ -181,6 +185,8 @@
 	Credentials Credentials
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Fetch performs a fetch of a reference from the remote, returning the Hash of
 // the fetched reference.
 func (r Repository) Fetch(ref string, opt *FetchOptions) (Hash, error) {
@@ -210,6 +216,8 @@
 	Credentials Credentials
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Push performs a push of the local reference to the remote reference.
 func (r Repository) Push(localRef, remoteRef string, opt *PushOptions) error {
 	if opt == nil {
@@ -240,6 +248,8 @@
 	Credentials Credentials
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Add stages the listed files
 func (r Repository) Add(path string, opt *AddOptions) error {
 	if opt == nil {
@@ -263,6 +273,8 @@
 	Amend bool
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Commit commits the staged files with the given message, returning the hash of
 // commit
 func (r Repository) Commit(msg string, opt *CommitOptions) (Hash, error) {
@@ -302,6 +314,8 @@
 	Timeout time.Duration
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Clean deletes all the untracked files in the repo
 func (r Repository) Clean(opt *CleanOptions) error {
 	if opt == nil {
@@ -319,6 +333,8 @@
 	Timeout time.Duration
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Checkout performs a checkout of a reference.
 func (r Repository) Checkout(ref string, opt *CheckoutOptions) error {
 	if opt == nil {
@@ -342,6 +358,8 @@
 
 const logPrettyFormatArg = "--pretty=format:ǁ%Hǀ%cIǀ%an <%ae>ǀ%sǀ%b"
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Log returns the list of commits between two references (inclusive).
 // The first returned commit is the most recent.
 func (r Repository) Log(opt *LogOptions) ([]CommitInfo, error) {
@@ -370,6 +388,8 @@
 	Timeout time.Duration
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // LogBetween returns the list of commits between two timestamps
 // The first returned commit is the most recent.
 func (r Repository) LogBetween(since, until time.Time, opt *LogBetweenOptions) ([]CommitInfo, error) {
@@ -403,6 +423,8 @@
 	Timeout time.Duration
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // StatsOptions returns the statistics for a given change
 func (r Repository) Stats(commit CommitInfo, opt *StatsOptions) (CommitStats, error) {
 	if opt == nil {
@@ -458,6 +480,8 @@
 	Timeout time.Duration
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 // Config returns the git configuration values for the repo
 func (r Repository) Config(opt *ConfigOptions) (map[string]string, error) {
 	if opt == nil {
@@ -483,6 +507,8 @@
 	return r.Git.run(r.Path, env, timeout, args...)
 }
 
+// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
+// dependency injection.
 func (g Git) run(dir string, env []string, timeout time.Duration, args ...string) (string, error) {
 	if timeout == 0 {
 		timeout = DefaultTimeout
diff --git a/tools/src/git/git_test.go b/tools/src/git/git_test.go
new file mode 100644
index 0000000..75ab748
--- /dev/null
+++ b/tools/src/git/git_test.go
@@ -0,0 +1,179 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+package git
+
+import (
+	"io/fs"
+	"path/filepath"
+	"testing"
+	"testing/fstest"
+	"time"
+
+	"dawn.googlesource.com/dawn/tools/src/oswrapper"
+	"github.com/stretchr/testify/require"
+)
+
+func TestHashString(t *testing.T) {
+	hash := Hash{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67}
+	got := hash.String()
+	want := "0123456789abcdef0123456789abcdef01234567"
+	require.Equal(t, want, got)
+}
+
+func TestHashIsZero(t *testing.T) {
+	require.True(t, (Hash{}).IsZero())
+	require.False(t, (Hash{1}).IsZero())
+}
+
+func TestParseHash(t *testing.T) {
+	t.Run("valid hash", func(t *testing.T) {
+		got, err := ParseHash("0123456789abcdef0123456789abcdef01234567")
+		require.NoError(t, err)
+		want := Hash{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67}
+		require.Equal(t, want, got)
+	})
+
+	t.Run("invalid hash string", func(t *testing.T) {
+		_, err := ParseHash("invalidhash")
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "failed to parse hash 'invalidhash'")
+	})
+}
+
+func TestCredentialsEmpty(t *testing.T) {
+	require.True(t, (Credentials{}).Empty())
+	require.False(t, (Credentials{Username: "a"}).Empty())
+	require.False(t, (Credentials{Password: "b"}).Empty())
+}
+
+func TestCredentialsAddToURL(t *testing.T) {
+	t.Run("valid url", func(t *testing.T) {
+		c := Credentials{"user", "pass"}
+		got, err := c.addToURL("https://example.com")
+		require.NoError(t, err)
+		want := "https://user:pass@example.com"
+		require.Equal(t, want, got)
+	})
+
+	t.Run("invalid url", func(t *testing.T) {
+		c := Credentials{"user", "pass"}
+		_, err := c.addToURL("://invalid")
+		require.Error(t, err)
+	})
+}
+
+func TestGitOpen(t *testing.T) {
+	// Test opening a valid repository
+	t.Run("valid repository", func(t *testing.T) {
+		os := oswrapper.CreateFSTestOSWrapper()
+		gitDir := filepath.Join("my-repo", ".git")
+		os.FS[".git"] = &fstest.MapFile{Mode: fs.ModeDir}
+		os.FS["my-repo"] = &fstest.MapFile{Mode: fs.ModeDir}
+		os.FS[filepath.ToSlash(gitDir)] = &fstest.MapFile{Mode: fs.ModeDir}
+
+		g := Git{os: os}
+		repo, err := g.Open("my-repo")
+		require.NoError(t, err)
+		require.Equal(t, "my-repo", repo.Path)
+	})
+
+	// Test opening a non-existent repository
+	t.Run("non-existent repository", func(t *testing.T) {
+		os := oswrapper.CreateFSTestOSWrapper()
+		g := Git{os: os}
+		_, err := g.Open("non-existent-repo")
+		require.Equal(t, ErrRepositoryDoesNotExist, err)
+	})
+
+	// Test opening a path that is a file
+	t.Run("path is a file", func(t *testing.T) {
+		os := oswrapper.CreateFSTestOSWrapper()
+		os.FS[".git"] = &fstest.MapFile{Data: []byte("not a directory")}
+		g := Git{os: os}
+		_, err := g.Open(".")
+		require.Equal(t, ErrRepositoryDoesNotExist, err)
+	})
+}
+
+func TestParseLog(t *testing.T) {
+	t.Run("valid log", func(t *testing.T) {
+		const log = `ǁde0122585373851323c2115b2231165334f26e41ǀ2022-07-22T15:42:05-07:00ǀTest User <test.user@example.com>ǀSubject 1ǀDescription 1
+ǁaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaǀ2022-07-21T15:42:05-07:00ǀTest User <test.user@example.com>ǀSubject 2ǀDescription 2
+`
+		got, err := parseLog(log)
+		require.NoError(t, err)
+		want := []CommitInfo{
+			{
+				Hash:        mustParseHash("de0122585373851323c2115b2231165334f26e41"),
+				Date:        time.Date(2022, 07, 22, 15, 42, 05, 0, time.FixedZone("", -7*60*60)),
+				Author:      "Test User <test.user@example.com>",
+				Subject:     "Subject 1",
+				Description: "Description 1",
+			},
+			{
+				Hash:        mustParseHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
+				Date:        time.Date(2022, 07, 21, 15, 42, 05, 0, time.FixedZone("", -7*60*60)),
+				Author:      "Test User <test.user@example.com>",
+				Subject:     "Subject 2",
+				Description: "Description 2",
+			},
+		}
+
+		// Compare fields individually, especially time.Time using UnixNano for robustness
+		require.Len(t, got, len(want))
+		for i := range got {
+			require.Equal(t, want[i].Hash, got[i].Hash)
+			require.Equal(t, want[i].Date.UnixNano(), got[i].Date.UnixNano())
+			require.Equal(t, want[i].Author, got[i].Author)
+			require.Equal(t, want[i].Subject, got[i].Subject)
+			require.Equal(t, want[i].Description, got[i].Description)
+		}
+	})
+
+	t.Run("invalid hash in log", func(t *testing.T) {
+		const log = `ǁinvalidhashstringǀ2022-07-22T15:42:05-07:00ǀTest User <test.user@example.com>ǀSubject 1ǀDescription 1`
+		_, err := parseLog(log)
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "failed to parse hash 'invalidhashstring'")
+	})
+
+	t.Run("invalid date in log", func(t *testing.T) {
+		const log = `ǁde0122585373851323c2115b2231165334f26e41ǀinvalid-dateǀTest User <test.user@example.com>ǀSubject 1ǀDescription 1`
+		_, err := parseLog(log)
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "cannot parse \"invalid-date\"")
+	})
+}
+
+func mustParseHash(s string) Hash {
+	h, err := ParseHash(s)
+	if err != nil {
+		panic(err)
+	}
+	return h
+}