From bcfeb945b751a0b472048fc882fb62b87f73a947 Mon Sep 17 00:00:00 2001
From: Mateusz Hawrus <48822818+nieomylnieja@users.noreply.github.com>
Date: Tue, 30 Jul 2024 23:04:32 +0200
Subject: [PATCH] feat: Improve ignore rules (#5)

Currently there's no way to define precise ignore rules, like `regex`
rule for specific repository and file.
This PR refactors these rules in order to make them more flexible.
---
 README.md                      | 16 +++++---
 internal/config/config.go      | 50 +++++++++++++----------
 internal/config/config_test.go | 42 +++++++++++++++++++
 internal/gitsync/gitsync.go    | 73 ++++++++++++++++++++++++++--------
 4 files changed, 138 insertions(+), 43 deletions(-)
 create mode 100644 internal/config/config_test.go

diff --git a/README.md b/README.md
index 1d8d92c..9ebe8e2 100644
--- a/README.md
+++ b/README.md
@@ -115,9 +115,16 @@ The config file is a JSON file which describes the synchronization process.
   },
   // Optional.
   "ignore": [
+    // If neither 'repositoryName' nor 'fileName' is provided,
+    // the rule will apply globally.
+    // If both are provided, the rule will apply only to the specific repository file.
     {
+      // Optional. Name of the repository to which the ignore rule applies.
+      "repositoryName": "go-libyear",
+      // Optional. Name of the file to which the ignore rule applies.
+      "fileName": "golangci linter config",
       // Optional. Regular expression used to ignore matching hunks.
-      "regex": "^\\s*local-prefixes:",
+      "regex": "^\\s*local-prefixes:"
     },
     {
       // Optional. Hunk to be ignored is represented with lines header and changes list.
@@ -141,14 +148,11 @@ The config file is a JSON file which describes the synchronization process.
       // Required. URL used to clone the repository.
       "url": "https://github.com/nieomylnieja/go-libyear.git",
       // Optional. Default: "origin/main".
-      "ref": "dev-branch",
-      // Optional, merged with global 'ignore' section.
-      // Follows the same format and rules but applies ONLY to the specified repository.
-      "ignore": [],
+      "ref": "dev-branch"
     },
     {
       "name": "sword-to-obsidian",
-      "url": "https://github.com/nieomylnieja/sword-to-obsidian.git",
+      "url": "https://github.com/nieomylnieja/sword-to-obsidian.git"
     }
   ],
   // Required. At least one file must be provided.
