You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
348 lines
11 KiB
348 lines
11 KiB
package fuse_test
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestGitOperations exercises git clone, checkout, and pull on a FUSE mount.
|
|
//
|
|
// The test creates a bare repo on the mount (acting as a remote), clones it,
|
|
// makes commits, pushes, then clones from the mount into an on-mount working
|
|
// directory. It pushes additional commits, checks out an older revision in the
|
|
// on-mount clone, and runs git pull to fast-forward with real changes —
|
|
// verifying file content integrity at each step.
|
|
func TestGitOperations(t *testing.T) {
|
|
framework := NewFuseTestFramework(t, DefaultTestConfig())
|
|
defer framework.Cleanup()
|
|
|
|
require.NoError(t, framework.Setup(DefaultTestConfig()))
|
|
|
|
mountPoint := framework.GetMountPoint()
|
|
|
|
// We need a local scratch dir (not on the mount) for the "developer" clone.
|
|
localDir, err := os.MkdirTemp("", "git_ops_local_")
|
|
require.NoError(t, err)
|
|
defer os.RemoveAll(localDir)
|
|
|
|
t.Run("CloneAndPull", func(t *testing.T) {
|
|
testGitCloneAndPull(t, mountPoint, localDir)
|
|
})
|
|
}
|
|
|
|
func testGitCloneAndPull(t *testing.T, mountPoint, localDir string) {
|
|
bareRepo := filepath.Join(mountPoint, "repo.git")
|
|
localClone := filepath.Join(localDir, "clone")
|
|
mountClone := filepath.Join(mountPoint, "working")
|
|
|
|
// ---- Phase 1: Create bare repo on the mount ----
|
|
t.Log("Phase 1: create bare repo on mount")
|
|
gitRun(t, "", "init", "--bare", bareRepo)
|
|
|
|
// ---- Phase 2: Clone locally, make initial commits, push ----
|
|
t.Log("Phase 2: clone locally, commit, push")
|
|
gitRun(t, "", "clone", bareRepo, localClone)
|
|
gitRun(t, localClone, "config", "user.email", "test@seaweedfs.test")
|
|
gitRun(t, localClone, "config", "user.name", "Test")
|
|
|
|
// Commit 1
|
|
writeFile(t, localClone, "README.md", "hello world\n")
|
|
mkdirAll(t, localClone, "src")
|
|
writeFile(t, localClone, "src/main.go", `package main; import "fmt"; func main() { fmt.Println("v1") }`)
|
|
gitRun(t, localClone, "add", "-A")
|
|
gitRun(t, localClone, "commit", "-m", "initial commit")
|
|
commit1 := gitOutput(t, localClone, "rev-parse", "HEAD")
|
|
|
|
// Commit 2: bulk files
|
|
mkdirAll(t, localClone, "data")
|
|
for i := 1; i <= 20; i++ {
|
|
name := filepath.Join("data", "file-"+leftPad(i, 3)+".txt")
|
|
writeFile(t, localClone, name, "content-"+strconv.Itoa(i)+"\n")
|
|
}
|
|
gitRun(t, localClone, "add", "-A")
|
|
gitRun(t, localClone, "commit", "-m", "add data files")
|
|
commit2 := gitOutput(t, localClone, "rev-parse", "HEAD")
|
|
|
|
// Commit 3: modify + new dir
|
|
writeFile(t, localClone, "src/main.go", `package main; import "fmt"; func main() { fmt.Println("v2") }`)
|
|
writeFile(t, localClone, "README.md", "hello world\n# Updated\n")
|
|
mkdirAll(t, localClone, "docs")
|
|
writeFile(t, localClone, "docs/guide.md", "documentation\n")
|
|
gitRun(t, localClone, "add", "-A")
|
|
gitRun(t, localClone, "commit", "-m", "update src and add docs")
|
|
commit3 := gitOutput(t, localClone, "rev-parse", "HEAD")
|
|
|
|
branch := gitOutput(t, localClone, "rev-parse", "--abbrev-ref", "HEAD")
|
|
gitRun(t, localClone, "push", "origin", branch)
|
|
|
|
// ---- Phase 3: Clone from mount bare repo into on-mount working dir ----
|
|
t.Log("Phase 3: clone from mount bare repo to on-mount working dir")
|
|
gitRun(t, "", "clone", bareRepo, mountClone)
|
|
|
|
assertFileContains(t, filepath.Join(mountClone, "README.md"), "# Updated")
|
|
assertFileContains(t, filepath.Join(mountClone, "src/main.go"), "v2")
|
|
assertFileExists(t, filepath.Join(mountClone, "docs/guide.md"))
|
|
assertFileExists(t, filepath.Join(mountClone, "data/file-020.txt"))
|
|
|
|
head := gitOutput(t, mountClone, "rev-parse", "HEAD")
|
|
assert.Equal(t, commit3, head, "on-mount clone HEAD should be commit 3")
|
|
|
|
dataFiles := countFiles(t, filepath.Join(mountClone, "data"))
|
|
assert.Equal(t, 20, dataFiles, "data/ should have 20 files")
|
|
|
|
// ---- Phase 4: Push more commits from the local clone ----
|
|
t.Log("Phase 4: push more commits")
|
|
|
|
for i := 21; i <= 50; i++ {
|
|
name := filepath.Join("data", "file-"+leftPad(i, 3)+".txt")
|
|
writeFile(t, localClone, name, "content-"+strconv.Itoa(i)+"\n")
|
|
}
|
|
writeFile(t, localClone, "src/main.go", `package main; import "fmt"; func main() { fmt.Println("v3") }`)
|
|
gitRun(t, localClone, "add", "-A")
|
|
gitRun(t, localClone, "commit", "-m", "expand data and update to v3")
|
|
commit4 := gitOutput(t, localClone, "rev-parse", "HEAD")
|
|
_ = commit4
|
|
|
|
gitRun(t, localClone, "mv", "docs/guide.md", "docs/manual.md")
|
|
gitRun(t, localClone, "rm", "data/file-001.txt")
|
|
gitRun(t, localClone, "commit", "-m", "rename guide, remove file-001")
|
|
commit5 := gitOutput(t, localClone, "rev-parse", "HEAD")
|
|
|
|
gitRun(t, localClone, "push", "origin", branch)
|
|
|
|
// ---- Phase 5: Reset to older revision in on-mount clone ----
|
|
t.Log("Phase 5: reset to older revision on mount clone")
|
|
ensureMountClone(t, bareRepo, mountClone)
|
|
gitRun(t, mountClone, "reset", "--hard", commit2)
|
|
|
|
resetHead := gitOutput(t, mountClone, "rev-parse", "HEAD")
|
|
assert.Equal(t, commit2, resetHead, "should be at commit 2")
|
|
assertFileContains(t, filepath.Join(mountClone, "src/main.go"), "v1")
|
|
assertFileNotExists(t, filepath.Join(mountClone, "docs/guide.md"))
|
|
|
|
// ---- Phase 6: Pull with real changes ----
|
|
t.Log("Phase 6: pull with real fast-forward changes")
|
|
|
|
ensureMountClone(t, bareRepo, mountClone)
|
|
|
|
// After git reset --hard, give the FUSE mount a moment to settle its
|
|
// metadata cache. On slow CI, the working directory can briefly appear
|
|
// missing to a new subprocess (git pull → unpack-objects).
|
|
waitForDir(t, mountClone)
|
|
|
|
oldHead := gitOutput(t, mountClone, "rev-parse", "HEAD")
|
|
assert.Equal(t, commit2, oldHead, "should be at commit 2 before pull")
|
|
|
|
gitRun(t, mountClone, "pull")
|
|
|
|
newHead := gitOutput(t, mountClone, "rev-parse", "HEAD")
|
|
assert.Equal(t, commit5, newHead, "HEAD should be commit 5 after pull")
|
|
|
|
assertFileContains(t, filepath.Join(mountClone, "src/main.go"), "v3")
|
|
assertFileExists(t, filepath.Join(mountClone, "docs/manual.md"))
|
|
assertFileNotExists(t, filepath.Join(mountClone, "docs/guide.md"))
|
|
assertFileNotExists(t, filepath.Join(mountClone, "data/file-001.txt"))
|
|
assertFileExists(t, filepath.Join(mountClone, "data/file-050.txt"))
|
|
|
|
finalCount := countFiles(t, filepath.Join(mountClone, "data"))
|
|
assert.Equal(t, 49, finalCount, "data/ should have 49 files after pull")
|
|
|
|
// ---- Phase 7: Verify git log and status ----
|
|
t.Log("Phase 7: verify log and status")
|
|
logOutput := gitOutput(t, mountClone, "log", "--format=%s")
|
|
lines := strings.Split(strings.TrimSpace(logOutput), "\n")
|
|
assert.Equal(t, 5, len(lines), "should have 5 commits in log")
|
|
|
|
assert.Contains(t, logOutput, "initial commit")
|
|
assert.Contains(t, logOutput, "expand data")
|
|
assert.Contains(t, logOutput, "rename guide")
|
|
|
|
status := gitOutput(t, mountClone, "status", "--porcelain")
|
|
assert.Empty(t, status, "git status should be clean")
|
|
|
|
_ = commit1 // used for documentation; not needed in assertions
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func gitRun(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
gitRunWithRetry(t, dir, args...)
|
|
}
|
|
|
|
func gitOutput(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
return gitRunWithRetry(t, dir, args...)
|
|
}
|
|
|
|
// gitRunWithRetry runs a git command with retries to handle transient FUSE
|
|
// I/O errors on slow CI runners (e.g. "Could not write new index file",
|
|
// "failed to stat", "unpack-objects failed").
|
|
func gitRunWithRetry(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
const (
|
|
maxRetries = 6
|
|
dirWait = 10 * time.Second
|
|
)
|
|
var out []byte
|
|
var err error
|
|
for i := 0; i < maxRetries; i++ {
|
|
if dir != "" && !waitForDirEventually(t, dir, dirWait) {
|
|
out = []byte("directory missing: " + dir)
|
|
err = &os.PathError{Op: "stat", Path: dir, Err: os.ErrNotExist}
|
|
} else {
|
|
cmd := exec.Command("git", args...)
|
|
if dir != "" {
|
|
cmd.Dir = dir
|
|
}
|
|
out, err = cmd.CombinedOutput()
|
|
}
|
|
if err == nil {
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
if i < maxRetries-1 {
|
|
t.Logf("git %s attempt %d failed (retrying): %s", strings.Join(args, " "), i+1, string(out))
|
|
if dir != "" {
|
|
refreshDirEntry(t, dir)
|
|
}
|
|
if repoPath := extractGitRepoPath(string(out)); repoPath != "" {
|
|
_ = exec.Command("git", "init", "--bare", repoPath).Run()
|
|
waitForBareRepoEventually(t, repoPath, 5*time.Second)
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
}
|
|
require.NoError(t, err, "git %s failed after %d attempts: %s", strings.Join(args, " "), maxRetries, string(out))
|
|
return ""
|
|
}
|
|
|
|
func writeFile(t *testing.T, base, rel, content string) {
|
|
t.Helper()
|
|
p := filepath.Join(base, rel)
|
|
require.NoError(t, os.WriteFile(p, []byte(content), 0644))
|
|
}
|
|
|
|
func mkdirAll(t *testing.T, base, rel string) {
|
|
t.Helper()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(base, rel), 0755))
|
|
}
|
|
|
|
func assertFileExists(t *testing.T, path string) {
|
|
t.Helper()
|
|
_, err := os.Stat(path)
|
|
require.NoError(t, err, "expected file to exist: %s", path)
|
|
}
|
|
|
|
func assertFileNotExists(t *testing.T, path string) {
|
|
t.Helper()
|
|
_, err := os.Stat(path)
|
|
require.True(t, os.IsNotExist(err), "expected file not to exist: %s", path)
|
|
}
|
|
|
|
func assertFileContains(t *testing.T, path, substr string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err, "failed to read %s", path)
|
|
assert.Contains(t, string(data), substr, "file %s should contain %q", path, substr)
|
|
}
|
|
|
|
func countFiles(t *testing.T, dir string) int {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
require.NoError(t, err, "failed to read dir %s", dir)
|
|
count := 0
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func waitForDir(t *testing.T, dir string) {
|
|
t.Helper()
|
|
if !waitForDirEventually(t, dir, 10*time.Second) {
|
|
t.Fatalf("directory %s did not appear within 10s", dir)
|
|
}
|
|
}
|
|
|
|
func waitForDirEventually(t *testing.T, dir string, timeout time.Duration) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(dir); err == nil {
|
|
return true
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func refreshDirEntry(t *testing.T, dir string) {
|
|
t.Helper()
|
|
parent := filepath.Dir(dir)
|
|
_, _ = os.ReadDir(parent)
|
|
}
|
|
|
|
func waitForBareRepoEventually(t *testing.T, bareRepo string, timeout time.Duration) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if isBareRepo(bareRepo) {
|
|
return true
|
|
}
|
|
refreshDirEntry(t, bareRepo)
|
|
time.Sleep(150 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isBareRepo(bareRepo string) bool {
|
|
required := []string{
|
|
filepath.Join(bareRepo, "HEAD"),
|
|
filepath.Join(bareRepo, "config"),
|
|
}
|
|
for _, p := range required {
|
|
if _, err := os.Stat(p); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func ensureMountClone(t *testing.T, bareRepo, mountClone string) {
|
|
t.Helper()
|
|
if _, err := os.Stat(mountClone); err == nil {
|
|
return
|
|
} else if !os.IsNotExist(err) {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Logf("mount clone missing, re-cloning from %s", bareRepo)
|
|
gitRun(t, "", "clone", bareRepo, mountClone)
|
|
}
|
|
|
|
var gitRepoPathRe = regexp.MustCompile(`'([^']+)' does not appear to be a git repository`)
|
|
|
|
func extractGitRepoPath(output string) string {
|
|
if match := gitRepoPathRe.FindStringSubmatch(output); len(match) > 1 {
|
|
return match[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func leftPad(n, width int) string {
|
|
s := strconv.Itoa(n)
|
|
for len(s) < width {
|
|
s = "0" + s
|
|
}
|
|
return s
|
|
}
|