Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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
)

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
Expand Down
34 changes: 30 additions & 4 deletions internal/gitvolume/unsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
Expand Down
93 changes: 93 additions & 0 deletions internal/gitvolume/unsync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
62 changes: 62 additions & 0 deletions test/integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
volumes:
- mount: "src_dir:copied_dir"
mode: copy
EOF

# Sync
"$GV_BIN" sync -q

# Verify directory was copied
if [[ -d "copied_dir" && -f "copied_dir/a.txt" && -f "copied_dir/nested/b.txt" ]]; then
pass "sync created copy directory correctly"
else
fail "sync failed to copy directory"
fi

# Unsync (unmodified directory should be removed)
"$GV_BIN" unsync -q

if [[ ! -d "copied_dir" ]]; then
pass "unsync removed copy directory"
else
fail "unsync failed to remove copy directory"
fi

# Test: unsync preserves modified copy directory
log "TEST" "Testing 'unsync' preserves modified copy directory..."

# Re-sync
"$GV_BIN" sync -q

# Modify a file inside the copied directory
echo "MODIFIED" > 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
# -----------------------------------------------------------------------------
Expand Down