diff --git a/internal/config/config.go b/internal/config/config.go
index caba3eb..8c5a75f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -14,11 +14,11 @@ import (
 const defaultRef = "origin/main"
 
 type Config struct {
-	StorePath    string              `json:"storePath,omitempty"`
-	Root         *RepositoryConfig   `json:"root"`
-	Ignore       []*IgnoreConfig     `json:"ignore,omitempty"`
-	Repositories []*RepositoryConfig `json:"syncRepositories"`
-	SyncFiles    []*FileConfig       `json:"syncFiles"`
+	StorePath    string        `json:"storePath,omitempty"`
+	Root         *Repository   `json:"root"`
+	Ignore       []*IgnoreRule `json:"ignore,omitempty"`
+	Repositories []*Repository `json:"syncRepositories"`
+	SyncFiles    []*File       `json:"syncFiles"`
 
 	path              string
 	resolvedStorePath string
@@ -32,35 +32,36 @@ func (c *Config) GetStorePath() string {
 	return c.resolvedStorePath
 }
 
-type RepositoryConfig struct {
-	Name   string          `json:"name"`
-	URL    string          `json:"url"`
-	Ref    string          `json:"ref,omitempty"`
-	Ignore []*IgnoreConfig `json:"ignore,omitempty"`
+type Repository struct {
+	Name string `json:"name"`
+	URL  string `json:"url"`
+	Ref  string `json:"ref,omitempty"`
 
 	path       string
 	defaultRef string
 }
 
-func (r *RepositoryConfig) GetPath() string {
+func (r *Repository) GetPath() string {
 	return r.path
 }
 
-func (r *RepositoryConfig) GetRef() string {
+func (r *Repository) GetRef() string {
 	if r.Ref != "" {
 		return r.Ref
 	}
 	return r.defaultRef
 }
 
-type FileConfig struct {
+type File struct {
 	Name string `json:"name"`
 	Path string `json:"path"`
 }
 
-type IgnoreConfig struct {
-	Regex *string    `json:"regex,omitempty"`
-	Hunk  *diff.Hunk `json:"hunk,omitempty"`
+type IgnoreRule struct {
+	RepositoryName *string    `json:"fileName,omitempty"`
+	FileName       *string    `json:"repositoryName,omitempty"`
+	Regex          *string    `json:"regex,omitempty"`
+	Hunk           *diff.Hunk `json:"hunk,omitempty"`
 }
 
 func ReadConfig(configPath string) (*Config, error) {
@@ -123,9 +124,6 @@ func (c *Config) setDefaults() error {
 		}
 	}
 	c.Root.path = filepath.Join(c.GetStorePath(), c.Root.Name)
-	if c.Root.Ignore != nil {
-		return errors.New("root repository cannot have ignore rules")
-	}
 	if c.Root.Ref == "" {
 		c.Root.defaultRef = defaultRef
 	}
@@ -158,10 +156,15 @@ func (c *Config) validate() error {
 			}
 		}
 	}
+	for _, ignore := range c.Ignore {
+		if err := ignore.validate(); err != nil {
+			return errors.Wrap(err, "ignore rule validation failed")
+		}
+	}
 	return nil
 }
 
-func (f *FileConfig) validate() error {
+func (f *File) validate() error {
 	if f.Name == "" {
 		return errors.New("file name is required")
 	}
@@ -170,3 +173,10 @@ func (f *FileConfig) validate() error {
 	}
 	return nil
 }
+
+func (i *IgnoreRule) validate() error {
+	if i.Regex == nil && i.Hunk == nil {
+		return errors.New("either 'regex' or 'hunk' needs to be defined")
+	}
+	return nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..e3e82e3
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,42 @@
+package config
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestReadmeExample(t *testing.T) {
+	data, err := os.ReadFile("../../README.md")
+	if err != nil {
+		t.Fatal(err, "failed to read README.md")
+	}
+	scan := bufio.NewScanner(bytes.NewReader(data))
+	readJSON := false
+	jsonBuilder := bytes.Buffer{}
+	for scan.Scan() {
+		line := strings.TrimSpace(scan.Text())
+		switch {
+		case line == "```json5":
+			readJSON = true
+		case line == "```":
+			readJSON = false
+		case readJSON && !strings.HasPrefix(line, "//"):
+			jsonBuilder.WriteString(line)
+			jsonBuilder.WriteString("\n")
+		}
+	}
+	if err = scan.Err(); err != nil {
+		t.Fatal(err, "failed to scan README.md contents")
+	}
+	var config Config
+	if err = json.Unmarshal(jsonBuilder.Bytes(), &config); err != nil {
+		t.Fatal(err, "failed to unmarshal JSON config")
+	}
+	if err = config.validate(); err != nil {
+		t.Fatal(err, "config validation failed")
+	}
+}
diff --git a/internal/gitsync/gitsync.go b/internal/gitsync/gitsync.go
index 3eb2306..cefb516 100644
--- a/internal/gitsync/gitsync.go
+++ b/internal/gitsync/gitsync.go
@@ -52,7 +52,7 @@ func Run(conf *config.Config, command Command) error {
 			}
 		}
 	}
-	updatedFiles := make(map[*config.RepositoryConfig][]string, len(conf.Repositories))
+	updatedFiles := make(map[*config.Repository][]string, len(conf.Repositories))
 	for _, file := range conf.SyncFiles {
 		rootFilePath := filepath.Join(conf.GetStorePath(), conf.Root.Name, file.Path)
 		for _, syncedRepo := range conf.Repositories {
@@ -90,16 +90,18 @@ func Run(conf *config.Config, command Command) error {
 func syncRepoFile(
 	conf *config.Config,
 	command Command,
-	syncedRepo *config.RepositoryConfig,
-	file *config.FileConfig,
+	syncedRepo *config.Repository,
+	file *config.File,
 	rootFilePath string,
 ) (bool, error) {
 	syncedRepoFilePath := filepath.Join(conf.GetStorePath(), syncedRepo.Name, file.Path)
-	var regexes []string
-	for _, ignore := range append(syncedRepo.Ignore, conf.Ignore...) {
-		if ignore.Regex != nil {
-			regexes = append(regexes, *ignore.Regex)
-		}
+	regexes := make([]string, 0)
+	for _, ignore := range getIgnoreRules(conf, ignoreRulesQuery{
+		RepoName: syncedRepo.Name,
+		FileName: file.Path,
+		Regex:    true,
+	}) {
+		regexes = append(regexes, *ignore.Regex)
 	}
 	args := []string{
 		"-U", "0",
@@ -131,8 +133,12 @@ func syncRepoFile(
 	resultHunks := make([]diff.Hunk, 0, len(unifiedFmt.Hunks))
 hunkLoop:
 	for i, hunk := range unifiedFmt.Hunks {
-		for _, ignore := range append(syncedRepo.Ignore, conf.Ignore...) {
-			if ignore.Hunk != nil && ignore.Hunk.Equal(hunk) {
+		for _, ignore := range getIgnoreRules(conf, ignoreRulesQuery{
+			RepoName: syncedRepo.Name,
+			FileName: file.Path,
+			Hunk:     true,
+		}) {
+			if ignore.Hunk.Equal(hunk) {
 				continue hunkLoop
 			}
 		}
@@ -155,7 +161,11 @@ hunkLoop:
 			case "i":
 				// Copy loop variable.
 				hunk := hunk
-				syncedRepo.Ignore = append(syncedRepo.Ignore, &config.IgnoreConfig{Hunk: &hunk})
+				conf.Ignore = append(conf.Ignore, &config.IgnoreRule{
+					RepositoryName: &syncedRepo.Name,
+					FileName:       &file.Name,
+					Hunk:           &hunk,
+				})
 			default:
 				fmt.Println("Invalid input. Please enter Y (all), y (yes), n (no), i (ignore), or h (help).")
 				fmt.Print(promptMessage)
@@ -207,7 +217,7 @@ type commitDetails struct {
 	Body  string
 }
 
-func commitChanges(root, repo *config.RepositoryConfig, changedFiles []string) (*commitDetails, error) {
+func commitChanges(root, repo *config.Repository, changedFiles []string) (*commitDetails, error) {
 	path := repo.GetPath()
 	fmt.Printf("%s: adding changes to the index\n", repo.Name)
 	if _, err := execCmd(
@@ -242,7 +252,7 @@ func commitChanges(root, repo *config.RepositoryConfig, changedFiles []string) (
 	}, nil
 }
 
-func pushChanges(repo *config.RepositoryConfig) error {
+func pushChanges(repo *config.Repository) error {
 	path := repo.GetPath()
 	fmt.Printf("%s: pushing changes to remote\n", repo.Name)
 	if _, err := execCmd(
@@ -264,7 +274,7 @@ type ghPullRequest struct {
 	URL   string `json:"url"`
 }
 
-func openPullRequest(repo *config.RepositoryConfig, commit *commitDetails) error {
+func openPullRequest(repo *config.Repository, commit *commitDetails) error {
 	ref := repo.GetRef()
 	u, err := url.Parse(repo.URL)
 	if err != nil {
@@ -323,7 +333,7 @@ func openPullRequest(repo *config.RepositoryConfig, commit *commitDetails) error
 	return nil
 }
 
-func cloneRepo(repo *config.RepositoryConfig) error {
+func cloneRepo(repo *config.Repository) error {
 	path := repo.GetPath()
 	if info, err := os.Stat(path); err == nil && info.IsDir() {
 		return nil
@@ -341,7 +351,7 @@ func cloneRepo(repo *config.RepositoryConfig) error {
 	return nil
 }
 
-func updateTrackedRef(repo *config.RepositoryConfig) error {
+func updateTrackedRef(repo *config.Repository) error {
 	path := repo.GetPath()
 	ref := repo.GetRef()
 	fmt.Printf("%s: updating repository ref (%s)\n", repo.Name, ref)
@@ -375,7 +385,7 @@ func updateTrackedRef(repo *config.RepositoryConfig) error {
 	return nil
 }
 
-func checkoutSyncBranch(repo *config.RepositoryConfig) error {
+func checkoutSyncBranch(repo *config.Repository) error {
 	path := repo.GetPath()
 	ref := repo.GetRef()
 	fmt.Printf("%s: checking out %s branch\n", repo.Name, gitsyncUpdateBranch)
@@ -413,3 +423,32 @@ func checkDependencies() error {
 	}
 	return nil
 }
+
+type ignoreRulesQuery struct {
+	RepoName string
+	FileName string
+	Hunk     bool
+	Regex    bool
+}
+
+func getIgnoreRules(conf *config.Config, query ignoreRulesQuery) []*config.IgnoreRule {
+	if len(conf.Ignore) == 0 {
+		return nil
+	}
+	rules := make([]*config.IgnoreRule, 0)
+	for _, ignore := range conf.Ignore {
+		if ignore.RepositoryName != nil && *ignore.RepositoryName != query.RepoName {
+			continue
+		}
+		if ignore.FileName != nil && *ignore.FileName != query.FileName {
+			continue
+		}
+		if query.Hunk && ignore.Hunk != nil {
+			rules = append(rules, ignore)
+		}
+		if query.Regex && ignore.Regex != nil {
+			rules = append(rules, ignore)
+		}
+	}
+	return rules
+}