package git import ( "archive/zip" "bytes" "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" git "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" "github.com/google/uuid" ) // RepoPath() resolves lib.GitReposDir against the process CWD, so point the // whole test binary at a throwaway directory to keep repos off the source tree. func TestMain(m *testing.M) { origWD, err := os.Getwd() if err != nil { panic(err) } tmp, err := os.MkdirTemp("", "farga-git-test-*") if err != nil { panic(err) } if err := os.Chdir(tmp); err != nil { panic(err) } code := m.Run() os.Chdir(origWD) os.RemoveAll(tmp) os.Exit(code) } func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } } // builds a bare repo at target with the given files. Two commits are made; the // second one only touches root.txt, so file-scoped history has one commit. func makeBareRepo(t *testing.T, target string, content map[string]string) { t.Helper() // RepoPath() returns a trailing slash; filepath.Dir() keeps the last // element when it ends with one, so normalize first. target = strings.TrimSuffix(target, "/") dir := filepath.Dir(target) work := filepath.Join(dir, "work") if err := os.MkdirAll(work, 0o755); err != nil { t.Fatal(err) } runGit(t, work, "init", "-b", "master") runGit(t, work, "config", "user.name", "Test Author") runGit(t, work, "config", "user.email", "test@example.com") for name, body := range content { full := filepath.Join(work, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatalf("mkdir %s: %v", full, err) } if err := os.WriteFile(full, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", full, err) } } runGit(t, work, "add", "-A") runGit(t, work, "commit", "-m", "first commit") if err := os.WriteFile(filepath.Join(work, "root.txt"), []byte("second\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, work, "add", "-A") runGit(t, work, "commit", "-m", "second commit") runGit(t, dir, "clone", "--bare", "work", filepath.Base(target)) } // a fixture with a README, a plain file, and one subdirectory. func newFixtureRepo(t *testing.T) (path string) { t.Helper() path = filepath.Join(t.TempDir(), "bare") makeBareRepo(t, path, map[string]string{ "README.md": "# Farga test\n", "root.txt": "root\n", "sub/hello.txt": "hello\n", }) return path } func openBare(t *testing.T, path string) *git.Repository { t.Helper() repo, err := git.PlainOpen(path) if err != nil { t.Fatalf("PlainOpen(%q): %v", path, err) } return repo } func headTree(t *testing.T, repo *git.Repository) *object.Tree { t.Helper() head, err := repo.Head() if err != nil { t.Fatalf("Head(): %v", err) } commit, err := object.GetCommit(repo.Storer, head.Hash()) if err != nil { t.Fatalf("GetCommit(%s): %v", head.Hash(), err) } tree, err := commit.Tree() if err != nil { t.Fatalf("Tree(): %v", err) } return tree } func TestResolveHead(t *testing.T) { path := newFixtureRepo(t) repo := openBare(t, path) // autoselect HEAD ref, err := ResolveHead(repo, "") if err != nil { t.Fatalf("ResolveHead(repo, \"\"): %v", err) } if ref.Name() != "refs/heads/master" { t.Errorf("autoselected branch = %s, want refs/heads/master", ref.Name()) } // explicit branch ref2, err := ResolveHead(repo, "master") if err != nil { t.Fatalf("ResolveHead(repo, \"master\"): %v", err) } if ref2.Hash() != ref.Hash() { t.Errorf("branch master resolved to %s, want %s", ref2.Hash(), ref.Hash()) } // nonexistent branch if _, err := ResolveHead(repo, "nope"); !errors.Is(err, ErrInvalidBranch) { t.Errorf("ResolveHead(repo, \"nope\") = %v, want ErrInvalidBranch", err) } } func TestResolveHeadEmptyRepo(t *testing.T) { path := filepath.Join(t.TempDir(), "empty") if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } runGit(t, path, "init", "--bare") repo := openBare(t, path) if _, err := ResolveHead(repo, ""); !errors.Is(err, ErrRepoHasNoHead) { t.Errorf("ResolveHead(empty repo) = %v, want ErrRepoHasNoHead", err) } } func TestInitRepo(t *testing.T) { userID := uuid.Must(uuid.NewV7()) repoID := uuid.Must(uuid.NewV7()) path := RepoPath(userID, repoID) repo, err := InitRepo(userID, repoID) if err != nil { t.Fatalf("InitRepo: %v", err) } defer repo.Close() if _, err := os.Stat(path); err != nil { t.Errorf("repo dir %s missing: %v", path, err) } if _, err := git.PlainOpen(path); err != nil { t.Errorf("PlainOpen(%s): %v", path, err) } } func TestRepo(t *testing.T) { userID := uuid.Must(uuid.NewV7()) repoID := uuid.Must(uuid.NewV7()) path := RepoPath(userID, repoID) makeBareRepo(t, path, map[string]string{ "README.md": "# Hi\n", "src/main.go": "package main\n", }) r, err := Repo(userID, repoID, "") if err != nil { t.Fatalf("Repo: %v", err) } if r.Head == nil { t.Fatal("Repo: Head is nil") } if len(r.ShortHash) != 8 { t.Errorf("Repo: ShortHash = %q, want 8 chars", r.ShortHash) } if r.LatestCommit == nil { t.Fatal("Repo: LatestCommit is nil") } if r.Readme.Name != "README.md" || !strings.Contains(r.Readme.Content, "# Hi") { t.Errorf("Repo: Readme = %+v, want README.md with # Hi", r.Readme) } if len(r.Files) != 0 { t.Errorf("Repo: Files should be unset, got %d", len(r.Files)) } // explicit branch resolves to the same commit r2, err := Repo(userID, repoID, "master") if err != nil { t.Fatalf("Repo(branch=master): %v", err) } if r2.Head.Hash() != r.Head.Hash() { t.Errorf("Repo(branch=master): hash %s, want %s", r2.Head.Hash(), r.Head.Hash()) } } func TestRepoDoesNotExist(t *testing.T) { userID := uuid.Must(uuid.NewV7()) repoID := uuid.Must(uuid.NewV7()) _, err := Repo(userID, repoID, "") if !errors.Is(err, ErrRepoDoesNotExist) { t.Errorf("Repo(missing) = %v, want ErrRepoDoesNotExist", err) } } func TestDeleteRepo(t *testing.T) { userID := uuid.Must(uuid.NewV7()) repoID := uuid.Must(uuid.NewV7()) path := RepoPath(userID, repoID) makeBareRepo(t, path, map[string]string{"a.txt": "a\n"}) DeleteRepo(userID, repoID) if _, err := os.Stat(path); !os.IsNotExist(err) { t.Errorf("repo dir %s still exists after DeleteRepo", path) } } func TestReadme(t *testing.T) { repo := openBare(t, newFixtureRepo(t)) tree := headTree(t, repo) readme := Readme(tree) if readme.Name != "README.md" { t.Errorf("Readme().Name = %q, want README.md", readme.Name) } if !strings.Contains(readme.Content, "# Farga test") { t.Errorf("Readme().Content = %q, want it to contain # Farga test", readme.Content) } // a tree without readme files sub, err := tree.Tree("sub") if err != nil { t.Fatal(err) } if empty := Readme(sub); empty.Name != "" || empty.Content != "" { t.Errorf("Readme(no readme tree) = %+v, want empty", empty) } } func TestReadmePrecedence(t *testing.T) { path := filepath.Join(t.TempDir(), "bare") makeBareRepo(t, path, map[string]string{ "README": "plain\n", "README.md": "markdown\n", }) repo := openBare(t, path) readme := Readme(headTree(t, repo)) // first candidate in the priority list wins if readme.Name != "README" || readme.Content != "plain\n" { t.Errorf("Readme() = %+v, want README (first candidate wins)", readme) } } func TestDirFiles(t *testing.T) { repo := openBare(t, newFixtureRepo(t)) tree := headTree(t, repo) files, err := DirFiles(tree, "") if err != nil { t.Fatalf("DirFiles(tree, \"\"): %v", err) } // dirs are prepended, files appended. if len(files) != 3 { t.Fatalf("DirFiles root = %d entries, want 3", len(files)) } if files[0].Name != "sub" || !files[0].IsDir || files[0].Path != "sub" { t.Errorf("first entry = %+v, want dir sub", files[0]) } if files[1].Name != "README.md" || files[1].IsDir { t.Errorf("second entry = %+v, want file README.md", files[1]) } if files[1].Path != "README.md" { t.Errorf("README.md Path = %q, want README.md", files[1].Path) } if len(files[1].ShortHash) != 8 { t.Errorf("README.md ShortHash = %q, want 8 chars", files[1].ShortHash) } if files[1].Hash.IsZero() { t.Error("README.md Hash is zero") } // subdirectory listing sub, err := DirFiles(tree, "sub") if err != nil { t.Fatalf("DirFiles(tree, \"sub\"): %v", err) } if len(sub) != 1 || sub[0].Name != "hello.txt" || sub[0].Path != "sub/hello.txt" { t.Errorf("DirFiles(sub) = %+v, want [sub/hello.txt]", sub) } // nonexistent path if _, err := DirFiles(tree, "nope"); err == nil { t.Error("DirFiles(tree, \"nope\") = nil error, want error") } } func TestGetLog(t *testing.T) { path := newFixtureRepo(t) repo := openBare(t, path) log, err := GetLog(LogOptions{Repo: repo}) if err != nil { t.Fatalf("GetLog: %v", err) } if len(log) != 2 { t.Fatalf("GetLog returned %d commits, want 2", len(log)) } if strings.TrimSpace(log[0].Message) != "second commit" || strings.TrimSpace(log[1].Message) != "first commit" { t.Errorf("commit order = [%q, %q], want newest first", log[0].Message, log[1].Message) } if log[0].Author.Name != "Test Author" || log[0].Author.Email != "test@example.com" { t.Errorf("author = %+v, want Test Author ", log[0].Author) } if len(log[0].ShortHash) != 8 { t.Errorf("ShortHash = %q, want 8 chars", log[0].ShortHash) } if log[0].Time.IsZero() { t.Error("commit Time is zero") } if len(log[0].Hash) != 40 { t.Errorf("Hash = %q, want 40 chars", log[0].Hash) } // explicit branch log2, err := GetLog(LogOptions{Repo: repo, Branch: "master"}) if err != nil { t.Fatalf("GetLog(Branch=master): %v", err) } if len(log2) != 2 { t.Errorf("GetLog(Branch=master) returned %d commits, want 2", len(log2)) } } func TestGetLogErrors(t *testing.T) { path := newFixtureRepo(t) repo := openBare(t, path) if _, err := GetLog(LogOptions{}); err == nil { t.Error("GetLog(nil repo) = nil error, want error") } if _, err := GetLog(LogOptions{Repo: repo, Branch: "nope"}); !errors.Is(err, ErrInvalidBranch) { t.Errorf("GetLog(bad branch) = %v, want ErrInvalidBranch", err) } empty := filepath.Join(t.TempDir(), "empty") if err := os.MkdirAll(empty, 0o755); err != nil { t.Fatal(err) } runGit(t, empty, "init", "--bare") emptyRepo := openBare(t, empty) if _, err := GetLog(LogOptions{Repo: emptyRepo}); !errors.Is(err, ErrRepoHasNoHead) { t.Errorf("GetLog(empty repo) = %v, want ErrRepoHasNoHead", err) } } func TestShortenHash(t *testing.T) { hash := "0123456789abcdef0123456789abcdef01234567" short, err := ShortenHash(hash) if err != nil { t.Fatalf("ShortenHash: %v", err) } if short != hash[:8] { t.Errorf("ShortenHash = %q, want %q", short, hash[:8]) } // shorter than GitHashLength: returned in full full, err := ShortenHash("abc") if err != nil { t.Fatalf("ShortenHash(short): %v", err) } if full != "abc" { t.Errorf("ShortenHash(short) = %q, want %q", full, "abc") } } func TestFindFileEntry(t *testing.T) { repo := openBare(t, newFixtureRepo(t)) tree := headTree(t, repo) entry, err := FindFileEntry(tree, "README.md") if err != nil { t.Fatalf("FindFileEntry(README.md): %v", err) } if !entry.Mode.IsFile() { t.Errorf("README.md entry mode = %s, want file", entry.Mode) } // nested path if _, err := FindFileEntry(tree, "sub/hello.txt"); err != nil { t.Errorf("FindFileEntry(sub/hello.txt): %v", err) } // leading slash is trimmed if _, err := FindFileEntry(tree, "/README.md"); err != nil { t.Errorf("FindFileEntry(/README.md): %v", err) } if _, err := FindFileEntry(tree, ""); err == nil { t.Error("FindFileEntry(\"\") = nil error, want error") } if _, err := FindFileEntry(tree, "nope"); err == nil { t.Error("FindFileEntry(nope) = nil error, want error") } if _, err := FindFileEntry(tree, "sub"); err == nil { t.Error("FindFileEntry(sub) = nil error, want not-a-file error") } } func TestRefExists(t *testing.T) { path := newFixtureRepo(t) ctx := context.Background() repo := openBare(t, path) head, err := repo.Head() if err != nil { t.Fatal(err) } latest := head.Hash().String() commit, err := object.GetCommit(repo.Storer, head.Hash()) if err != nil { t.Fatal(err) } root := commit.ParentHashes[0].String() // the initial commit // any existing commit resolves, including the root commit if err := RefExists(ctx, path, latest); err != nil { t.Errorf("RefExists(latest commit): %v", err) } if err := RefExists(ctx, path, root); err != nil { t.Errorf("RefExists(root commit): %v", err) } if err := RefExists(ctx, path, "master"); err != nil { t.Errorf("RefExists(master): %v", err) } if err := RefExists(ctx, path, "nope"); err == nil { t.Error("RefExists(nope) = nil error, want error") } if err := RefExists(ctx, path, "evil ref"); !errors.Is(err, ErrInvalidRef) { t.Errorf("RefExists(evil ref) = %v, want ErrInvalidRef", err) } } func TestZipStream(t *testing.T) { path := newFixtureRepo(t) var buf bytes.Buffer if err := ZipStream(context.Background(), path, "master", &buf); err != nil { t.Fatalf("ZipStream: %v", err) } zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) if err != nil { t.Fatalf("archive is not a zip: %v", err) } var names []string for _, f := range zr.File { names = append(names, f.Name) } for _, want := range []string{"README.md", "root.txt", "sub/hello.txt"} { found := false for _, n := range names { if n == want { found = true break } } if !found { t.Errorf("zip is missing %s; entries: %v", want, names) } } if err := ZipStream(context.Background(), path, "evil ref", &buf); !errors.Is(err, ErrInvalidRef) { t.Errorf("ZipStream(bad ref) = %v, want ErrInvalidRef", err) } } func blobHash(t *testing.T, tree *object.Tree, name string) plumbing.Hash { t.Helper() entry, err := tree.FindEntry(name) if err != nil { t.Fatalf("FindEntry(%s): %v", name, err) } if !entry.Mode.IsFile() { t.Fatalf("%s is not a file", name) } return entry.Hash } func TestBlobSize(t *testing.T) { path := newFixtureRepo(t) tree := headTree(t, openBare(t, path)) size, err := BlobSize(path, blobHash(t, tree, "README.md")) if err != nil { t.Fatalf("BlobSize: %v", err) } if size != int64(len("# Farga test\n")) { t.Errorf("BlobSize = %d, want %d", size, len("# Farga test\n")) } } func TestStreamBlob(t *testing.T) { path := newFixtureRepo(t) tree := headTree(t, openBare(t, path)) var buf bytes.Buffer if err := StreamBlob(path, blobHash(t, tree, "sub/hello.txt"), &buf); err != nil { t.Fatalf("StreamBlob: %v", err) } if buf.String() != "hello\n" { t.Errorf("StreamBlob = %q, want %q", buf.String(), "hello\n") } } func TestReadBlobPrefix(t *testing.T) { path := newFixtureRepo(t) tree := headTree(t, openBare(t, path)) hash := blobHash(t, tree, "README.md") prefix, err := ReadBlobPrefix(path, hash, 7) if err != nil { t.Fatalf("ReadBlobPrefix: %v", err) } if string(prefix) != "# Farga" { t.Errorf("ReadBlobPrefix(7) = %q, want %q", prefix, "# Farga") } // n larger than the blob returns the whole blob whole, err := ReadBlobPrefix(path, hash, 1000) if err != nil { t.Fatalf("ReadBlobPrefix(1000): %v", err) } if string(whole) != "# Farga test\n" { t.Errorf("ReadBlobPrefix(1000) = %q, want whole blob", whole) } empty, err := ReadBlobPrefix(path, hash, 0) if err != nil { t.Fatalf("ReadBlobPrefix(0): %v", err) } if len(empty) != 0 { t.Errorf("ReadBlobPrefix(0) = %q, want empty", empty) } }