diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..62b900e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + build: + name: build & vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: go build + run: go build ./... + + - name: go vet + run: go vet ./... + + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.12 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bb562f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Go build artifacts +/grsync +/grsync.exe +*.exe +*.test +*.out + +# IDE / editor +.vscode/ +.idea/ +*.swp diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..9dc5736 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,22 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: standard + enable: + - errcheck + - govet + - staticcheck + - unused + - ineffassign + - revive + - misspell + - unconvert + - gocritic + +formatters: + enable: + - gofmt + - goimports diff --git a/README.md b/README.md new file mode 100644 index 0000000..155ea32 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# grsync + +An rsync-inspired file synchronization tool written in Go. + +## Status + +**CLI skeleton only — no sync logic yet.** The command-line interface parses +arguments and flags and prints what it received, but no files are actually +read, compared, transferred, or deleted. `internal/sync` and +`internal/transport` are currently empty packages reserved for that work. + +## Build + +Requires Go (see [go.mod](go.mod) for the version this module targets). + +```sh +go build ./cmd/grsync +``` + +This produces a `grsync` (or `grsync.exe` on Windows) binary in the current +directory. To run without building a binary: + +```sh +go run ./cmd/grsync ... [flags] +``` + +## Usage + +``` +grsync ... [flags] +``` + +At least one `source` and exactly one `destination` are required +(the last positional argument is always the destination), matching rsync's +own `SRC... DEST` grammar rather than restricting to a single source. + +| Flag | Shorthand | Description | +|---|---|---| +| `--archive` | `-a` | archive mode (equivalent to common rsync defaults) | +| `--verbose` | `-v` | increase output verbosity | +| `--compress` | `-z` | compress file data during transfer | +| `--recursive` | `-r` | recurse into directories | +| `--dry-run` | `-n` | show what would be transferred without transferring | +| `--delete` | | delete extraneous files from destination | +| `--progress` | | show progress during transfer | +| `--exclude PATTERN` | | exclude files matching PATTERN (repeatable) | +| `--include PATTERN` | | include files matching PATTERN (repeatable) | +| `--filter RULE` | | add a file-filtering RULE (repeatable) | + +`--exclude`, `--include`, and `--filter` are collected into a single ordered +rule list, not three independent lists — their relative order on the command +line is preserved regardless of which of the three flags produced each rule. +This matches rsync's first-match-wins filter semantics, where rule order is +significant. + +At this stage, running the command only echoes the parsed values back — it +does not transfer any files: + +```sh +$ go run ./cmd/grsync ./src1 ./src2 ./dst -av --exclude "*.log" --include "keep.log" +sources: [./src1 ./src2] +destination: ./dst +archive: true +verbose: true +... +filters: exclude:*.log, include:keep.log +``` + +## Architecture + +grsync follows the same conceptual split as upstream +[rsync](https://rsync.samba.org/): a **sync layer** that decides *what* needs +to change (comparing source and destination file trees, applying +include/exclude/filter rules) and a **transport layer** that handles *how* +data actually moves (local copies today, with room for remote transports +later). The codebase mirrors that split: + +- `cmd/grsync` — CLI entrypoint (binary wiring only). +- `internal/cli` — argument/flag parsing (built on + [spf13/cobra](https://github.com/spf13/cobra)); owns no sync or transport + logic. +- `internal/sync` — (placeholder) will own comparison and change-planning + logic, analogous to rsync's file-list generation and delta algorithm. +- `internal/transport` — (placeholder) will own how bytes are actually moved + between source and destination. + +grsync's goal is full feature parity with upstream rsync, not just +similarity of CLI flags — including protocol- and format-level +interoperability with the C implementation where specified (for example, +batch mode's `--write-batch`/`--read-batch` file format is required to be +interoperable with the C rsync implementation). None of that is implemented +yet: the wire protocol, delta-transfer algorithm, and batch file format are +still to come in later tickets. This README will be updated as each piece +lands. + +## License + +[GNU General Public License v3.0](LICENSE) diff --git a/cmd/grsync/main.go b/cmd/grsync/main.go new file mode 100644 index 0000000..7210ee0 --- /dev/null +++ b/cmd/grsync/main.go @@ -0,0 +1,18 @@ +// Command grsync is the CLI entrypoint. It is intentionally thin: all +// command/flag definitions live in internal/cli so they stay testable +// without invoking a real process. +package main + +import ( + "fmt" + "os" + + "github.com/syntaxroot-cc/grsync/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5791822 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/syntaxroot-cc/grsync + +go 1.26.5 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..3ffcf5c --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,150 @@ +// Package cli defines the grsync command-line interface: argument parsing, +// flags, and the command tree. It does not perform any sync or transport +// logic itself — it only collects options and hands them off (see the +// options struct printed in Run below, which will later be passed to +// internal/sync). +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// FilterRuleType identifies which kind of rule a FilterRule represents. +type FilterRuleType string + +// The three rule kinds grsync's flags can produce. Kept as their own type +// (rather than a bare string) so callers can't accidentally pass an +// arbitrary value through. +const ( + FilterRuleInclude FilterRuleType = "include" + FilterRuleExclude FilterRuleType = "exclude" + FilterRuleFilter FilterRuleType = "filter" +) + +// FilterRule is a single --include/--exclude/--filter rule. rsync treats +// these three flags as one ordered, first-match-wins rule list rather than +// three independent lists, so grsync collects them the same way: Type +// records which flag produced the rule, and relative order across *all* +// three flags is preserved in the order the user supplied them. +type FilterRule struct { + Type FilterRuleType + Pattern string +} + +// options holds every flag value parsed from the command line. Keeping them +// in one struct (rather than loose variables) makes it straightforward to +// pass a single value into internal/sync once that package exists. +type options struct { + archive bool + verbose bool + compress bool + recursive bool + dryRun bool + delete bool + progress bool + filterRules []FilterRule +} + +// filterRuleFlag implements pflag.Value. Each of --exclude/--include/--filter +// gets its own instance, fixed to a single FilterRuleType, but all three +// share the same backing slice — so pflag's normal "call Set once per +// occurrence" behavior naturally builds one ordered rule list regardless of +// which of the three flag names was used at each position. +type filterRuleFlag struct { + ruleType FilterRuleType + rules *[]FilterRule +} + +func (f *filterRuleFlag) String() string { return "" } + +func (f *filterRuleFlag) Set(pattern string) error { + *f.rules = append(*f.rules, FilterRule{Type: f.ruleType, Pattern: pattern}) + return nil +} + +func (f *filterRuleFlag) Type() string { + if f.ruleType == FilterRuleFilter { + return "rule" + } + return "pattern" +} + +// NewRootCmd builds the root grsync command. It is exported as a +// constructor (rather than a package-level var) so tests can create fresh +// instances without shared state. +func NewRootCmd() *cobra.Command { + opts := &options{} + + cmd := &cobra.Command{ + Use: "grsync ... ", + Short: "grsync synchronizes files between one or more sources and a destination", + Long: "grsync is an rsync-inspired file synchronization tool.\n" + + "At this stage it only parses arguments and flags; no files are copied yet.", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + sources, destination := args[:len(args)-1], args[len(args)-1] + return run(cmd, sources, destination, opts) + }, + } + + flags := cmd.Flags() + flags.BoolVarP(&opts.archive, "archive", "a", false, "archive mode (equivalent to common rsync defaults)") + flags.BoolVarP(&opts.verbose, "verbose", "v", false, "increase output verbosity") + flags.BoolVarP(&opts.compress, "compress", "z", false, "compress file data during transfer") + flags.BoolVarP(&opts.recursive, "recursive", "r", false, "recurse into directories") + flags.BoolVarP(&opts.dryRun, "dry-run", "n", false, "show what would be transferred without transferring") + flags.BoolVar(&opts.delete, "delete", false, "delete extraneous files from destination") + flags.BoolVar(&opts.progress, "progress", false, "show progress during transfer") + flags.Var(&filterRuleFlag{ruleType: FilterRuleExclude, rules: &opts.filterRules}, + "exclude", "exclude files matching PATTERN (repeatable, order preserved relative to --include/--filter)") + flags.Var(&filterRuleFlag{ruleType: FilterRuleInclude, rules: &opts.filterRules}, + "include", "include files matching PATTERN (repeatable, order preserved relative to --exclude/--filter)") + flags.Var(&filterRuleFlag{ruleType: FilterRuleFilter, rules: &opts.filterRules}, + "filter", "add a file-filtering RULE (repeatable, order preserved relative to --exclude/--include)") + + return cmd +} + +// run is the placeholder command body. It only echoes back what was parsed +// so the flag wiring can be verified end to end; the actual sync/transport +// work lands in later tickets. +func run(cmd *cobra.Command, sources []string, destination string, opts *options) error { + var rules strings.Builder + if len(opts.filterRules) == 0 { + rules.WriteString("[]") + } else { + for i, r := range opts.filterRules { + if i > 0 { + rules.WriteString(", ") + } + fmt.Fprintf(&rules, "%s:%s", r.Type, r.Pattern) + } + } + + summary := fmt.Sprintf( + "sources: %v\n"+ + "destination: %s\n"+ + "archive: %t\n"+ + "verbose: %t\n"+ + "compress: %t\n"+ + "recursive: %t\n"+ + "dry-run: %t\n"+ + "delete: %t\n"+ + "progress: %t\n"+ + "filters: %s\n", + sources, destination, + opts.archive, opts.verbose, opts.compress, opts.recursive, opts.dryRun, + opts.delete, opts.progress, rules.String(), + ) + + _, err := fmt.Fprint(cmd.OutOrStdout(), summary) + return err +} + +// Execute runs the root command using os.Args, as called from main(). +func Execute() error { + return NewRootCmd().Execute() +} diff --git a/internal/sync/.gitkeep b/internal/sync/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/transport/.gitkeep b/internal/transport/.gitkeep new file mode 100644 index 0000000..e69de29