Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ea35da7
Add design document for Go rewrite
eloualiche Mar 1, 2026
86395db
Add implementation plan for Go rewrite
eloualiche Mar 1, 2026
e53fff5
feat: scaffold Go project with Cobra root command
eloualiche Mar 1, 2026
c2b9813
feat: add config package with TOML loading, defaults, and search path
eloualiche Mar 1, 2026
945cd1c
chore: run go mod tidy to fix viper as direct dependency
eloualiche Mar 1, 2026
4592900
feat: add syncer package with rsync command builder and SSH support
eloualiche Mar 1, 2026
55d6703
feat: add watcher package with fsnotify and debouncing
eloualiche Mar 1, 2026
8bcc36b
feat: add logger package with JSON and text output
eloualiche Mar 1, 2026
d7d8360
feat: add TUI with dashboard, log view, and Lipgloss styles
eloualiche Mar 1, 2026
72ed45f
feat: add sync command with TUI and daemon modes
eloualiche Mar 1, 2026
08deb0c
feat: add smart init command with .gitignore import
eloualiche Mar 1, 2026
330c6a2
feat: add check and edit commands for config validation and preview
eloualiche Mar 1, 2026
80a79ba
feat: add status command and PID file for daemon mode
eloualiche Mar 1, 2026
f2412c9
feat: add graceful shutdown with signal handling
eloualiche Mar 1, 2026
6638aae
docs: rewrite README for Go version with TOML examples
eloualiche Mar 1, 2026
cfa44aa
test: add integration tests for local sync and watcher
eloualiche Mar 1, 2026
419ec71
chore: add example config and update gitignore
eloualiche Mar 1, 2026
cbb3d37
ci: add CI and release workflows
eloualiche Mar 1, 2026
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Vet
run: go vet ./...

- name: Test
run: go test ./... -v

- name: Build
run: go build ./...
56 changes: 56 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: darwin
goarch: arm64
suffix: darwin-arm64
- goos: linux
goarch: amd64
suffix: linux-amd64
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: go build -ldflags="-s -w" -o esync-${{ matrix.suffix }} .

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: esync-${{ matrix.suffix }}
path: esync-${{ matrix.suffix }}

release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4

- name: Create release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
esync-darwin-arm64/esync-darwin-arm64
esync-linux-amd64/esync-linux-amd64
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ wheels/
# Virtual environments
.venv

# Compiled binary
esync

# temporary ignores
test-sync
tests
Expand Down
1 change: 0 additions & 1 deletion .python-version

This file was deleted.

219 changes: 219 additions & 0 deletions cmd/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package cmd

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

"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"

"github.com/eloualiche/esync/internal/config"
)

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

var (
greenHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
yellowHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
dimText = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
)

// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------

var checkCmd = &cobra.Command{
Use: "check",
Short: "Validate config and preview included/excluded files",
Long: "Load the esync configuration, walk the local directory, and show which files would be included or excluded by the ignore patterns.",
RunE: runCheck,
}

func init() {
rootCmd.AddCommand(checkCmd)
}

// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------

func runCheck(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
return printPreview(cfg)
}

// ---------------------------------------------------------------------------
// Shared: loadConfig
// ---------------------------------------------------------------------------

// loadConfig loads configuration from the -c flag or auto-detects it.
func loadConfig() (*config.Config, error) {
path := cfgFile
if path == "" {
path = config.FindConfigFile()
}
if path == "" {
return nil, fmt.Errorf("no config file found; use -c to specify one, or run `esync init`")
}
cfg, err := config.Load(path)
if err != nil {
return nil, fmt.Errorf("loading config %s: %w", path, err)
}
return cfg, nil
}

// ---------------------------------------------------------------------------
// Shared: printPreview
// ---------------------------------------------------------------------------

// fileEntry records a file path and (for excluded files) the rule that matched.
type fileEntry struct {
path string
rule string
}

// printPreview walks the local directory and displays included/excluded files.
func printPreview(cfg *config.Config) error {
localDir := cfg.Sync.Local
patterns := cfg.AllIgnorePatterns()

var included []fileEntry
var excluded []fileEntry
var includedSize int64

err := filepath.Walk(localDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable entries
}

rel, err := filepath.Rel(localDir, path)
if err != nil {
return nil
}

// Skip the root directory itself
if rel == "." {
return nil
}

// Check against ignore patterns
for _, pattern := range patterns {
if matchesIgnorePattern(rel, info, pattern) {
excluded = append(excluded, fileEntry{path: rel, rule: pattern})
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}

if !info.IsDir() {
included = append(included, fileEntry{path: rel})
includedSize += info.Size()
}
return nil
})
if err != nil {
return fmt.Errorf("walking %s: %w", localDir, err)
}

// --- Config summary ---
fmt.Println()
fmt.Printf(" Local: %s\n", cfg.Sync.Local)
fmt.Printf(" Remote: %s\n", cfg.Sync.Remote)
fmt.Println()

// --- Included files ---
fmt.Println(greenHeader.Render(" Included files:"))
limit := 10
for i, f := range included {
if i >= limit {
fmt.Printf(" ... %d more files\n", len(included)-limit)
break
}
fmt.Printf(" %s\n", f.path)
}
if len(included) == 0 {
fmt.Println(" (none)")
}
fmt.Println()

// --- Excluded files ---
fmt.Println(yellowHeader.Render(" Excluded files:"))
for i, f := range excluded {
if i >= limit {
fmt.Printf(" ... %d more excluded\n", len(excluded)-limit)
break
}
fmt.Printf(" %-40s %s\n", f.path, dimText.Render("← "+f.rule))
}
if len(excluded) == 0 {
fmt.Println(" (none)")
}
fmt.Println()

// --- Totals ---
totals := fmt.Sprintf(" %d files included (%s) | %d excluded",
len(included), formatSize(includedSize), len(excluded))
fmt.Println(dimText.Render(totals))
fmt.Println()

return nil
}

// ---------------------------------------------------------------------------
// Pattern matching
// ---------------------------------------------------------------------------

// matchesIgnorePattern checks whether a file (given its relative path and
// file info) matches a single ignore pattern. It handles bracket/quote
// stripping, ** prefixes, and directory-specific patterns.
func matchesIgnorePattern(rel string, info os.FileInfo, pattern string) bool {
// Strip surrounding quotes and brackets
pattern = strings.Trim(pattern, `"'`)
pattern = strings.Trim(pattern, "[]")
pattern = strings.TrimSpace(pattern)

if pattern == "" {
return false
}

// Check if this is a directory-only pattern (ends with /)
dirOnly := strings.HasSuffix(pattern, "/")
cleanPattern := strings.TrimSuffix(pattern, "/")

// Strip **/ prefix for simpler matching
cleanPattern = strings.TrimPrefix(cleanPattern, "**/")

if dirOnly && !info.IsDir() {
return false
}

baseName := filepath.Base(rel)

// Match against base name
if matched, _ := filepath.Match(cleanPattern, baseName); matched {
return true
}

// Match against full relative path
if matched, _ := filepath.Match(cleanPattern, rel); matched {
return true
}

// For directory patterns, also try matching directory components
if info.IsDir() {
if matched, _ := filepath.Match(cleanPattern, baseName); matched {
return true
}
}

return false
}
Loading