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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder \
### Sync Command Options

- `--dest`: The destination FTP server URL. It should follow the format `ftp://username:password@hostname:port/path/to/remote/folder`.
- `--exclude`: (Optional) Specifies paths or patterns to exclude from the synchronization process. You can specify multiple `--exclude` options to exclude multiple paths or patterns.
- `--exclude`: (Optional) Specifies paths or glob patterns to exclude from synchronization. Supports `*`, `**`, and `?`. You can specify multiple `--exclude` options.

### Sync Command Arguments

Expand All @@ -195,7 +195,7 @@ The application uses structured error handling with specific exit codes:
<!-- ROADMAP -->
## Roadmap

- [ ] Support for patterns in the `--exclude` option.
- [x] Support for patterns in the `--exclude` option.
- [ ] Support of Secure FTP (SFTP) protocol.
- [ ] Improved error handling and error messages.
- [ ] Integration with Git for automatic syncing on commit or branch changes.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/capcom6/sftp-sync
go 1.24.3

require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/fsnotify/fsnotify v1.6.0
github.com/go-core-fx/cli-logger v0.0.0-20260319073231-90ee4649c242
github.com/jlaffaye/ftp v0.2.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
Expand Down
13 changes: 10 additions & 3 deletions internal/cli/commands/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/capcom6/sftp-sync/internal/cli/codes"
"github.com/capcom6/sftp-sync/internal/client"
"github.com/capcom6/sftp-sync/internal/exclude"
"github.com/capcom6/sftp-sync/internal/syncer"
"github.com/capcom6/sftp-sync/internal/watcher"
logger "github.com/go-core-fx/cli-logger"
Expand Down Expand Up @@ -33,7 +34,7 @@ func Command() *cli.Command {
},
&cli.StringSliceFlag{
Name: "exclude",
Usage: "paths or patterns to exclude from the synchronization process",
Usage: "paths or glob patterns to exclude (supports *, **, ?)",
},
},
ArgsUsage: "[source]",
Expand Down Expand Up @@ -73,8 +74,14 @@ func Action(ctx context.Context, cmd *cli.Command) error {
return cli.Exit(err.Error(), codes.ClientError)
}

watcher := watcher.New(cfg.Source, cfg.Excludes, log)
syncer := syncer.New(cfg.Source, remote, log)
excludeMatcher, err := exclude.New(cfg.Excludes, cfg.Source)
if err != nil {
log.Error(ctx, "Failed to build exclude matcher", err)
return cli.Exit(err.Error(), codes.ParamsError)
}

watcher := watcher.New(cfg.Source, excludeMatcher, log)
syncer := syncer.New(cfg.Source, remote, excludeMatcher, log)

var wg sync.WaitGroup

Expand Down
7 changes: 7 additions & 0 deletions internal/exclude/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exclude

import "errors"

var (
ErrInvalidPattern = errors.New("invalid exclude pattern")
)
95 changes: 95 additions & 0 deletions internal/exclude/matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package exclude

import (
"fmt"
"path"
"path/filepath"
"strings"

"github.com/bmatcuk/doublestar/v4"
)
Comment thread
capcom6 marked this conversation as resolved.

type rule struct {
value string
isPattern bool
}

type Matcher struct {
sourceRoot string
rules []rule
}

func New(rules []string, sourceRoot string) (*Matcher, error) {
absSourceRoot, err := filepath.Abs(sourceRoot)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err)
}

compiled := make([]rule, 0, len(rules))
for _, raw := range rules {
normalized := filepath.ToSlash(raw)
if !doublestar.ValidatePattern(normalized) {
return nil, fmt.Errorf("%w: exclude rule %q is invalid", ErrInvalidPattern, raw)
}

compiled = append(compiled, rule{
value: normalized,
isPattern: hasMeta(normalized),
})
}

return &Matcher{
sourceRoot: absSourceRoot,
rules: compiled,
}, nil
}

