| // 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) |
| } |
| } |