blob: 5fb7f197f629260e56f63f63e018e09e79b0a361 [file]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gitutil_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"go.fuchsia.dev/jiri/gitutil"
"go.fuchsia.dev/jiri/jiritest"
"go.fuchsia.dev/jiri/jiritest/xtest"
)
func TestMain(m *testing.M) {
jiritest.IsolateGitEnv()
os.Exit(m.Run())
}
func TestWorktree(t *testing.T) {
jirix := xtest.NewX(t)
// Create a temporary directory for the main repository.
repoDir := t.TempDir()
gMain := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := gMain.Init(repoDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Configure user for commits.
if err := gMain.Config("user.email", "test@example.com"); err != nil {
t.Fatalf("Config user.email failed: %v", err)
}
if err := gMain.Config("user.name", "Test User"); err != nil {
t.Fatalf("Config user.name failed: %v", err)
}
// Create an initial commit.
testFile := filepath.Join(repoDir, "commit_file")
if err := os.WriteFile(testFile, []byte("initial"), 0644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := gMain.Add(testFile); err != nil {
t.Fatalf("Add failed: %v", err)
}
if err := gMain.CommitWithMessage("initial commit"); err != nil {
t.Fatalf("Commit failed: %v", err)
}
// Create a branch to use for worktree.
if err := gMain.CreateBranch("feature"); err != nil {
t.Fatalf("CreateBranch failed: %v", err)
}
// Create a temporary directory for the worktree.
wtDir := filepath.Join(t.TempDir(), "wt")
// Add worktree.
if err := gMain.WorktreeAdd(wtDir, "feature"); err != nil {
t.Fatalf("WorktreeAdd failed: %v", err)
}
// Verify worktree exists.
if _, err := os.Stat(wtDir); os.IsNotExist(err) {
t.Fatalf("Worktree directory %s does not exist", wtDir)
}
if _, err := os.Stat(filepath.Join(wtDir, ".git")); os.IsNotExist(err) {
t.Fatalf("Worktree .git file does not exist")
}
// Verify we can run git commands in worktree.
gWt := gitutil.New(jirix, gitutil.RootDirOpt(wtDir))
branch, err := gWt.CurrentBranchName()
if err != nil {
t.Fatalf("CurrentBranchName failed: %v", err)
}
if branch != "feature" {
t.Errorf("Expected branch 'feature', got %q", branch)
}
// Remove worktree.
if err := gMain.WorktreeRemove(wtDir); err != nil {
t.Fatalf("WorktreeRemove failed: %v", err)
}
// Verify worktree directory is gone (or at least git doesn't track it).
if _, err := os.Stat(wtDir); !os.IsNotExist(err) {
t.Errorf("Worktree directory %s still exists after removal", wtDir)
}
}
func TestClean(t *testing.T) {
jirix := xtest.NewX(t)
repoDir := t.TempDir()
g := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := g.Init(repoDir); err != nil {
t.Fatal(err)
}
// Create a file and commit it.
file1 := filepath.Join(repoDir, "file1")
if err := os.WriteFile(file1, []byte("content1"), 0644); err != nil {
t.Fatal(err)
}
if err := g.Add(file1); err != nil {
t.Fatal(err)
}
// Untracked file.
file2 := filepath.Join(repoDir, "file2")
if err := os.WriteFile(file2, []byte("content2"), 0644); err != nil {
t.Fatal(err)
}
// Ignored file.
file3 := filepath.Join(repoDir, "file3")
if err := os.WriteFile(file3, []byte("content3"), 0644); err != nil {
t.Fatal(err)
}
gitignore := filepath.Join(repoDir, ".gitignore")
if err := os.WriteFile(gitignore, []byte("file3\n"), 0644); err != nil {
t.Fatal(err)
}
if err := g.Add(gitignore); err != nil {
t.Fatal(err)
}
if err := g.Config("user.email", "test@test.com"); err != nil {
t.Fatal(err)
}
if err := g.Config("user.name", "test"); err != nil {
t.Fatal(err)
}
if err := g.CommitWithMessage("add gitignore"); err != nil {
t.Fatal(err)
}
// Now Clean without ignored=true. file2 (untracked) should be deleted, file3 (ignored) should remain.
if err := g.Clean(nil, gitutil.ForceOpt(true), gitutil.DirsOpt(true)); err != nil {
t.Fatalf("Clean failed: %v", err)
}
if _, err := os.Stat(file2); !os.IsNotExist(err) {
t.Errorf("Expected file2 to be deleted")
}
if _, err := os.Stat(file3); os.IsNotExist(err) {
t.Errorf("Expected file3 to remain (ignored)")
}
// Recreate file2.
if err := os.WriteFile(file2, []byte("content2"), 0644); err != nil {
t.Fatal(err)
}
// Clean with ignored=true. Both file2 and file3 should be deleted.
if err := g.Clean(nil, gitutil.ForceOpt(true), gitutil.DirsOpt(true), gitutil.IgnoredOpt(true)); err != nil {
t.Fatalf("Clean failed: %v", err)
}
if _, err := os.Stat(file2); !os.IsNotExist(err) {
t.Errorf("Expected file2 to be deleted")
}
if _, err := os.Stat(file3); !os.IsNotExist(err) {
t.Errorf("Expected file3 to be deleted (ignored)")
}
// Recreate file2 and file3.
if err := os.WriteFile(file2, []byte("content2"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file3, []byte("content3"), 0644); err != nil {
t.Fatal(err)
}
// Clean with excludes. Exclude file2.
if err := g.Clean([]string{"file2"}, gitutil.ForceOpt(true), gitutil.DirsOpt(true), gitutil.IgnoredOpt(true)); err != nil {
t.Fatalf("Clean failed: %v", err)
}
if _, err := os.Stat(file2); os.IsNotExist(err) {
t.Errorf("Expected file2 to remain (excluded)")
}
if _, err := os.Stat(file3); !os.IsNotExist(err) {
t.Errorf("Expected file3 to be deleted")
}
}
func TestRefreshIndex(t *testing.T) {
jirix := xtest.NewX(t)
repoDir := t.TempDir()
g := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := g.Init(repoDir); err != nil {
t.Fatal(err)
}
// Configure user for commits.
if err := g.Config("user.email", "test@example.com"); err != nil {
t.Fatal(err)
}
if err := g.Config("user.name", "Test User"); err != nil {
t.Fatal(err)
}
// Create a file and commit it.
file1 := filepath.Join(repoDir, "file1")
if err := os.WriteFile(file1, []byte("content1"), 0644); err != nil {
t.Fatal(err)
}
if err := g.Add(file1); err != nil {
t.Fatal(err)
}
if err := g.CommitWithMessage("initial commit"); err != nil {
t.Fatal(err)
}
// Case 1: No changes. RefreshIndex should succeed.
if err := g.RefreshIndex(); err != nil {
t.Errorf("RefreshIndex failed when no changes: %v", err)
}
// Case 2: Modify file (dirty). RefreshIndex should still succeed (ignore exit status 1).
if err := os.WriteFile(file1, []byte("content1_modified"), 0644); err != nil {
t.Fatal(err)
}
if err := g.RefreshIndex(); err != nil {
t.Errorf("RefreshIndex failed when file is dirty: %v", err)
}
// Case 3: Run in non-git directory (should fail).
nonGitDir := t.TempDir()
gNonGit := gitutil.New(jirix, gitutil.RootDirOpt(nonGitDir))
if err := gNonGit.RefreshIndex(); err == nil {
t.Errorf("RefreshIndex expected to fail in non-git directory")
}
}
func TestCommonGitDir(t *testing.T) {
jirix := xtest.NewX(t)
repoDir := t.TempDir()
gMain := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := gMain.Init(repoDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Configure user for commits.
if err := gMain.Config("user.email", "test@example.com"); err != nil {
t.Fatalf("Config user.email failed: %v", err)
}
if err := gMain.Config("user.name", "Test User"); err != nil {
t.Fatalf("Config user.name failed: %v", err)
}
// Create an initial commit.
testFile := filepath.Join(repoDir, "commit_file")
if err := os.WriteFile(testFile, []byte("initial"), 0644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := gMain.Add(testFile); err != nil {
t.Fatalf("Add failed: %v", err)
}
if err := gMain.CommitWithMessage("initial commit"); err != nil {
t.Fatalf("Commit failed: %v", err)
}
// Create a branch.
if err := gMain.CreateBranch("feature"); err != nil {
t.Fatalf("CreateBranch failed: %v", err)
}
// Create a worktree.
wtDir := filepath.Join(t.TempDir(), "wt")
if err := gMain.WorktreeAdd(wtDir, "feature"); err != nil {
t.Fatalf("WorktreeAdd failed: %v", err)
}
defer func() {
_ = gMain.WorktreeRemove(wtDir, gitutil.ForceOpt(true))
}()
// Verify CommonGitDir.
expectedCommonDir, err := filepath.EvalSymlinks(filepath.Join(repoDir, ".git"))
if err != nil {
t.Fatalf("Failed to resolve main .git path: %v", err)
}
// Main repo common dir
mainCommonDir, err := gMain.CommonGitDir()
if err != nil {
t.Fatalf("Main repo CommonGitDir failed: %v", err)
}
mainCommonDirResolved, err := filepath.EvalSymlinks(mainCommonDir)
if err != nil {
t.Fatalf("Failed to resolve mainCommonDir: %v", err)
}
if mainCommonDirResolved != expectedCommonDir {
t.Errorf("Expected main repo common dir %q, got %q", expectedCommonDir, mainCommonDirResolved)
}
// Worktree common dir
gWt := gitutil.New(jirix, gitutil.RootDirOpt(wtDir))
wtCommonDir, err := gWt.CommonGitDir()
if err != nil {
t.Fatalf("Worktree CommonGitDir failed: %v", err)
}
wtCommonDirResolved, err := filepath.EvalSymlinks(wtCommonDir)
if err != nil {
t.Fatalf("Failed to resolve wtCommonDir: %v", err)
}
if wtCommonDirResolved != expectedCommonDir {
t.Errorf("Expected worktree common dir %q, got %q", expectedCommonDir, wtCommonDirResolved)
}
// Worktree absolute git dir (should be different from common dir)
wtGitDir, err := gWt.AbsoluteGitDir()
if err != nil {
t.Fatalf("Worktree AbsoluteGitDir failed: %v", err)
}
wtGitDirResolved, err := filepath.EvalSymlinks(wtGitDir)
if err != nil {
t.Fatalf("Failed to resolve wtGitDir: %v", err)
}
if wtGitDirResolved == expectedCommonDir {
t.Errorf("Expected worktree git dir to be different from common dir, but both are %q", expectedCommonDir)
}
// Expect wtGitDir to be under main repo's .git/worktrees
expectedWtGitDirPrefix := filepath.Join(expectedCommonDir, "worktrees")
if !strings.HasPrefix(wtGitDirResolved, expectedWtGitDirPrefix) {
t.Errorf("Expected worktree git dir %q to be under %q", wtGitDirResolved, expectedWtGitDirPrefix)
}
}
func TestStatus(t *testing.T) {
jirix := xtest.NewX(t)
repoDir := t.TempDir()
g := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := g.Init(repoDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
writeFile := func(name, content string) {
path := filepath.Join(repoDir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
}
// 1. Empty status
items, err := g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
if len(items) != 0 {
t.Errorf("Expected empty status, got %v", items)
}
// 2. Untracked file
writeFile("file1.txt", "content1")
items, err = g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
want := []gitutil.StatusItem{
{X: '?', Y: '?', Path: "file1.txt"},
}
if diff := cmp.Diff(want, items); diff != "" {
t.Errorf("Status mismatch (-want +got):\n%s", diff)
}
// 3. Stage file
if err := g.Add("file1.txt"); err != nil {
t.Fatalf("Add failed: %v", err)
}
items, err = g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
want = []gitutil.StatusItem{
{X: 'A', Y: ' ', Path: "file1.txt"},
}
if diff := cmp.Diff(want, items); diff != "" {
t.Errorf("Status mismatch (-want +got):\n%s", diff)
}
// 4. Commit file
if err := g.CommitWithMessage("initial commit"); err != nil {
t.Fatalf("Commit failed: %v", err)
}
items, err = g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
if len(items) != 0 {
t.Errorf("Expected empty status after commit, got %v", items)
}
// 5. Modify file (unstaged)
writeFile("file1.txt", "modified content")
items, err = g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
want = []gitutil.StatusItem{
{X: ' ', Y: 'M', Path: "file1.txt"},
}
if diff := cmp.Diff(want, items); diff != "" {
t.Errorf("Status mismatch (-want +got):\n%s", diff)
}
// 6. Stage modification
if err := g.Add("file1.txt"); err != nil {
t.Fatalf("Add failed: %v", err)
}
items, err = g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
want = []gitutil.StatusItem{
{X: 'M', Y: ' ', Path: "file1.txt"},
}
if diff := cmp.Diff(want, items); diff != "" {
t.Errorf("Status mismatch (-want +got):\n%s", diff)
}
if err := g.CommitWithMessage("commit modification"); err != nil {
t.Fatalf("Commit failed: %v", err)
}
// 7. Rename file (staged)
if err := os.Rename(filepath.Join(repoDir, "file1.txt"), filepath.Join(repoDir, "file2.txt")); err != nil {
t.Fatalf("Rename failed: %v", err)
}
if err := g.Remove("file1.txt"); err != nil {
t.Fatalf("Remove failed: %v", err)
}
if err := g.Add("file2.txt"); err != nil {
t.Fatalf("Add failed: %v", err)
}
items, err = g.Status()
if err != nil {
t.Fatalf("Status failed: %v", err)
}
want = []gitutil.StatusItem{
{X: 'R', Y: ' ', Path: "file2.txt", OldPath: "file1.txt"},
}
if diff := cmp.Diff(want, items); diff != "" {
t.Errorf("Status mismatch (-want +got):\n%s", diff)
}
}
func TestConfig(t *testing.T) {
jirix := xtest.NewX(t)
repoDir := t.TempDir()
g := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := g.Init(repoDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
key := "test.key"
val1 := "value1"
val2 := "value2"
// Key should not be set initially
if _, err := g.ConfigGetKey(key); err == nil {
t.Fatalf("expected error for non-existent key")
}
// Set key
if err := g.ConfigSetIfNeeded(key, val1); err != nil {
t.Fatalf("ConfigSetIfNeeded failed: %v", err)
}
if got, err := g.ConfigGetKey(key); err != nil {
t.Fatalf("ConfigGetKey failed: %v", err)
} else if got != val1 {
t.Errorf("expected %q, got %q", val1, got)
}
// Set key to same value
if err := g.ConfigSetIfNeeded(key, val1); err != nil {
t.Fatalf("ConfigSetIfNeeded failed: %v", err)
}
if got, err := g.ConfigGetKey(key); err != nil {
t.Fatalf("ConfigGetKey failed: %v", err)
} else if got != val1 {
t.Errorf("expected %q, got %q", val1, got)
}
// Set key to different value
if err := g.ConfigSetIfNeeded(key, val2); err != nil {
t.Fatalf("ConfigSetIfNeeded failed: %v", err)
}
if got, err := g.ConfigGetKey(key); err != nil {
t.Fatalf("ConfigGetKey failed: %v", err)
} else if got != val2 {
t.Errorf("expected %q, got %q", val2, got)
}
}
func TestAddOrReplacePartialRemote(t *testing.T) {
jirix := xtest.NewX(t)
repoDir := t.TempDir()
g := gitutil.New(jirix, gitutil.RootDirOpt(repoDir))
if err := g.Init(repoDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Test partial remote with explicit filter ("blob:none")
if err := g.AddOrReplacePartialRemote("origin", "https://example.com/repo.git", "blob:none"); err != nil {
t.Fatalf("AddOrReplacePartialRemote failed: %v", err)
}
if filter, err := g.ConfigGetKey("remote.origin.partialCloneFilter"); err != nil {
t.Fatalf("failed to get partialCloneFilter: %v", err)
} else if filter != "blob:none" {
t.Errorf("expected 'blob:none', got %q", filter)
}
if promisor, err := g.ConfigGetKey("remote.origin.promisor"); err != nil {
t.Fatalf("failed to get promisor: %v", err)
} else if promisor != "true" {
t.Errorf("expected 'true', got %q", promisor)
}
// Test custom filter ("tree:0" for treeless clone)
if err := g.AddOrReplacePartialRemote("origin", "https://example.com/repo.git", "tree:0"); err != nil {
t.Fatalf("AddOrReplacePartialRemote with tree:0 failed: %v", err)
}
if filter, err := g.ConfigGetKey("remote.origin.partialCloneFilter"); err != nil {
t.Fatalf("failed to get partialCloneFilter: %v", err)
} else if filter != "tree:0" {
t.Errorf("expected 'tree:0', got %q", filter)
}
// Test empty filter unsets partialCloneFilter while retaining promisor
if err := g.AddOrReplacePartialRemote("origin", "https://example.com/repo.git", ""); err != nil {
t.Fatalf("AddOrReplacePartialRemote with empty filter failed: %v", err)
}
if filter, err := g.ConfigGetKey("remote.origin.partialCloneFilter"); err == nil {
t.Errorf("expected error getting unset partialCloneFilter, got %q", filter)
}
if promisor, err := g.ConfigGetKey("remote.origin.promisor"); err != nil {
t.Fatalf("failed to get promisor: %v", err)
} else if promisor != "true" {
t.Errorf("expected 'true', got %q", promisor)
}
}