diff --git a/internal/gitvolume/context_test.go b/internal/gitvolume/context_test.go index bcdf820..eab4dd6 100644 --- a/internal/gitvolume/context_test.go +++ b/internal/gitvolume/context_test.go @@ -321,6 +321,26 @@ func TestNewWorkspace_NoConfig(t *testing.T) { assert.Error(t, ctx.Load("", true)) } +func TestNewWorkspace_EmptyConfig(t *testing.T) { + repoDir, cleanup := setupTestGitRepo(t) + defer cleanup() + + configPath := filepath.Join(repoDir, ConfigFileName) + require.NoError(t, os.WriteFile(configPath, []byte(""), 0644)) + + // Change to repo dir + oldDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldDir) }() + require.NoError(t, os.Chdir(repoDir)) + + ctx, err := NewContext() + require.NoError(t, err) + // Should not error, just empty volumes + require.NoError(t, ctx.Load("", true)) + assert.Equal(t, 0, len(ctx.Volumes)) +} + func TestNewWorkspace_RelativeCustomPath(t *testing.T) { repoDir, cleanup := setupTestGitRepo(t) defer cleanup() @@ -392,3 +412,109 @@ func TestHasGlobalVolumes(t *testing.T) { }) } } + +func TestCheckStatus(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + createVol := func(source, target, mode string) Volume { + return Volume{ + Source: source, + Target: target, + Mode: mode, + SourcePath: filepath.Join(sourceDir, source), + TargetPath: filepath.Join(targetDir, target), + } + } + + testCases := []struct { + name string + vol Volume + setup func(t *testing.T, vol Volume) + wantStatus string + }{ + { + name: "Link OK", + vol: createVol("source1.txt", "link.txt", ModeLink), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, os.Symlink(vol.SourcePath, vol.TargetPath)) + }, + wantStatus: StatusOKLinked, + }, + { + name: "Link WrongLink", + vol: createVol("source1.txt", "wrong_link.txt", ModeLink), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, os.Symlink(filepath.Join(sourceDir, "source2.txt"), vol.TargetPath)) + }, + wantStatus: StatusWrongLink, + }, + { + name: "Link ExistsNotLink", + vol: createVol("source1.txt", "notlink.txt", ModeLink), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, os.WriteFile(vol.TargetPath, []byte("file"), 0644)) + }, + wantStatus: StatusExistsNotLink, + }, + { + name: "Copy OK", + vol: createVol("source1.txt", "copy_ok.txt", ModeCopy), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, copyFile(vol.SourcePath, vol.TargetPath)) + }, + wantStatus: StatusOKCopied, + }, + { + name: "Copy Modified", + vol: createVol("source1.txt", "copy_mod.txt", ModeCopy), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, os.WriteFile(vol.TargetPath, []byte("modified"), 0644)) + }, + wantStatus: StatusModified, + }, + { + name: "MissingSource", + vol: createVol("missing.txt", "x.txt", ModeLink), + setup: func(t *testing.T, vol Volume) {}, + wantStatus: StatusMissingSource, + }, + { + name: "NotMounted", + vol: createVol("source1.txt", "nomount.txt", ModeLink), + setup: func(t *testing.T, vol Volume) {}, + wantStatus: StatusNotMounted, + }, + { + name: "Copy Dir OK", + vol: createVol("statusdir", "statusdir_ok", ModeCopy), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, os.Mkdir(vol.SourcePath, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(vol.SourcePath, "f.txt"), []byte("data"), 0644)) + require.NoError(t, copyDir(vol.SourcePath, vol.TargetPath)) + }, + wantStatus: StatusOKCopied, + }, + { + name: "Copy Dir Modified", + vol: createVol("statusdir2", "statusdir_mod", ModeCopy), + setup: func(t *testing.T, vol Volume) { + require.NoError(t, os.Mkdir(vol.SourcePath, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(vol.SourcePath, "f.txt"), []byte("data"), 0644)) + require.NoError(t, copyDir(vol.SourcePath, vol.TargetPath)) + require.NoError(t, os.WriteFile(filepath.Join(vol.TargetPath, "f.txt"), []byte("changed"), 0644)) + }, + wantStatus: StatusModified, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.setup(t, tc.vol) + defer os.RemoveAll(tc.vol.TargetPath) + + status := tc.vol.CheckStatus() + assert.Equal(t, tc.wantStatus, status.Status) + }) + } +} diff --git a/internal/gitvolume/fs_test.go b/internal/gitvolume/fs_test.go new file mode 100644 index 0000000..613d8df --- /dev/null +++ b/internal/gitvolume/fs_test.go @@ -0,0 +1,140 @@ +package gitvolume + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopyFile(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "source.txt") + dst := filepath.Join(tmpDir, "dest.txt") + + // 1. Normal copy + err := os.WriteFile(src, []byte("hello"), 0644) + require.NoError(t, err) + + err = copyFile(src, dst) + require.NoError(t, err) + + content, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "hello", string(content)) + + info, err := os.Stat(dst) + require.NoError(t, err) + if runtime.GOOS != "windows" { + assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) + } + + // 2. Source missing + err = copyFile(filepath.Join(tmpDir, "missing"), dst) + assert.Error(t, err) + + // 3. Dest read-only (directory) + // Create a directory where the file should be to trigger error + err = os.Mkdir(filepath.Join(tmpDir, "readonly"), 0755) + require.NoError(t, err) + err = copyFile(src, filepath.Join(tmpDir, "readonly")) + assert.Error(t, err) +} + +func TestCopyDir(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "src") + dst := filepath.Join(tmpDir, "dst") + + // Setup source structure + require.NoError(t, os.MkdirAll(filepath.Join(src, "subdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "file1.txt"), []byte("file1"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "subdir", "file2.txt"), []byte("file2"), 0644)) + + // 1. Normal recursive copy + err := copyDir(src, dst) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(dst, "file1.txt")) + assert.FileExists(t, filepath.Join(dst, "subdir", "file2.txt")) + + // 2. Symlink in source (should fail) + symLinkSrc := filepath.Join(tmpDir, "symsrc") + require.NoError(t, os.Mkdir(symLinkSrc, 0755)) + require.NoError(t, os.Symlink(dst, filepath.Join(symLinkSrc, "link"))) + + err = copyDir(symLinkSrc, filepath.Join(tmpDir, "symdst")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "symlink, which is not allowed") + + // 3. Target exists and is not a directory + isFile := filepath.Join(tmpDir, "isFile") + require.NoError(t, os.WriteFile(isFile, []byte("data"), 0644)) + err = copyDir(src, isFile) + assert.Error(t, err) + // Error message differs by OS for MkdirAll on existing file + // Linux/Mac: "not a directory" +} + +func TestHashAndVerify(t *testing.T) { + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + + require.NoError(t, os.WriteFile(file1, []byte("content"), 0644)) + require.NoError(t, os.WriteFile(file2, []byte("content"), 0644)) + + // 1. hashFile + h1, err := hashFile(file1) + require.NoError(t, err) + h2, err := hashFile(file2) + require.NoError(t, err) + assert.Equal(t, h1, h2) + + // 2. verifyHash matches + match, err := verifyHash(file1, file2) + require.NoError(t, err) + assert.True(t, match) + + // 3. verifyHash mismatch + require.NoError(t, os.WriteFile(file2, []byte("diff"), 0644)) + match, err = verifyHash(file1, file2) + require.NoError(t, err) + assert.False(t, match) + + // 4. Missing file + _, err = hashFile(filepath.Join(tmpDir, "missing")) + assert.Error(t, err) +} + +func TestCleanEmptyParents(t *testing.T) { + tmpDir := t.TempDir() + nested := filepath.Join(tmpDir, "a", "b", "c") + require.NoError(t, os.MkdirAll(nested, 0755)) + + // 1. Clean up empty dirs logic + // Remove 'c', then cleanEmptyParents should remove 'b' and 'a' but stop at tmpDir + err := os.Remove(nested) + require.NoError(t, err) + + cleanEmptyParents(filepath.Join(tmpDir, "a", "b"), tmpDir) + + assert.NoDirExists(t, filepath.Join(tmpDir, "a", "b")) + assert.NoDirExists(t, filepath.Join(tmpDir, "a")) + assert.DirExists(t, tmpDir) + + // 2. Stop if not empty + require.NoError(t, os.MkdirAll(nested, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "a", "file.txt"), []byte("keep"), 0644)) + + err = os.Remove(nested) + require.NoError(t, err) + + cleanEmptyParents(filepath.Join(tmpDir, "a", "b"), tmpDir) + + assert.NoDirExists(t, filepath.Join(tmpDir, "a", "b")) + assert.DirExists(t, filepath.Join(tmpDir, "a")) // Should exist because of file.txt +} diff --git a/internal/gitvolume/git_repro_test.go b/internal/gitvolume/git_repro_test.go new file mode 100644 index 0000000..649f203 --- /dev/null +++ b/internal/gitvolume/git_repro_test.go @@ -0,0 +1,114 @@ +package gitvolume + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindCommonDir_Repro(t *testing.T) { + // Create a temporary directory for our test environment + tmpDir, err := os.MkdirTemp("", "git-volume-repro-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // 1. Test Case: Bare Repository + Worktree + t.Run("Bare Repository with Worktree", func(t *testing.T) { + bareRepo := filepath.Join(tmpDir, "bare.git") + worktree := filepath.Join(tmpDir, "worktree") + + // Create a non-bare origin repo with an initial commit, + // then clone it as bare. + + origin := filepath.Join(tmpDir, "origin") + require.NoError(t, os.MkdirAll(origin, 0755)) + cmd := exec.Command("git", "init", origin) + require.NoError(t, cmd.Run()) + + // config user + cmd = exec.Command("git", "-C", origin, "config", "user.email", "test@example.com") + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", origin, "config", "user.name", "Test User") + require.NoError(t, cmd.Run()) + + // commit + require.NoError(t, os.WriteFile(filepath.Join(origin, "README.md"), []byte("test"), 0644)) + cmd = exec.Command("git", "-C", origin, "add", ".") + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", origin, "commit", "-m", "initial") + require.NoError(t, cmd.Run()) + + // Clone as bare (this creates the bareRepo directory) + cmd = exec.Command("git", "clone", "--bare", origin, bareRepo) + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "-C", bareRepo, "worktree", "add", "-b", "bare-worktree", worktree) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git worktree add failed: %s, output: %s", err, out) + } + + // Resolve symlinks for accurate comparison + bareRepo, err = filepath.EvalSymlinks(bareRepo) + require.NoError(t, err) + + // Run findCommonDir from worktree root + commonDir, err := findCommonDir(worktree) + require.NoError(t, err) + + // For bare repo, common dir should be the bare repo path itself + assert.Equal(t, bareRepo, commonDir, "Should identify bare repo root as common dir") + + // Run findCommonDir from subdirectory of worktree + subDir := filepath.Join(worktree, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0755)) + + commonDirSub, err := findCommonDir(subDir) + require.NoError(t, err) + assert.Equal(t, bareRepo, commonDirSub, "Should identify bare repo root from subdirectory") + }) + + // 2. Test Case: Regular Repository + Worktree + t.Run("Regular Repository with Worktree", func(t *testing.T) { + mainRepo := filepath.Join(tmpDir, "main") + worktree := filepath.Join(tmpDir, "main-worktree") + + // Initialize main repo + require.NoError(t, os.MkdirAll(mainRepo, 0755)) + cmd := exec.Command("git", "init", mainRepo) + require.NoError(t, cmd.Run()) + + // config user + cmd = exec.Command("git", "-C", mainRepo, "config", "user.email", "test@example.com") + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", mainRepo, "config", "user.name", "Test User") + require.NoError(t, cmd.Run()) + + // commit + require.NoError(t, os.WriteFile(filepath.Join(mainRepo, "README.md"), []byte("test"), 0644)) + cmd = exec.Command("git", "-C", mainRepo, "add", ".") + require.NoError(t, cmd.Run()) + cmd = exec.Command("git", "-C", mainRepo, "commit", "-m", "initial") + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "-C", mainRepo, "worktree", "add", "-b", "main-worktree-branch", worktree) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git worktree add failed: %s, output: %s", err, out) + } + + // Resolve symlinks + mainRepo, err = filepath.EvalSymlinks(mainRepo) + require.NoError(t, err) + + // Run findCommonDir from worktree root + commonDir, err := findCommonDir(worktree) + require.NoError(t, err) + + // For regular repo, common dir is .git dir, so root is parent of .git + // BUT findCommonDir returns the ROOT of the main repo, not the .git dir. + assert.Equal(t, mainRepo, commonDir, "Should identify main repo root") + }) +} diff --git a/internal/gitvolume/git_test.go b/internal/gitvolume/git_test.go new file mode 100644 index 0000000..5d2e507 --- /dev/null +++ b/internal/gitvolume/git_test.go @@ -0,0 +1,90 @@ +package gitvolume + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindWorktreeRoot(t *testing.T) { + // 1. Regular git repo + tmpDir := t.TempDir() + cmd := exec.Command("git", "init", tmpDir) + require.NoError(t, cmd.Run()) + + // Create a subdirectory + subDir := filepath.Join(tmpDir, "subdir") + require.NoError(t, os.Mkdir(subDir, 0755)) + + // Find root from root + root, err := FindWorktreeRoot(tmpDir) + require.NoError(t, err) + + // Evaluate symlinks in case /var vs /private/var on Mac + evalTmpDir, err := filepath.EvalSymlinks(tmpDir) + require.NoError(t, err) + evalRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + assert.Equal(t, evalTmpDir, evalRoot) + + // Find root from subdir + root, err = FindWorktreeRoot(subDir) + require.NoError(t, err) + evalRoot, err = filepath.EvalSymlinks(root) + require.NoError(t, err) + assert.Equal(t, evalTmpDir, evalRoot) + + // 2. Not a git repo + nonGitDir := t.TempDir() + _, err = FindWorktreeRoot(nonGitDir) + assert.Error(t, err) +} + +func TestFindCommonDir(t *testing.T) { + tmpDir := t.TempDir() + + // 1. Regular repo + repoDir := filepath.Join(tmpDir, "repo") + require.NoError(t, os.Mkdir(repoDir, 0755)) + cmd := exec.Command("git", "init", repoDir) + require.NoError(t, cmd.Run()) + + commonDir, err := findCommonDir(repoDir) + require.NoError(t, err) + + evalRepoDir, err := filepath.EvalSymlinks(repoDir) + require.NoError(t, err) + evalCommonDir, err := filepath.EvalSymlinks(commonDir) + require.NoError(t, err) + assert.Equal(t, evalRepoDir, evalCommonDir) + + // 2. Worktree (standard layout) is harder to set up without bare repo or commits + // but findCommonDir should return main repo root for the main repo itself. +} + +func TestIsBareRepository(t *testing.T) { + tmpDir := t.TempDir() + + // 1. Regular repo + repoDir := filepath.Join(tmpDir, "repo") + require.NoError(t, os.Mkdir(repoDir, 0755)) + cmd := exec.Command("git", "init", repoDir) + require.NoError(t, cmd.Run()) + + isBare, err := isBareRepository(repoDir) + require.NoError(t, err) + assert.False(t, isBare) + + // 2. Bare repo + bareDir := filepath.Join(tmpDir, "bare.git") + cmd = exec.Command("git", "init", "--bare", bareDir) + require.NoError(t, cmd.Run()) + + isBare, err = isBareRepository(bareDir) + require.NoError(t, err) + assert.True(t, isBare) +} diff --git a/internal/gitvolume/gitvolume_test.go b/internal/gitvolume/gitvolume_test.go index 91aa9c3..c612cb2 100644 --- a/internal/gitvolume/gitvolume_test.go +++ b/internal/gitvolume/gitvolume_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -67,3 +68,32 @@ func createTestGitVolume(sourceDir, targetDir, globalDir string, volumes []Volum quiet: true, } } + +func TestGitVolumeAccessors(t *testing.T) { + sourceDir, targetDir, globalDir, cleanup := setupTestEnvWithGlobal(t) + defer cleanup() + + volumes := []Volume{ + {Source: "source1.txt", Target: "link1.txt", Mode: ModeLink}, + {Source: "global.txt", Target: "global.txt", Mode: ModeLink, IsGlobal: true}, + } + gv := createTestGitVolume(sourceDir, targetDir, globalDir, volumes) + + assert.Equal(t, sourceDir, gv.SourceDir()) + assert.Equal(t, targetDir, gv.TargetDir()) + assert.Equal(t, globalDir, gv.GlobalDir()) + assert.NotNil(t, gv.Context()) + assert.True(t, gv.HasGlobalVolumes()) +} + +func TestValidatePath_GitDirectory(t *testing.T) { + err := validatePath(".git") + assert.Error(t, err) + assert.Contains(t, err.Error(), ".git") + + err = validatePath(".git/config") + assert.Error(t, err) + + err = validatePath(".") + assert.Error(t, err) +} diff --git a/internal/gitvolume/init_test.go b/internal/gitvolume/init_test.go new file mode 100644 index 0000000..c669eb1 --- /dev/null +++ b/internal/gitvolume/init_test.go @@ -0,0 +1,115 @@ +package gitvolume + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInit(t *testing.T) { + // 1. Setup git repo + tmpDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "repo"), 0755)) + repoDir := filepath.Join(tmpDir, "repo") + cmd := exec.Command("git", "init", repoDir) + require.NoError(t, cmd.Run()) + + // Change CWD to repo + wd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(wd) }() // Restore CWD + require.NoError(t, os.Chdir(repoDir)) + + // Setup GitVolume (GlobalDir will be in tmp) + globalDir := filepath.Join(tmpDir, "global") + gv := createTestGitVolume(repoDir, repoDir, globalDir, nil) + gv.quiet = true + + // Test 1: Init success + err = gv.Init() + require.NoError(t, err) + + assert.DirExists(t, globalDir) + assert.FileExists(t, filepath.Join(repoDir, "git-volume.yaml")) + + // Verify content + content, err := os.ReadFile(filepath.Join(repoDir, "git-volume.yaml")) + require.NoError(t, err) + assert.Contains(t, string(content), "volumes:") + + // Test 2: Idempotency (should not overwrite) + err = os.WriteFile(filepath.Join(repoDir, "git-volume.yaml"), []byte("modified: true"), 0644) + require.NoError(t, err) + + err = gv.Init() + require.NoError(t, err) + + content, err = os.ReadFile(filepath.Join(repoDir, "git-volume.yaml")) + require.NoError(t, err) + assert.Equal(t, "modified: true", string(content)) +} + +func TestInit_OutsideGit(t *testing.T) { + // 1. Setup non-git dir + tmpDir := t.TempDir() + + // Change CWD + wd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(wd) }() + require.NoError(t, os.Chdir(tmpDir)) + + gv := createTestGitVolume(tmpDir, tmpDir, filepath.Join(tmpDir, "global"), nil) + gv.quiet = true + + // Test: Init fails + err = gv.Init() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to find git repository root") +} + +func TestInit_NonQuiet(t *testing.T) { + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "repo") + require.NoError(t, os.Mkdir(repoDir, 0755)) + cmd := exec.Command("git", "init", repoDir) + require.NoError(t, cmd.Run()) + + wd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(wd) }() + require.NoError(t, os.Chdir(repoDir)) + + globalDir := filepath.Join(tmpDir, "global") + gv := createTestGitVolume(repoDir, repoDir, globalDir, nil) + gv.quiet = false // Non-quiet to cover afterInit output branches + + // First init (configCreated branch) + err = gv.Init() + require.NoError(t, err) + assert.DirExists(t, globalDir) + assert.FileExists(t, filepath.Join(repoDir, "git-volume.yaml")) + + // Second init (configExists branch) + err = gv.Init() + require.NoError(t, err) +} + +func TestInit_NonQuiet_Error(t *testing.T) { + tmpDir := t.TempDir() + + wd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(wd) }() + require.NoError(t, os.Chdir(tmpDir)) + + gv := createTestGitVolume(tmpDir, tmpDir, filepath.Join(tmpDir, "global"), nil) + gv.quiet = false // Non-quiet to cover error branch + + err = gv.Init() + assert.Error(t, err) +} diff --git a/internal/gitvolume/list_test.go b/internal/gitvolume/list_test.go index 0c86cdd..8096f3b 100644 --- a/internal/gitvolume/list_test.go +++ b/internal/gitvolume/list_test.go @@ -87,3 +87,65 @@ func TestSortTree(t *testing.T) { sortTree(root) verifyOrder() } + +func TestGlobalList(t *testing.T) { + globalDir, cleanup := setupGlobalTestEnv(t) + defer cleanup() + + require.NoError(t, os.MkdirAll(globalDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "file1.txt"), []byte("a"), 0644)) + + gv := createTestGitVolumeWithGlobal(globalDir) + err := gv.GlobalList() + require.NoError(t, err) +} + +func TestGlobalList_Empty(t *testing.T) { + globalDir, cleanup := setupGlobalTestEnv(t) + defer cleanup() + + require.NoError(t, os.MkdirAll(globalDir, 0755)) + + gv := createTestGitVolumeWithGlobal(globalDir) + err := gv.GlobalList() + require.NoError(t, err) +} + +func TestGlobalList_NotInitialized(t *testing.T) { + tmpDir := t.TempDir() + globalDir := filepath.Join(tmpDir, "nonexistent") + + gv := createTestGitVolumeWithGlobal(globalDir) + err := gv.GlobalList() + require.NoError(t, err) +} + +func TestGlobalList_NestedDirs(t *testing.T) { + globalDir, cleanup := setupGlobalTestEnv(t) + defer cleanup() + + require.NoError(t, os.MkdirAll(filepath.Join(globalDir, "dir1", "subdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "file1.txt"), []byte("a"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "dir1", "file2.txt"), []byte("b"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "dir1", "subdir", "file3.txt"), []byte("c"), 0644)) + + gv := createTestGitVolumeWithGlobal(globalDir) + err := gv.GlobalList() + require.NoError(t, err) +} + +func TestPrintNode(t *testing.T) { + // Test printNode directly for both isLast=true and isLast=false + node := &treeNode{ + name: "parent", + isDir: true, + children: []*treeNode{ + {name: "child1.txt", isDir: false}, + {name: "child2.txt", isDir: false}, + }, + } + // Should not panic + printNode(node, "", true) + printNode(node, "", false) + printNode(node, "│ ", true) +} diff --git a/internal/gitvolume/remove_test.go b/internal/gitvolume/remove_test.go new file mode 100644 index 0000000..b8776a1 --- /dev/null +++ b/internal/gitvolume/remove_test.go @@ -0,0 +1,73 @@ +package gitvolume + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGlobalRemove(t *testing.T) { + _, _, globalDir, cleanup := setupTestEnvWithGlobal(t) + defer cleanup() + + // 1. Setup - Create additional files for testing + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "file1.txt"), []byte("content"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(globalDir, "dir1", "subdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "dir1", "file2.txt"), []byte("content"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "dir1", "subdir", "file3.txt"), []byte("content"), 0644)) + + gv := createTestGitVolume("", "", globalDir, nil) + + // Test 1: success - remove file + err := gv.GlobalRemove([]string{"file1.txt"}) + require.NoError(t, err) + assert.NoFileExists(t, filepath.Join(globalDir, "file1.txt")) + + // Test 2: success - remove directory + // Removing 'subdir' should remove file3.txt + err = gv.GlobalRemove([]string{"dir1/subdir"}) + require.NoError(t, err) + assert.NoDirExists(t, filepath.Join(globalDir, "dir1", "subdir")) + assert.FileExists(t, filepath.Join(globalDir, "dir1", "file2.txt")) // Sibling preserved + + // Test 3: cleanup - remove last file in dir1, dir1 should be removed + err = gv.GlobalRemove([]string{"dir1/file2.txt"}) + require.NoError(t, err) + assert.NoFileExists(t, filepath.Join(globalDir, "dir1", "file2.txt")) + assert.NoDirExists(t, filepath.Join(globalDir, "dir1")) // Cleanup happened + + // Test 4: missing file + err = gv.GlobalRemove([]string{"missing.txt"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + + // Test 5: path traversal + err = gv.GlobalRemove([]string{"../outside.txt"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid path") + + // Test 6: multiple files with error aggregation + // Create one valid file + require.NoError(t, os.WriteFile(filepath.Join(globalDir, "valid.txt"), []byte("content"), 0644)) + + err = gv.GlobalRemove([]string{"valid.txt", "missing.txt"}) + assert.Error(t, err) // Should return error because one failed + assert.NoFileExists(t, filepath.Join(globalDir, "valid.txt")) // Valid one should still be removed +} + +func TestGlobalRemove_NotInitialized(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "git-volume-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Point to a non-existent directory + globalDir := filepath.Join(tmpDir, "non_existent_global") + gv := createTestGitVolume("", "", globalDir, nil) + + err = gv.GlobalRemove([]string{"file.txt"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "global storage not initialized") +} diff --git a/internal/gitvolume/status_test.go b/internal/gitvolume/status_test.go index 7ebde6e..0374d49 100644 --- a/internal/gitvolume/status_test.go +++ b/internal/gitvolume/status_test.go @@ -1,6 +1,7 @@ package gitvolume import ( + "fmt" "os" "path/filepath" "testing" @@ -71,3 +72,24 @@ func TestGitVolume_status_CopyDirectoryModified(t *testing.T) { require.NoError(t, err) assert.Equal(t, StatusModified, statuses[0].Status) } + +func TestAfterAllStatus(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + volumes := []Volume{ + {Source: "source1.txt", Target: "local.txt", Mode: ModeLink}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + + // Test afterAllStatus with nil error + statuses := []VolumeStatus{ + {Source: "source1.txt", Target: "local.txt", Mode: ModeLink, Status: StatusNotMounted}, + } + err := gv.afterAllStatus(statuses, nil) + assert.NoError(t, err) + + // Test afterAllStatus with error + err = gv.afterAllStatus(nil, fmt.Errorf("test error")) + assert.Error(t, err) +} diff --git a/internal/gitvolume/sync_test.go b/internal/gitvolume/sync_test.go index da2bdc6..b34a81b 100644 --- a/internal/gitvolume/sync_test.go +++ b/internal/gitvolume/sync_test.go @@ -334,3 +334,101 @@ func TestGitVolume_Sync_GlobalSource_EmptyGlobalBase(t *testing.T) { assert.Error(t, err, "expected error when GlobalDir is empty") assert.Contains(t, err.Error(), "global directory not configured") } + +func TestGitVolume_Sync_SourceIsSymlink(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + // Create symlink source + // We need absolute path for symlink source to be valid in most OSs or relative + targetFile := filepath.Join(sourceDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("target"), 0644)) + + symlinkSource := filepath.Join(sourceDir, "symlink-source") + require.NoError(t, os.Symlink(targetFile, symlinkSource)) + + volumes := []Volume{ + {Source: "symlink-source", Target: "dest.txt", Mode: ModeCopy}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + + err := gv.Sync(SyncOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "source file is a symlink") +} + +func TestGitVolume_Sync_PathTraversal(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + // 1. Source traversal + volumes := []Volume{ + {Source: "../outside.txt", Target: "dest.txt", Mode: ModeCopy}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + err := gv.Sync(SyncOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "security error for source") + + // 2. Target traversal + volumes = []Volume{ + {Source: "source1.txt", Target: "../outside.txt", Mode: ModeCopy}, + } + gv = createTestGitVolume(sourceDir, targetDir, "", volumes) + err = gv.Sync(SyncOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "security error for target") +} + +func TestGitVolume_Sync_Verbose(t *testing.T) { + sourceDir, targetDir, globalDir, cleanup := setupTestEnvWithGlobal(t) + defer cleanup() + + volumes := []Volume{ + {Source: "source1.txt", Target: "link.txt", Mode: ModeLink}, + {Source: "source2.txt", Target: "copy.txt", Mode: ModeCopy}, + {Source: "global.txt", Target: "global.txt", Mode: ModeLink, IsGlobal: true}, + } + ctx := &Context{ + SourceDir: sourceDir, + TargetDir: targetDir, + GlobalDir: globalDir, + Volumes: volumes, + } + ctx.ResolveVolumePaths() + + gv := &GitVolume{ctx: ctx, verbose: true, quiet: false} + + // Sync with verbose output + require.NoError(t, gv.Sync(SyncOptions{})) + + // Verify all files created + _, err := os.Lstat(filepath.Join(targetDir, "link.txt")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(targetDir, "copy.txt")) + assert.NoError(t, err) + + // Sync with relative links and verbose + require.NoError(t, gv.Sync(SyncOptions{RelativeLinks: true})) +} + +func TestGitVolume_Sync_NonQuiet_Error(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + volumes := []Volume{ + {Source: "nonexistent.txt", Target: "dest.txt", Mode: ModeLink}, + } + ctx := &Context{ + SourceDir: sourceDir, + TargetDir: targetDir, + Volumes: volumes, + } + ctx.ResolveVolumePaths() + + gv := &GitVolume{ctx: ctx, verbose: false, quiet: false} + + // Should report error non-quietly + err := gv.Sync(SyncOptions{}) + assert.Error(t, err) +} diff --git a/internal/gitvolume/unsync_test.go b/internal/gitvolume/unsync_test.go index 9062ec2..9674e79 100644 --- a/internal/gitvolume/unsync_test.go +++ b/internal/gitvolume/unsync_test.go @@ -351,3 +351,32 @@ func TestGitVolume_Unsync_CopyDirectory_MissingSource(t *testing.T) { _, err := os.Stat(filepath.Join(targetDir, "config")) assert.NoError(t, err, "Config directory should NOT be removed if source is missing") } + +func TestGitVolume_Unsync_TypeMismatch(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + // 1. Setup Source as Directory + configDir := filepath.Join(sourceDir, "config") + require.NoError(t, os.MkdirAll(configDir, 0755)) + + volumes := []Volume{ + {Source: "config", Target: "config", Mode: ModeCopy}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + + // Manually create Target as File (simulating a change or weird state) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "config"), []byte("I am a file"), 0644)) + + // Unsync + require.NoError(t, gv.Unsync(UnsyncOptions{})) + + // Verify Target File Preserved (Safety check should fail due to type mismatch) + // checkRemovable sees source is Dir, target is File -> mismatch -> returns false + info, err := os.Stat(filepath.Join(targetDir, "config")) + require.NoError(t, err) + assert.True(t, !info.IsDir()) + + data, _ := os.ReadFile(filepath.Join(targetDir, "config")) + assert.Equal(t, "I am a file", string(data)) +}