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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Go build artifacts
/grsync
/grsync.exe
*.exe
*.test
*.out

# IDE / editor
.vscode/
.idea/
*.swp
22 changes: 22 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <source>... <destination> [flags]
```

## Usage

```
grsync <source>... <destination> [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)
18 changes: 18 additions & 0 deletions cmd/grsync/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
150 changes: 150 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -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 <source>... <destination>",
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()
}
Empty file added internal/sync/.gitkeep
Empty file.
Empty file added internal/transport/.gitkeep
Empty file.