func (m *Matcher) Match(filePath string) bool {
matched, _ := m.MatchRule(filePath)
return matched
}

func (m *Matcher) MatchRule(filePath string) (bool, string) {
candidate := filePath
if filepath.IsAbs(candidate) {
if rel, err := filepath.Rel(m.sourceRoot, candidate); err == nil {
candidate = rel
}
}
normalized := path.Clean(filepath.ToSlash(candidate))
Comment thread
capcom6 marked this conversation as resolved.

for _, r := range m.rules {
if !r.isPattern {
if normalized == r.value || strings.HasPrefix(normalized, r.value+"/") {
return true, r.value
}

continue
}

// Try direct match first
matched, matchErr := doublestar.Match(r.value, normalized)
if matchErr == nil && matched {
return true, r.value
}

// If direct match fails, try matching against path prefixes
// This handles cases like:
// - pattern "build/*" matching "build/out/main.bin"
// - pattern "**/node_modules" matching "web/node_modules/react/index.js"
parts := strings.Split(normalized, "/")
for i := 1; i <= len(parts); i++ {
prefix := path.Join(parts[:i]...)
matched, matchErr = doublestar.Match(r.value, prefix)
if matchErr == nil && matched {
return true, r.value
}
}
}

return false, ""
}

func hasMeta(pattern string) bool {
return strings.ContainsAny(pattern, "*?{")
}
Comment thread
capcom6 marked this conversation as resolved.
115 changes: 115 additions & 0 deletions internal/exclude/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package exclude_test

import (
"path/filepath"
"testing"

"github.com/capcom6/sftp-sync/internal/exclude"
)

func TestNewRejectsInvalidPattern(t *testing.T) {
t.Parallel()

_, err := exclude.New([]string{"**/["}, ".")
if err == nil {
t.Fatal("expected invalid pattern error")
}
}

