diff --git a/go.mod b/go.mod index 92f9030..e5c74dc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/laggu/git-volume go 1.23.0 require ( + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 @@ -10,7 +11,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/internal/gitvolume/unsync.go b/internal/gitvolume/unsync.go index 3e6d9c0..de40d27 100644 --- a/internal/gitvolume/unsync.go +++ b/internal/gitvolume/unsync.go @@ -111,10 +111,24 @@ func (g *GitVolume) checkRemovable(vol Volume) (bool, error) { } if vol.Mode == ModeCopy { - // Copy Mode: Check Hash + // Copy Mode: Check content hash (file or directory) + srcInfo, err := os.Stat(vol.SourcePath) + if err != nil { + return false, fmt.Errorf("could not verify content (source missing?)") + } + if srcInfo.IsDir() { + if !info.IsDir() { + return false, nil // type mismatch: source is dir but target is not + } + match, err := verifyDirHash(vol.SourcePath, vol.TargetPath) + if err != nil { + return false, fmt.Errorf("could not verify directory hash: %w", err) + } + return match, nil + } match, err := verifyHash(vol.SourcePath, vol.TargetPath) if err != nil { - return false, fmt.Errorf("could not verify hash (source missing?)") + return false, fmt.Errorf("could not verify file hash: %w", err) } return match, nil } @@ -137,9 +151,21 @@ func (g *GitVolume) checkRemovable(vol Volume) (bool, error) { } func (g *GitVolume) removeVolume(vol Volume) error { - if err := os.Remove(vol.TargetPath); err != nil { - return fmt.Errorf("failed to remove %s: %w", vol.TargetPath, err) + info, err := os.Lstat(vol.TargetPath) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", vol.TargetPath, err) } + + if info.IsDir() { + if err := os.RemoveAll(vol.TargetPath); err != nil { + return fmt.Errorf("failed to remove directory %s: %w", vol.TargetPath, err) + } + } else { + if err := os.Remove(vol.TargetPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", vol.TargetPath, err) + } + } + if !g.quiet { fmt.Printf("✓ Removed %s\n", vol.Target) } diff --git a/internal/gitvolume/unsync_test.go b/internal/gitvolume/unsync_test.go index ac06872..9062ec2 100644 --- a/internal/gitvolume/unsync_test.go +++ b/internal/gitvolume/unsync_test.go @@ -258,3 +258,96 @@ func TestGitVolume_Unsync_RelativeLink(t *testing.T) { _, err = os.Lstat(linkPath) assert.True(t, os.IsNotExist(err), "Relative link should be correctly identified and removed") } + +func TestGitVolume_Unsync_CopyDirectory(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + // Create source directory with files + configDir := filepath.Join(sourceDir, "config") + require.NoError(t, os.MkdirAll(configDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "app.env"), []byte("A=1"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "db.env"), []byte("B=2"), 0644)) + + volumes := []Volume{ + {Source: "config", Target: "config", Mode: ModeCopy}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + + // Sync + require.NoError(t, gv.Sync(SyncOptions{})) + + // Verify synced + targetConfig := filepath.Join(targetDir, "config") + _, err := os.Stat(filepath.Join(targetConfig, "app.env")) + require.NoError(t, err, "app.env should exist after sync") + _, err = os.Stat(filepath.Join(targetConfig, "db.env")) + require.NoError(t, err, "db.env should exist after sync") + + // Unsync + require.NoError(t, gv.Unsync(UnsyncOptions{})) + + // Verify directory is completely removed + _, err = os.Stat(targetConfig) + assert.True(t, os.IsNotExist(err), "config directory should be removed after unsync") +} + +func TestGitVolume_Unsync_CopyDirectory_Modified(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + // Create source directory + configDir := filepath.Join(sourceDir, "config") + require.NoError(t, os.MkdirAll(configDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "app.env"), []byte("A=1"), 0644)) + + volumes := []Volume{ + {Source: "config", Target: "config", Mode: ModeCopy}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + + // Sync + require.NoError(t, gv.Sync(SyncOptions{})) + + // Modify a file inside the copied directory + modifiedPath := filepath.Join(targetDir, "config", "app.env") + require.NoError(t, os.WriteFile(modifiedPath, []byte("MODIFIED"), 0644)) + + // Unsync + require.NoError(t, gv.Unsync(UnsyncOptions{})) + + // Verify directory was preserved (hash mismatch → skip) + _, err := os.Stat(filepath.Join(targetDir, "config")) + assert.NoError(t, err, "Modified config directory should NOT be removed") + + data, _ := os.ReadFile(modifiedPath) + assert.Equal(t, "MODIFIED", string(data), "Modified file content should be preserved") +} + +func TestGitVolume_Unsync_CopyDirectory_MissingSource(t *testing.T) { + sourceDir, targetDir, cleanup := setupTestEnv(t) + defer cleanup() + + // Create source directory + configDir := filepath.Join(sourceDir, "config") + require.NoError(t, os.MkdirAll(configDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "app.env"), []byte("A=1"), 0644)) + + volumes := []Volume{ + {Source: "config", Target: "config", Mode: ModeCopy}, + } + gv := createTestGitVolume(sourceDir, targetDir, "", volumes) + + // Sync + require.NoError(t, gv.Sync(SyncOptions{})) + + // Delete source directory + require.NoError(t, os.RemoveAll(configDir)) + + // Unsync + require.NoError(t, gv.Unsync(UnsyncOptions{})) + + // Verify target directory was NOT removed (source missing → safe skip) + _, err := os.Stat(filepath.Join(targetDir, "config")) + assert.NoError(t, err, "Config directory should NOT be removed if source is missing") +} diff --git a/test/integration.sh b/test/integration.sh index 1d638a8..220e3b3 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -322,6 +322,68 @@ else fail "global remove allowed path traversal (unexpected)" fi +# ----------------------------------------------------------------------------- +# Test: unsync copy directory +# ----------------------------------------------------------------------------- +log "TEST" "Testing 'unsync' copy directory..." + +# Create source directory with files +mkdir -p src_dir/nested +echo "FILE_A" > src_dir/a.txt +echo "FILE_B" > src_dir/nested/b.txt + +# Update config for directory copy +cat > git-volume.yaml < copied_dir/a.txt + +# Unsync +"$GV_BIN" unsync -q + +if [[ -d "copied_dir" ]]; then + CONTENT=$(cat copied_dir/a.txt) + if [[ "$CONTENT" == "MODIFIED" ]]; then + pass "unsync preserved modified copy directory" + else + fail "unsync changed modified copy directory content" + fi +else + fail "unsync deleted modified copy directory" +fi + +# Clean up for next tests +rm -rf copied_dir + # ----------------------------------------------------------------------------- # Summary # -----------------------------------------------------------------------------