diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 77f4dba..83b1d84 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -95,14 +95,9 @@ func RegisterCallbacks(api *api.Api) { return nil, api.SetCorsProxy(args[0].String()) }) }), - "pullAll": js.FuncOf(func(this js.Value, args []js.Value) any { + "syncAll": js.FuncOf(func(this js.Value, args []js.Value) any { return wrapPromise(func() (any, error) { - return nil, api.PullAll() - }) - }), - "pushAll": js.FuncOf(func(this js.Value, args []js.Value) any { - return wrapPromise(func() (any, error) { - return nil, api.PushAll() + return nil, api.SyncAll() }) }), "exportZip": js.FuncOf(func(this js.Value, args []js.Value) any { diff --git a/e2e/events_basic_test.go b/e2e/events_basic_test.go index 40c9e6c..a36e6ba 100644 --- a/e2e/events_basic_test.go +++ b/e2e/events_basic_test.go @@ -155,6 +155,7 @@ func TestAddEventAndGetEvent(t *testing.T) { if err != nil { t.Fatalf("failed to get an event by id: %v", err) } + eventIn.UpdatedAt = eventOut.UpdatedAt if !reflect.DeepEqual(eventIn, *eventOut) { t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index 7e41c6a..1d17a5e 100644 --- a/e2e/events_repeating_test.go +++ b/e2e/events_repeating_test.go @@ -49,6 +49,8 @@ func TestAddInfinitelyRepeatingEventAndGetEvents(t *testing.T) { if err != nil { t.Fatalf("failed to get an event by id: %v", err) } + eventIn.UpdatedAt = eventOut.UpdatedAt + if !reflect.DeepEqual(eventIn, *eventOut) { t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) } @@ -96,6 +98,8 @@ func TestAddCountRepeatingEventAndGetEvents(t *testing.T) { if err != nil { t.Fatalf("failed to get an event by id: %v", err) } + eventIn.UpdatedAt = eventOut.UpdatedAt + if !reflect.DeepEqual(eventIn, *eventOut) { t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) } @@ -144,6 +148,8 @@ func TestAddRepeatingEventsAndRemoveRepeatingEvent(t *testing.T) { if err != nil { t.Fatalf("failed to get an event by id: %v", err) } + eventIn.UpdatedAt = eventOut.UpdatedAt + if !reflect.DeepEqual(eventIn, *eventOut) { t.Errorf("events are not the same: \nin: %+v\n!=\nout: %+v", eventIn, *eventOut) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 3e19fc9..5e8a361 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -47,8 +47,7 @@ func (a *Api) RenameCalendar(oldName, newName string) error { func (a *Api) LoadCalendars() error { return a.inner.LoadCalendars() } func (a *Api) SetCorsProxy(proxyUrl string) error { return a.inner.SetCorsProxy(proxyUrl) } -func (a *Api) PullAll() error { return a.inner.PullAll() } -func (a *Api) PushAll() error { return a.inner.PushAll() } +func (a *Api) SyncAll() error { return a.inner.SyncAll() } func (a *Api) ExportZip(calendar string) ([]byte, error) { return a.inner.ExportZip(calendar) } // ------------------------------ Wrapper methods encoding and decoding JSONs ------------------------------ diff --git a/pkg/core/constants.go b/pkg/core/constants.go index c4980ab..1a00ff1 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -7,6 +7,8 @@ const ( EventsDirName string = "events" GitAuthorName string = "git-calendar" + GitRemoteName string = "origin" + GitBranchName string = "main" ) // ------- Repeating frequency ------- diff --git a/pkg/core/core.go b/pkg/core/core.go index 3a52db2..60f471e 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -9,6 +9,7 @@ import ( "github.com/git-calendar/core/pkg/export" "github.com/git-calendar/core/pkg/filesystem" + "github.com/git-calendar/core/pkg/gitmerge" "github.com/go-git/go-billy/v5" gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/cache" @@ -50,87 +51,64 @@ func (c *Core) SetCorsProxy(proxyUrl string) error { return err } -// Update all remotes for all repositories. -func (c *Core) PushAll() error { - var errs error +// SyncAll tries to synchronize all calendars with its remotes. +func (c *Core) SyncAll() error { + var resultErr error + for _, cal := range c.calendars { - remotes, err := cal.repository.Remotes() - if err != nil { - errs = errors.Join(err) + if cal == nil || cal.repository == nil { + continue // important to check here; syncCalendar and other do assume this } - for _, remote := range remotes { - err = remote.Push(&gogit.PushOptions{}) - if err == gogit.NoErrAlreadyUpToDate { - continue // this is ok - } - if err != nil { - errs = errors.Join(err) - } + if err := c.syncCalendar(cal); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("%q: sync failed: %w", cal.Name, err)) } } - return errs -} -func (c *Core) PullAll() error { - var resultErr error + if err := c.LoadCalendars(); err != nil { // reload events from disk + resultErr = errors.Join(resultErr, err) + } - for _, cal := range c.calendars { - if cal == nil || cal.repository == nil { - continue + return resultErr +} + +// syncCalendar assumes the worktree is clean and all local calendar changes have already been committed. +func (c *Core) syncCalendar(cal *calendar) error { + if err := fetchCalendar(cal, c.proxyUrl); err != nil { + if errors.Is(err, gogit.ErrRemoteNotFound) { + return nil // this is ok } + return err + } - fmt.Println("pulling", cal.Name) + localCommit, remoteCommit, err := gitmerge.GetCommits(cal.repository, GitBranchName, GitRemoteName) + if err != nil { + return err + } - wt, err := cal.repository.Worktree() - if err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("%q: get worktree: %w", cal.Name, err)) - continue - } - if wt == nil { - continue - } + switch { + case localCommit.Hash == remoteCommit.Hash: + return nil // already in sync - remote, err := cal.repository.Remote("origin") - if err != nil { - if errors.Is(err, gogit.ErrRemoteNotFound) { - continue // this is ok - } - resultErr = errors.Join(resultErr, fmt.Errorf("%q: get remote: %w", cal.Name, err)) - continue - } + case isAncestor(localCommit, remoteCommit): + // remote is ahead + return fastForwardCalendar(cal, remoteCommit.Hash) - cfg := remote.Config() - if cfg == nil { - resultErr = errors.Join(resultErr, fmt.Errorf("%q: remote has no config", cal.Name)) - continue - } - if len(cfg.URLs) != 1 || cfg.URLs[0] == "" { - resultErr = errors.Join(resultErr, fmt.Errorf("%q: remote must have exactly one non-empty URL", cal.Name)) - continue - } - repoURL, err := url.Parse(cfg.URLs[0]) - if err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("%q: parse remote URL: %w", cal.Name, err)) - continue - } + case isAncestor(remoteCommit, localCommit): + // local is ahead + return pushCalendar(cal, c.proxyUrl) - finalURL, auth := prepareRepoUrl(repoURL, c.proxyUrl) - err = wt.Pull(&gogit.PullOptions{ - RemoteName: "origin", - RemoteURL: finalURL.String(), - Auth: auth, - }) - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - continue // this is ok - } - if err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("%q: pull from origin failed: %w", cal.Name, err)) - continue + default: + // cant simply push or pull (history diverged) -> try merge + + fmt.Printf("Diverged history detected on %q, trying to merge...\n", cal.Name) + if err := mergeOriginMain(cal.repository); err != nil { + return fmt.Errorf("failed to merge: %w", err) } - } + fmt.Printf("Custom merge successfull for %q\n", cal.Name) - return resultErr + return pushCalendar(cal, c.proxyUrl) + } } func (c *Core) ExportZip(calendar string) ([]byte, error) { diff --git a/pkg/core/core_calendars.go b/pkg/core/core_calendars.go index 59585bb..e44d720 100644 --- a/pkg/core/core_calendars.go +++ b/pkg/core/core_calendars.go @@ -71,7 +71,7 @@ func (c *Core) ListCalendars() ([]CalendarInfo, error) { for _, cal := range calendars { var remoteUrl string if cal.repository != nil { - remote, err := cal.repository.Remote("origin") + remote, err := cal.repository.Remote(GitRemoteName) if err == nil { cfg := remote.Config() if cfg == nil { @@ -208,7 +208,7 @@ func (c *Core) CloneCalendar(repoUrl *url.URL, password string) error { finalUrl, auth := prepareRepoUrl(repoUrl, c.proxyUrl) // clone now newRepo, err := gogit.Clone(storage, repoFS, &gogit.CloneOptions{ - RemoteName: "origin", + RemoteName: GitRemoteName, URL: finalUrl.String(), Auth: auth, }) @@ -300,6 +300,8 @@ func (c *Core) RenameCalendar(oldName, newName string) error { delete(c.calendars, oldName) c.calendars[newName] = calendar + // TODO: handle "calendar" field in its events + return nil } @@ -314,7 +316,7 @@ func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL) error { } if remoteURL == nil { - if err := cal.repository.DeleteRemote("origin"); err != nil { + if err := cal.repository.DeleteRemote(GitRemoteName); err != nil { if errors.Is(err, gogit.ErrRemoteNotFound) { return nil } @@ -328,8 +330,8 @@ func (c *Core) UpdateRemote(calendar string, remoteURL *url.URL) error { } cfg, _ := cal.repository.Config() - cfg.Remotes["origin"] = &config.RemoteConfig{ - Name: "origin", + cfg.Remotes[GitRemoteName] = &config.RemoteConfig{ + Name: GitRemoteName, URLs: []string{remoteURL.String()}, } if err := cal.repository.SetConfig(cfg); err != nil { diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index fb65a2b..d88aaf9 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -424,6 +424,8 @@ func (c *Core) removeCurrentChild(event *Event) error { // Serializes event to JSON, saves to file, stages and commits with given message. func (c *Core) saveAndCommitEvent(event *Event, commitMsg string) error { + event.UpdatedAt = time.Now() // force new time + // -------- write to disk -------- cal, ok := c.calendars[event.Calendar] if !ok { diff --git a/pkg/core/event.go b/pkg/core/event.go index 7b1e670..f13bb1d 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -30,6 +30,7 @@ type Event struct { Tag string `json:"tag,omitzero"` // User-defined category or label. ParentId uuid.UUID `json:"parent_id,omitzero"` // Specific for child events. It is uuid.Nil if the event is basic or parent. Repeat *Repetition `json:"repeat,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` // Used for git conflict resolution; latest wins. } // Repetition defines the recurrence rules for a Parent event. diff --git a/pkg/core/git.go b/pkg/core/git.go new file mode 100644 index 0000000..99b1cd8 --- /dev/null +++ b/pkg/core/git.go @@ -0,0 +1,116 @@ +package core + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "path" + "time" + + "github.com/git-calendar/core/pkg/gitmerge" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func pushCalendar(cal *calendar, proxyUrl *url.URL) error { + fmt.Println("pushing", cal.Name) + + repoUrl, err := repoUrlFromCalendar(cal) + if err != nil { + return err + } + + finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) + return ignoreUpToDate(cal.repository.Push(&gogit.PushOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + })) +} + +func fetchCalendar(cal *calendar, proxyUrl *url.URL) error { + fmt.Println("fetching", cal.Name) + + repoUrl, err := repoUrlFromCalendar(cal) + if err != nil { + return err + } + + finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) + return ignoreUpToDate(cal.repository.Fetch(&gogit.FetchOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + })) +} + +func fastForwardCalendar(cal *calendar, hash plumbing.Hash) error { + fmt.Println("fast-forward", cal.Name) + + wt, err := cal.repository.Worktree() + if err != nil { + return err + } + + if err := wt.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(GitBranchName), + }); err != nil { + return fmt.Errorf("failed to checkout %s: %w", GitBranchName, err) + } + + if err := wt.Reset(&gogit.ResetOptions{ + Commit: hash, + Mode: gogit.HardReset, + }); err != nil { + return fmt.Errorf("failed to fast-forward %s to %s: %w", GitBranchName, hash, err) + } + + return nil +} + +// ignoreUpToDate swallows the benign "already up to date" error from push/fetch. +func ignoreUpToDate(err error) error { + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return nil + } + return err +} + +// isAncestor reports whether a is an ancestor of (or equal to) b. +func isAncestor(a, b *object.Commit) bool { + if a.Hash == b.Hash { + return true + } + ok, err := a.IsAncestor(b) + if err != nil { + fmt.Printf("WARN: isAncestor %s -> %s: %v\n", a.Hash, b.Hash, err) + return false + } + return ok +} + +// mergeOriginMain is an "adapter" for gitmerge.MergeRemoteIntoBranch. +func mergeOriginMain(repo *gogit.Repository) error { + return gitmerge.MergeRemoteIntoBranch(repo, gitmerge.Options{ + BranchName: GitBranchName, + RemoteName: GitRemoteName, + + AuthorName: GitAuthorName, + + IncludePath: func(gitPath string) bool { + return path.Dir(gitPath) == EventsDirName && + path.Ext(gitPath) == ".json" + }, + + UpdatedAt: func(gitPath string, data []byte) (time.Time, error) { + var ev Event + if err := json.Unmarshal(data, &ev); err != nil { + return time.Time{}, fmt.Errorf("%s: failed to parse event JSON: %w", gitPath, err) + } + + return ev.UpdatedAt, nil + }, + }) +} diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 274109b..d826fd8 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -2,11 +2,14 @@ package core import ( "encoding/binary" + "errors" + "fmt" "net/url" "path" "strings" "time" + gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/google/uuid" ) @@ -84,6 +87,30 @@ func prepareRepoUrl(repoUrl *url.URL, proxyUrl *url.URL) (*url.URL, *http.BasicA return &repo, auth } +func repoUrlFromCalendar(cal *calendar) (*url.URL, error) { + remote, err := cal.repository.Remote(GitRemoteName) + if err != nil { + if errors.Is(err, gogit.ErrRemoteNotFound) { + return nil, err // this is ok + } + return nil, fmt.Errorf("%q: failed to get remote: %w", cal.Name, err) + } + + cfg := remote.Config() + if cfg == nil { + return nil, fmt.Errorf("%q: remote has no config", cal.Name) + } + if len(cfg.URLs) != 1 || cfg.URLs[0] == "" { + return nil, fmt.Errorf("%q: remote must have exactly one non-empty URL", cal.Name) + } + repoURL, err := url.Parse(cfg.URLs[0]) + if err != nil { + return nil, fmt.Errorf("%q: failed to parse remote URL: %w", cal.Name, err) + } + + return repoURL, nil +} + // useCorsProxy returns a new URL that routes the original URL through the given CORS proxy. // The full original URL (including scheme) is appended as the path to the proxy. func useCorsProxy(original, proxy *url.URL) *url.URL { diff --git a/pkg/gitmerge/merge.go b/pkg/gitmerge/merge.go new file mode 100644 index 0000000..b7a0376 --- /dev/null +++ b/pkg/gitmerge/merge.go @@ -0,0 +1,323 @@ +package gitmerge + +import ( + "errors" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/util" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// MergeRemoteIntoBranch performs a 3-way last-write-wins merge of origin/main -> main. +func MergeRemoteIntoBranch(repo *gogit.Repository, opts Options) error { + if repo == nil { + return errors.New("repository is nil") + } + if err := opts.validate(); err != nil { + return err + } + + wt, err := ensureBranch(repo, opts.BranchName) + if err != nil { + return err + } + + localCommit, remoteCommit, err := GetCommits(repo, opts.BranchName, opts.RemoteName) + if err != nil { + return err + } + if localCommit.Hash == remoteCommit.Hash { + return nil + } + + baseCommit, err := mergeBase(localCommit, remoteCommit) + if err != nil { + return err + } + + baseTree, err := baseCommit.Tree() + if err != nil { + return fmt.Errorf("load base tree: %w", err) + } + + localTree, err := localCommit.Tree() + if err != nil { + return fmt.Errorf("load local tree: %w", err) + } + + remoteTree, err := remoteCommit.Tree() + if err != nil { + return fmt.Errorf("load remote tree: %w", err) + } + + paths, err := collectPaths(opts.IncludePath, baseTree, localTree, remoteTree) + if err != nil { + return err + } + + for _, p := range paths { + baseVer, err := readFileVersion(baseTree, p, opts.UpdatedAt) + if err != nil { + return err + } + + localVer, err := readFileVersion(localTree, p, opts.UpdatedAt) + if err != nil { + return err + } + + remoteVer, err := readFileVersion(remoteTree, p, opts.UpdatedAt) + if err != nil { + return err + } + + if err := applyLWW(wt, p, baseVer, localVer, remoteVer); err != nil { + return err + } + } + + commitMsg := fmt.Sprintf("Merge '%s/%s' (LWW)", opts.RemoteName, opts.BranchName) + + _, err = wt.Commit(commitMsg, &gogit.CommitOptions{ + Parents: []plumbing.Hash{localCommit.Hash, remoteCommit.Hash}, + Author: &object.Signature{ + Name: opts.AuthorName, + Email: opts.AuthorEmail, + When: time.Now(), + }, + AllowEmptyCommits: true, + }) + if err != nil { + return fmt.Errorf("failed to commit merge: %w", err) + } + + return nil +} + +// applyLWW applies last-write-wins strategy to an event file from three versions (base, local, remote). +func applyLWW(wt *gogit.Worktree, gitPath string, base, local, remote fileVersion) error { + switch { + case base.exists && !remote.exists: // remote deleted -> delete wins + if local.exists { + if _, err := wt.Remove(gitPath); err != nil { + return fmt.Errorf("%s: failed to remove an event which was deleted on remote: %w", gitPath, err) + } + } + + case base.exists && !local.exists: // local deleted -> delete wins; nothing to do + + case !remote.exists: // remote has nothing new; local-only add or both absent + + case !local.exists: // remote added a new file -> take it + return writeFile(wt, gitPath, remote) + + case local.hash == remote.hash: // identical blobs; no-op + + default: // both sides modified -> last write wins + if remote.updatedAt.After(local.updatedAt) { // if same, local wins i guess? + return writeFile(wt, gitPath, remote) + } + } + + return nil +} + +// writeFile writes a remote blob to the worktree filesystem and stages it. +func writeFile(wt *gogit.Worktree, gitPath string, ver fileVersion) error { + fs := wt.Filesystem + localPath := billyPath(fs, gitPath) + + if dir := path.Dir(gitPath); dir != "." { + if err := fs.MkdirAll(billyPath(fs, dir), 0o755); err != nil { + return fmt.Errorf("%s: failed to create parent dir: %w", gitPath, err) + } + } + + if err := util.WriteFile(fs, localPath, ver.data, 0o644); err != nil { + return fmt.Errorf("%s: failed to write file: %w", gitPath, err) + } + + if _, err := wt.Add(gitPath); err != nil { + return fmt.Errorf("%s: failed to stage file: %w", gitPath, err) + } + + return nil +} + +// ------------------------------------ Helpers ------------------------------------ + +// ensureBranch checks out branch if HEAD is elsewhere, then returns the worktree. +func ensureBranch(repo *gogit.Repository, branch string) (*gogit.Worktree, error) { + wt, err := repo.Worktree() + if err != nil { + return nil, err + } + head, err := repo.Head() + if err != nil { + return nil, err + } + ref := plumbing.NewBranchReferenceName(branch) + if head.Name() == ref { + return wt, nil + } + if err := wt.Checkout(&gogit.CheckoutOptions{Branch: ref}); err != nil { + return nil, fmt.Errorf("failed to checkout %s: %w", branch, err) + } + return wt, nil +} + +// GetCommits returns the local and remote HEAD commits for the main branch. +func GetCommits( + repo *gogit.Repository, + branchName string, + remoteName string, +) (local, remote *object.Commit, err error) { + localRef, err := localBranchRef(repo, branchName) + if err != nil { + return nil, nil, err + } + + remoteRef, err := remoteBranchRef(repo, remoteName, branchName) + if err != nil { + return nil, nil, err + } + + local, err = repo.CommitObject(localRef.Hash()) + if err != nil { + return nil, nil, fmt.Errorf("failed to load local commit: %w", err) + } + + remote, err = repo.CommitObject(remoteRef.Hash()) + if err != nil { + return nil, nil, fmt.Errorf("failed to load remote commit: %w", err) + } + + return local, remote, nil +} + +func localBranchRef(repo *gogit.Repository, branchName string) (*plumbing.Reference, error) { + name := plumbing.NewBranchReferenceName(branchName) + + ref, err := repo.Reference(name, true) + if err != nil { + return nil, fmt.Errorf("failed to get local ref %s: %w", name, err) + } + + return ref, nil +} + +func remoteBranchRef( + repo *gogit.Repository, + remoteName string, + branchName string, +) (*plumbing.Reference, error) { + name := plumbing.NewRemoteReferenceName(remoteName, branchName) + + ref, err := repo.Reference(name, true) + if err != nil { + return nil, fmt.Errorf("failed to get remote ref %s: %w", name, err) + } + + return ref, nil +} + +// mergeBase returns the best common ancestor of a and b. +func mergeBase(a, b *object.Commit) (*object.Commit, error) { + bases, err := a.MergeBase(b) + if err != nil { + return nil, fmt.Errorf("merge base %s / %s: %w", a.Hash, b.Hash, err) + } + if len(bases) == 0 { + return nil, fmt.Errorf("no merge base between %s and %s", a.Hash, b.Hash) + } + return bases[0], nil +} + +// ------------------------------------------------------------------------------ + +type fileVersion struct { + exists bool + hash plumbing.Hash + data []byte + updatedAt time.Time +} + +func readFileVersion( + tree *object.Tree, + gitPath string, + updatedAtFunc UpdatedAtFunc, +) (fileVersion, error) { + f, err := tree.File(gitPath) + if errors.Is(err, object.ErrFileNotFound) { + return fileVersion{}, nil + } + if err != nil { + return fileVersion{}, fmt.Errorf("%s: failed to lookup in tree: %w", gitPath, err) + } + + r, err := f.Reader() + if err != nil { + return fileVersion{}, fmt.Errorf("%s: failed to open blob: %w", gitPath, err) + } + defer r.Close() + + data, err := io.ReadAll(r) + if err != nil { + return fileVersion{}, fmt.Errorf("%s: failed to read blob: %w", gitPath, err) + } + + updatedAt, err := updatedAtFunc(gitPath, data) + if err != nil { + return fileVersion{}, err + } + + return fileVersion{ + exists: true, + hash: f.Hash, + data: data, + updatedAt: updatedAt, + }, nil +} + +// collectPaths returns the sorted union of file paths across all trees. +func collectPaths(include IncludePathFunc, trees ...*object.Tree) ([]string, error) { + seen := make(map[string]struct{}) + + for _, tree := range trees { + if tree == nil { + continue + } + + err := tree.Files().ForEach(func(f *object.File) error { + p := path.Clean(f.Name) + if include(p) { + seen[p] = struct{}{} + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to collect paths: %w", err) + } + } + + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + + sort.Strings(paths) + return paths, nil +} + +// billyPath converts a slash-delimited git path to a native filesystem path just to make sure everything is ok. +func billyPath(fs billy.Filesystem, gitPath string) string { + return fs.Join(strings.Split(path.Clean(gitPath), "/")...) +} diff --git a/pkg/gitmerge/merge_test.go b/pkg/gitmerge/merge_test.go new file mode 100644 index 0000000..d3f873f --- /dev/null +++ b/pkg/gitmerge/merge_test.go @@ -0,0 +1,389 @@ +package gitmerge + +import ( + "encoding/json" + "errors" + "io" + "testing" + "time" + + "github.com/go-git/go-billy/v5/util" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// Caution! +// AI generated tests... + +const ( + testBranch = "main" + testRemote = "origin" + testFile = "events/event.json" +) + +type testEvent struct { + UpdatedAt time.Time `json:"updatedAt"` +} + +func TestMergeRemoteIntoBranch_StrategyMatrix(t *testing.T) { + t0 := ts(10) + t1 := ts(11) + t2 := ts(12) + + tests := []struct { + name string + base *time.Time + local *time.Time + remote *time.Time + want *time.Time + }{ + { + name: "local added", + local: t1, + want: t1, + }, + { + name: "remote added", + remote: t1, + want: t1, + }, + { + name: "both deleted", + base: t0, + want: nil, + }, + { + name: "both added, remote newer", + local: t1, + remote: t2, + want: t2, + }, + { + name: "both added, local newer", + local: t2, + remote: t1, + want: t2, + }, + { + name: "both edited, remote newer", + base: t0, + local: t1, + remote: t2, + want: t2, + }, + { + name: "both edited, local newer", + base: t0, + local: t2, + remote: t1, + want: t2, + }, + { + name: "local deleted, remote unchanged", + base: t0, + local: nil, + remote: t0, + want: nil, + }, + { + name: "local unchanged, remote deleted", + base: t0, + local: t0, + remote: nil, + want: nil, + }, + { + name: "local deleted, remote edited", + base: t0, + local: nil, + remote: t1, + want: nil, + }, + { + name: "local edited, remote deleted", + base: t0, + local: t1, + remote: nil, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := makeMergeRepo(t, tt.base, tt.local, tt.remote) + + if err := MergeRemoteIntoBranch(repo, testOptions()); err != nil { + t.Fatalf("MergeRemoteIntoBranch() error = %v", err) + } + + assertHeadEvent(t, repo, tt.want) + }) + } +} + +func TestMergeRemoteIntoBranch_AlreadyUpToDateDoesNotCommit(t *testing.T) { + when := ts(10) + repo := makeMergeRepo(t, when, when, when) + + before := headHash(t, repo) + + if err := MergeRemoteIntoBranch(repo, testOptions()); err != nil { + t.Fatalf("MergeRemoteIntoBranch() error = %v", err) + } + + after := headHash(t, repo) + if after != before { + t.Fatalf("HEAD changed: before=%s after=%s", before, after) + } +} + +func testOptions() Options { + return Options{ + BranchName: testBranch, + RemoteName: testRemote, + AuthorName: "Test", + AuthorEmail: "test@example.com", + + IncludePath: func(p string) bool { + return p == testFile + }, + + UpdatedAt: func(_ string, data []byte) (time.Time, error) { + var ev testEvent + if err := json.Unmarshal(data, &ev); err != nil { + return time.Time{}, err + } + return ev.UpdatedAt, nil + }, + + Now: func() time.Time { + return time.Date(2026, 1, 1, 13, 0, 0, 0, time.UTC) + }, + } +} + +// -------------------------------------- Repo setup --------------------------------------- + +func makeMergeRepo(t *testing.T, base, local, remote *time.Time) *gogit.Repository { + t.Helper() + + repo, err := gogit.PlainInit(t.TempDir(), false) + if err != nil { + t.Fatalf("PlainInit: %v", err) + } + + mainRef := plumbing.NewBranchReferenceName(testBranch) + if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, mainRef)); err != nil { + t.Fatalf("set HEAD: %v", err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("Worktree: %v", err) + } + + if err := util.WriteFile(wt.Filesystem, "root.txt", []byte("root\n"), 0o644); err != nil { + t.Fatalf("write root: %v", err) + } + if _, err := wt.Add("root.txt"); err != nil { + t.Fatalf("add root: %v", err) + } + + applyEvent(t, wt, nil, base) + baseHash := commit(t, wt, "base") + + localHash := baseHash + if !sameTime(base, local) { + applyEvent(t, wt, base, local) + localHash = commit(t, wt, "local") + } + + remoteHash := baseHash + if !sameTime(base, remote) { + tmpRef := plumbing.NewBranchReferenceName("tmp-remote-build") + + if err := repo.Storer.SetReference(plumbing.NewHashReference(tmpRef, baseHash)); err != nil { + t.Fatalf("create tmp remote branch: %v", err) + } + + if err := wt.Checkout(&gogit.CheckoutOptions{ + Branch: tmpRef, + Force: true, + }); err != nil { + t.Fatalf("checkout tmp remote branch: %v", err) + } + + applyEvent(t, wt, base, remote) + remoteHash = commit(t, wt, "remote") + } + + remoteRef := plumbing.NewRemoteReferenceName(testRemote, testBranch) + + if err := repo.Storer.SetReference(plumbing.NewHashReference(remoteRef, remoteHash)); err != nil { + t.Fatalf("set remote ref: %v", err) + } + + if err := repo.Storer.SetReference(plumbing.NewHashReference(mainRef, localHash)); err != nil { + t.Fatalf("set local ref: %v", err) + } + + if err := wt.Checkout(&gogit.CheckoutOptions{ + Branch: mainRef, + Force: true, + }); err != nil { + t.Fatalf("checkout local branch: %v", err) + } + + return repo +} + +func applyEvent(t *testing.T, wt *gogit.Worktree, from, to *time.Time) { + t.Helper() + + switch { + case to == nil: + if from != nil { + if _, err := wt.Remove(testFile); err != nil { + t.Fatalf("remove event: %v", err) + } + } + + case from != nil && to.Equal(*from): + return + + default: + writeEvent(t, wt, *to) + } +} + +func writeEvent(t *testing.T, wt *gogit.Worktree, when time.Time) { + t.Helper() + + data, err := json.Marshal(testEvent{UpdatedAt: when}) + if err != nil { + t.Fatalf("marshal event: %v", err) + } + + if err := wt.Filesystem.MkdirAll("events", 0o755); err != nil { + t.Fatalf("mkdir events: %v", err) + } + + if err := util.WriteFile(wt.Filesystem, testFile, data, 0o644); err != nil { + t.Fatalf("write event: %v", err) + } + + if _, err := wt.Add(testFile); err != nil { + t.Fatalf("add event: %v", err) + } +} + +func commit(t *testing.T, wt *gogit.Worktree, msg string) plumbing.Hash { + t.Helper() + + hash, err := wt.Commit(msg, &gogit.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@example.com", + When: time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC), + }, + }) + if err != nil { + t.Fatalf("commit %q: %v", msg, err) + } + + return hash +} + +// ------------------------------------ Assertions ----------------------------------------- + +func assertHeadEvent(t *testing.T, repo *gogit.Repository, want *time.Time) { + t.Helper() + + tree := headTree(t, repo) + + f, err := tree.File(testFile) + if want == nil { + if errors.Is(err, object.ErrFileNotFound) { + return + } + if err != nil { + t.Fatalf("lookup event: %v", err) + } + t.Fatalf("HEAD has %s, want deleted", f.Name) + } + + if err != nil { + t.Fatalf("lookup event: %v", err) + } + + r, err := f.Reader() + if err != nil { + t.Fatalf("open event blob: %v", err) + } + defer r.Close() + + data, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read event blob: %v", err) + } + + var got testEvent + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + + if !got.UpdatedAt.Equal(*want) { + t.Fatalf( + "UpdatedAt = %s, want %s", + got.UpdatedAt.Format(time.RFC3339Nano), + want.Format(time.RFC3339Nano), + ) + } +} + +func headHash(t *testing.T, repo *gogit.Repository) plumbing.Hash { + t.Helper() + + ref, err := repo.Head() + if err != nil { + t.Fatalf("Head: %v", err) + } + + return ref.Hash() +} + +func headTree(t *testing.T, repo *gogit.Repository) *object.Tree { + t.Helper() + + ref, err := repo.Head() + if err != nil { + t.Fatalf("Head: %v", err) + } + + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + t.Fatalf("CommitObject: %v", err) + } + + tree, err := commit.Tree() + if err != nil { + t.Fatalf("Tree: %v", err) + } + + return tree +} + +func ts(hour int) *time.Time { + t := time.Date(2026, 1, 1, hour, 0, 0, 0, time.UTC) + return &t +} + +func sameTime(a, b *time.Time) bool { + switch { + case a == nil || b == nil: + return a == b + default: + return a.Equal(*b) + } +} diff --git a/pkg/gitmerge/options.go b/pkg/gitmerge/options.go new file mode 100644 index 0000000..1385c5a --- /dev/null +++ b/pkg/gitmerge/options.go @@ -0,0 +1,39 @@ +package gitmerge + +import ( + "errors" + "time" +) + +type UpdatedAtFunc func(gitPath string, data []byte) (time.Time, error) + +type IncludePathFunc func(gitPath string) bool + +type Options struct { + BranchName string + RemoteName string + + AuthorName string + AuthorEmail string + + IncludePath IncludePathFunc + UpdatedAt UpdatedAtFunc + + Now func() time.Time +} + +func (o Options) validate() error { + if o.BranchName == "" { + return errors.New("branch name is required") + } + if o.RemoteName == "" { + return errors.New("remote name is required") + } + if o.IncludePath == nil { + return errors.New("include path function is required") + } + if o.UpdatedAt == nil { + return errors.New("updated-at function is required") + } + return nil +}