func TestMatcherLiteralPathMatching(t *testing.T) {
t.Parallel()

matcher, err := exclude.New([]string{".git", "vendor/cache"}, "/repo")
if err != nil {
t.Fatalf("New() error = %v", err)
}

tests := []struct {
name string
path string
want bool
}{
{name: "exact dir", path: "./.git", want: true},
{name: "descendant", path: "./.git/config", want: true},
{name: "other path", path: "./pkg/main.go", want: false},
{name: "nested descendant", path: "vendor/cache/tmp.txt", want: true},
{name: "meta-like characters stay literal", path: "vendor/cache[file].txt", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := matcher.Match(tt.path); got != tt.want {
t.Fatalf("Match(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
Comment thread
capcom6 marked this conversation as resolved.
}

func TestMatcherLiteralBackwardCompatibilityWithMetaCharacters(t *testing.T) {
t.Parallel()

matcher, err := exclude.New([]string{"cache[file].txt"}, "/repo")
if err != nil {
t.Fatalf("New() error = %v", err)
}

if got := matcher.Match("cache[file].txt"); !got {
t.Fatalf("literal path containing [] should still match exactly")
}

if got := matcher.Match("cachef.txt"); got {
t.Fatalf("literal-first behavior expected; glob expansion should not alter matching")
}
}

func TestMatcherPatternMatching(t *testing.T) {
t.Parallel()

matcher, err := exclude.New([]string{"**/*.tmp", "build/*", "**/node_modules"}, "/repo")
if err != nil {
t.Fatalf("New() error = %v", err)
}

tests := []struct {
name string
path string
want bool
}{
{name: "recursive extension", path: "a/b/c.tmp", want: true},
{name: "single segment", path: "build/main.bin", want: true},
{name: "single segment nested via matched ancestor", path: "build/out/main.bin", want: true},
{name: "pattern matches ancestor directory", path: "web/node_modules/react/index.js", want: true},
{name: "non match", path: "src/main.go", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := matcher.Match(tt.path); got != tt.want {
t.Fatalf("Match(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
Comment thread
capcom6 marked this conversation as resolved.
}

func TestMatcherAbsolutePathAndRootBoundary(t *testing.T) {
t.Parallel()

root := filepath.FromSlash("/repo")
inside := filepath.Join(root, "dist", "bundle.js")
outside := filepath.FromSlash("/other/dist/bundle.js")

matcher, err := exclude.New([]string{"dist/**"}, root)
if err != nil {
t.Fatalf("New() error = %v", err)
}

if got := matcher.Match(inside); !got {
t.Fatalf("Match(%q) = %v, want true", inside, got)
}

if got := matcher.Match(outside); got {
t.Fatalf("Match(%q) = %v, want false", outside, got)
}
}
53 changes: 41 additions & 12 deletions internal/syncer/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,32 @@ import (
"errors"
"fmt"
"os"
"path"
"path/filepath"

"github.com/capcom6/sftp-sync/internal/client"
"github.com/capcom6/sftp-sync/internal/exclude"
logger "github.com/go-core-fx/cli-logger"
)

type Syncer struct {
rootPath string
client client.Client
matcher *exclude.Matcher

logger logger.Logger
}

func New(rootPath string, client client.Client, logger logger.Logger) *Syncer {
func New(rootPath string, client client.Client, matcher *exclude.Matcher, logger logger.Logger) *Syncer {
return &Syncer{
rootPath: rootPath,
client: client,
matcher: matcher,

logger: logger.WithContext("syncer", ""),
}
}

func (s *Syncer) Sync(ctx context.Context, absPath string) error {
exists, isDir, err := fsInfo(absPath)
if err != nil {
return fmt.Errorf("fsInfo: %w", err)
}

absRoot, err := filepath.Abs(s.rootPath)
if err != nil {
return fmt.Errorf("filepath.Abs: %w", err)
Expand All @@ -44,6 +41,19 @@ func (s *Syncer) Sync(ctx context.Context, absPath string) error {
return fmt.Errorf("filepath.Rel: %w", err)
}

if matched, rule := s.isExcluded(absPath); matched {
s.logger.Debug(ctx, "Excluded path skipped", logger.Fields{
"path": relPath,
"rule": rule,
})
return nil
}

exists, isDir, err := fsInfo(absPath)
if err != nil {
return fmt.Errorf("fsInfo: %w", err)
}

if !exists {
if rmErr := s.client.Remove(ctx, pathNormalize(relPath)); rmErr != nil {
return fmt.Errorf("c.Remove: %w", rmErr)
Expand Down Expand Up @@ -95,14 +105,25 @@ func (s *Syncer) syncDir(ctx context.Context, absPath, relPath string) error {
default:
}

childAbsPath := filepath.Join(absPath, file.Name())
childRelPath := filepath.Join(relPath, file.Name())
if matched, rule := s.isExcluded(childAbsPath); matched {
s.logger.Debug(ctx, "Excluded path skipped", logger.Fields{
"path": childRelPath,
"rule": rule,
})
continue
}

if file.IsDir() {
if dErr := s.syncDir(ctx, path.Join(absPath, file.Name()), path.Join(relPath, file.Name())); dErr != nil {
if dErr := s.syncDir(ctx, childAbsPath, childRelPath); dErr != nil {
return dErr
}
} else {
if fErr := s.syncFile(ctx, path.Join(absPath, file.Name()), path.Join(relPath, file.Name())); fErr != nil {
return fErr
}
continue
}

if fErr := s.syncFile(ctx, childAbsPath, childRelPath); fErr != nil {
return fErr
}
}

Expand All @@ -124,3 +145,11 @@ func fsInfo(path string) (bool, bool, error) {
func pathNormalize(path string) string {
return filepath.ToSlash(path)
}

func (s *Syncer) isExcluded(absPath string) (bool, string) {
if s.matcher == nil {
return false, ""
}

return s.matcher.MatchRule(absPath)
}
Loading
Loading