From df0fea9c4b89d8180a708a91ec6b8670cf0205aa Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 10 Jun 2026 10:22:01 +0200 Subject: [PATCH 1/8] basic merging --- pkg/core/constants.go | 2 + pkg/core/core.go | 93 ++++++++++++-------- pkg/core/core_calendars.go | 12 +-- pkg/core/event.go | 1 + pkg/core/merge.go | 168 +++++++++++++++++++++++++++++++++++++ pkg/core/utils.go | 27 ++++++ 6 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 pkg/core/merge.go 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..c5984d0 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -50,30 +50,45 @@ func (c *Core) SetCorsProxy(proxyUrl string) error { return err } -// Update all remotes for all repositories. func (c *Core) PushAll() error { - var errs 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 } - for _, remote := range remotes { - err = remote.Push(&gogit.PushOptions{}) - if err == gogit.NoErrAlreadyUpToDate { + fmt.Println("pushing", cal.Name) + + repoUrl, err := repoUrlFromCalendar(cal) + if err != nil { + if errors.Is(err, gogit.ErrRemoteNotFound) { continue // this is ok } - if err != nil { - errs = errors.Join(err) + resultErr = errors.Join(resultErr, err) + continue + } + + finalUrl, auth := prepareRepoUrl(repoUrl, c.proxyUrl) + err = cal.repository.Push(&gogit.PushOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + }) + if err != nil { + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + continue // this is ok } + resultErr = errors.Join(resultErr, err) } } - return errs + + return resultErr } func (c *Core) PullAll() error { var resultErr error + var needPushAfter bool for _, cal := range c.calendars { if cal == nil || cal.repository == nil { @@ -91,45 +106,53 @@ func (c *Core) PullAll() error { continue } - remote, err := cal.repository.Remote("origin") + repoUrl, err := repoUrlFromCalendar(cal) 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)) + resultErr = errors.Join(resultErr, err) continue } - 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 - } - - finalURL, auth := prepareRepoUrl(repoURL, c.proxyUrl) + finalUrl, auth := prepareRepoUrl(repoUrl, c.proxyUrl) err = wt.Pull(&gogit.PullOptions{ - RemoteName: "origin", - RemoteURL: finalURL.String(), + RemoteName: GitRemoteName, + 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)) + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + continue // good + } + + // histories have diverged -> merge needed + if errors.Is(err, gogit.ErrNonFastForwardUpdate) { + fmt.Printf("Diverged history detected on %q, trying to merge...\n", cal.Name) + + err := customMergeRemote(cal, c.proxyUrl) + if err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("%q: custom merge failed: %w", cal.Name, err)) + continue + } + fmt.Printf("Custom merge successfull\n", cal.Name) + needPushAfter = true + + continue + } + + // some other error happened + resultErr = errors.Join(resultErr, fmt.Errorf("%q: pull from remote failed: %w", cal.Name, err)) continue } } + if needPushAfter { + if err := c.PushAll(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("push to remotes failed: %w", err)) + } + } + return resultErr } 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/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/merge.go b/pkg/core/merge.go new file mode 100644 index 0000000..5c4ac1e --- /dev/null +++ b/pkg/core/merge.go @@ -0,0 +1,168 @@ +package core + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path" + "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" +) + +func customMergeRemote(cal *calendar, proxyUrl *url.URL) error { + if cal == nil { + return errors.New("cal is nil") + } + + wt, err := cal.repository.Worktree() + if err != nil { + return err + } + + // fetch from remote + repoUrl, err := repoUrlFromCalendar(cal) + if err != nil { + return err + } + + finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) + err = cal.repository.Fetch(&gogit.FetchOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + }) + if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return fmt.Errorf("fetch failed: %w", err) + } + + // checkout main branch to be sure + localBranch := plumbing.NewBranchReferenceName(GitBranchName) + head, err := cal.repository.Head() + if err != nil { + return err + } + + // checkout main (just to be sure) + if head.Name() != localBranch { + if err := wt.Checkout(&gogit.CheckoutOptions{ + Branch: localBranch, + }); err != nil { + return fmt.Errorf("checkout %s: %w", localBranch, err) + } + } + + // get local HEAD and remote Ref + localHead, err := cal.repository.Head() + if err != nil { + return err + } + remoteRef, err := cal.repository.Reference(plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName), true) + if err != nil { + return fmt.Errorf("could not find remote ref: %w", err) + } + + if localHead.Hash() == remoteRef.Hash() { // if same hash, no need to merge + return nil + } + + remoteCommit, err := cal.repository.CommitObject(remoteRef.Hash()) + if err != nil { + return err + } + remoteTree, err := remoteCommit.Tree() + if err != nil { + return err + } + + // process changes file by file + err = remoteTree.Files().ForEach(func(f *object.File) error { + remotePath := path.Clean(f.Name) + + // merge based on file type (parent dir) + switch path.Dir(remotePath) { + case EventsDirName: + return mergeEventFile(wt.Filesystem, remotePath, f) + default: + return nil // TODO: merge index.json, ... ? + } + }) + if err != nil { + return fmt.Errorf("failed during tree traversal: %w", err) + } + + // commit the merge + if _, err = wt.Add(EventsDirName); err != nil { // TODO: stage only touched files + return err + } + + commitMsg := fmt.Sprintf("Merge remote-tracking branch '%s/%s' (LWW resolution)", GitRemoteName, GitBranchName) + _, err = wt.Commit(commitMsg, &gogit.CommitOptions{ + Parents: []plumbing.Hash{localHead.Hash(), remoteRef.Hash()}, + Author: &object.Signature{ + Name: GitAuthorName, + Email: "", + When: time.Now(), + }, + AllowEmptyCommits: true, + }) + + return err +} + +func mergeEventFile(repoFs billy.Filesystem, remotePath string, f *object.File) error { + if path.Dir(remotePath) != EventsDirName { + return nil + } + if path.Ext(remotePath) != ".json" { + return nil + } + localFilePath := repoFs.Join(strings.Split(remotePath, "/")...) // let fs decide on the path format just in case + + // read contents + remoteReader, err := f.Reader() + if err != nil { + return err + } + defer remoteReader.Close() + + remoteData, err := io.ReadAll(remoteReader) + if err != nil { + return err + } + + // if file doesn't exist locally, take the remote one + if _, err := repoFs.Stat(localFilePath); os.IsNotExist(err) { + return util.WriteFile(repoFs, localFilePath, remoteData, 0o644) + } + + // else collision + localData, err := util.ReadFile(repoFs, localFilePath) + if err != nil { + return err + } + + // parse json events + var localEvent, remoteEvent Event + if err := json.Unmarshal(localData, &localEvent); err != nil { + return err + } + if err := json.Unmarshal(remoteData, &remoteEvent); err != nil { + return err + } + + // latest update wins + if remoteEvent.UpdatedAt.After(localEvent.UpdatedAt) { + return util.WriteFile(repoFs, localFilePath, remoteData, 0o644) + } + + return 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 { From 664bbb3d045ad546d6746aea71a75b4a1449d8d9 Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 10 Jun 2026 12:09:59 +0200 Subject: [PATCH 2/8] group everything into SyncAll --- cmd/wasm/main.go | 9 +-- pkg/api/api.go | 3 +- pkg/core/core.go | 189 +++++++++++++++++++++++++++------------------- pkg/core/merge.go | 97 +++++++++++++++++------- 4 files changed, 184 insertions(+), 114 deletions(-) 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/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/core.go b/pkg/core/core.go index c5984d0..39a646a 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -11,6 +11,7 @@ import ( "github.com/git-calendar/core/pkg/filesystem" "github.com/go-git/go-billy/v5" gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/cache" gogitfs "github.com/go-git/go-git/v5/storage/filesystem" "github.com/google/uuid" @@ -50,110 +51,140 @@ func (c *Core) SetCorsProxy(proxyUrl string) error { return err } -func (c *Core) PushAll() error { +func (c *Core) SyncAll() error { var resultErr error for _, cal := range c.calendars { if cal == nil || cal.repository == nil { - continue + continue // important to check here; syncCalendar and other do assume this } - fmt.Println("pushing", cal.Name) - - repoUrl, err := repoUrlFromCalendar(cal) - if err != nil { - if errors.Is(err, gogit.ErrRemoteNotFound) { - continue // this is ok - } - resultErr = errors.Join(resultErr, err) - continue + if err := c.syncCalendar(cal); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("%q: sync failed: %w", cal.Name, err)) } + } - finalUrl, auth := prepareRepoUrl(repoUrl, c.proxyUrl) - err = cal.repository.Push(&gogit.PushOptions{ - RemoteName: GitRemoteName, - RemoteURL: finalUrl.String(), - Auth: auth, - }) - if err != nil { - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - continue // this is ok - } - resultErr = errors.Join(resultErr, err) - } + if err := c.LoadCalendars(); err != nil { // reload events from disk + resultErr = errors.Join(resultErr, err) } return resultErr } -func (c *Core) PullAll() error { - var resultErr error - var needPushAfter bool - - for _, cal := range c.calendars { - if cal == nil || cal.repository == nil { - continue +// 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) + localRef, err := localMainRef(cal.repository) + 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 - } + remoteRef, err := remoteMainRef(cal.repository) + if err != nil { + return err + } - repoUrl, err := repoUrlFromCalendar(cal) - if err != nil { - if errors.Is(err, gogit.ErrRemoteNotFound) { - continue // this is ok - } - resultErr = errors.Join(resultErr, err) - continue + switch { + case localRef.Hash() == remoteRef.Hash(): + return nil + + case isAncestor(cal.repository, localRef.Hash(), remoteRef.Hash()): + // remote is ahead + return fastForwardCalendar(cal, remoteRef.Hash()) + + case isAncestor(cal.repository, remoteRef.Hash(), localRef.Hash()): + // local is ahead + return pushCalendar(cal, c.proxyUrl) + + 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 := customMerge(cal, c.proxyUrl); err != nil { + return fmt.Errorf("failed to merge: %w", err) } + fmt.Printf("Custom merge successfull for %q\n", cal.Name) - finalUrl, auth := prepareRepoUrl(repoUrl, c.proxyUrl) - err = wt.Pull(&gogit.PullOptions{ - RemoteName: GitRemoteName, - RemoteURL: finalUrl.String(), - Auth: auth, - }) - if err != nil { - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - continue // good - } - - // histories have diverged -> merge needed - if errors.Is(err, gogit.ErrNonFastForwardUpdate) { - fmt.Printf("Diverged history detected on %q, trying to merge...\n", cal.Name) - - err := customMergeRemote(cal, c.proxyUrl) - if err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("%q: custom merge failed: %w", cal.Name, err)) - continue - } - fmt.Printf("Custom merge successfull\n", cal.Name) - needPushAfter = true - - continue - } - - // some other error happened - resultErr = errors.Join(resultErr, fmt.Errorf("%q: pull from remote failed: %w", cal.Name, err)) - continue + return pushCalendar(cal, c.proxyUrl) + } +} + +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) + err = cal.repository.Push(&gogit.PushOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + }) + if err != nil { + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return nil // this is ok } + return err + } + + return nil +} + +func fetchCalendar(cal *calendar, proxyUrl *url.URL) error { + fmt.Println("fetching", cal.Name) + + repoUrl, err := repoUrlFromCalendar(cal) + if err != nil { + return err } - if needPushAfter { - if err := c.PushAll(); err != nil { - resultErr = errors.Join(resultErr, fmt.Errorf("push to remotes failed: %w", err)) + finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) + err = cal.repository.Fetch(&gogit.FetchOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + }) + if err != nil { + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return nil // this is ok } + return err } - return resultErr + return nil +} + +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 } func (c *Core) ExportZip(calendar string) ([]byte, error) { diff --git a/pkg/core/merge.go b/pkg/core/merge.go index 5c4ac1e..df58113 100644 --- a/pkg/core/merge.go +++ b/pkg/core/merge.go @@ -18,7 +18,7 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -func customMergeRemote(cal *calendar, proxyUrl *url.URL) error { +func customMerge(cal *calendar, proxyUrl *url.URL) error { if cal == nil { return errors.New("cal is nil") } @@ -28,23 +28,6 @@ func customMergeRemote(cal *calendar, proxyUrl *url.URL) error { return err } - // fetch from remote - repoUrl, err := repoUrlFromCalendar(cal) - if err != nil { - return err - } - - finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) - err = cal.repository.Fetch(&gogit.FetchOptions{ - RemoteName: GitRemoteName, - RemoteURL: finalUrl.String(), - Auth: auth, - }) - if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { - return fmt.Errorf("fetch failed: %w", err) - } - - // checkout main branch to be sure localBranch := plumbing.NewBranchReferenceName(GitBranchName) head, err := cal.repository.Head() if err != nil { @@ -92,6 +75,7 @@ func customMergeRemote(cal *calendar, proxyUrl *url.URL) error { case EventsDirName: return mergeEventFile(wt.Filesystem, remotePath, f) default: + fmt.Printf("skipping %q merge...\n", f.Name) return nil // TODO: merge index.json, ... ? } }) @@ -101,7 +85,7 @@ func customMergeRemote(cal *calendar, proxyUrl *url.URL) error { // commit the merge if _, err = wt.Add(EventsDirName); err != nil { // TODO: stage only touched files - return err + return fmt.Errorf("failed to stage merged events: %w", err) } commitMsg := fmt.Sprintf("Merge remote-tracking branch '%s/%s' (LWW resolution)", GitRemoteName, GitBranchName) @@ -130,39 +114,100 @@ func mergeEventFile(repoFs billy.Filesystem, remotePath string, f *object.File) // read contents remoteReader, err := f.Reader() if err != nil { - return err + return fmt.Errorf("%s: failed to open remote blob as reader: %w", remotePath, err) } defer remoteReader.Close() remoteData, err := io.ReadAll(remoteReader) if err != nil { - return err + return fmt.Errorf("%s: failed to read remote blob: %w", remotePath, err) } // if file doesn't exist locally, take the remote one if _, err := repoFs.Stat(localFilePath); os.IsNotExist(err) { - return util.WriteFile(repoFs, localFilePath, remoteData, 0o644) + if os.IsNotExist(err) { + // make sure events/ exists + if err := repoFs.MkdirAll(EventsDirName, 0o755); err != nil { + return fmt.Errorf("%s: failed to create events dir: %w", remotePath, err) + } + if err := util.WriteFile(repoFs, localFilePath, remoteData, 0o644); err != nil { + return fmt.Errorf("%s: failed to write remote event: %w", remotePath, err) + } + return nil + } + + return fmt.Errorf("%s: failed to stat local event: %w", remotePath, err) } // else collision localData, err := util.ReadFile(repoFs, localFilePath) if err != nil { - return err + return fmt.Errorf("%s: failed to read local event: %w", remotePath, err) } // parse json events var localEvent, remoteEvent Event if err := json.Unmarshal(localData, &localEvent); err != nil { - return err + return fmt.Errorf("%s: failed to parse local event: %w", remotePath, err) } if err := json.Unmarshal(remoteData, &remoteEvent); err != nil { - return err + return fmt.Errorf("%s: failed to parse remote event: %w", remotePath, err) } // latest update wins if remoteEvent.UpdatedAt.After(localEvent.UpdatedAt) { - return util.WriteFile(repoFs, localFilePath, remoteData, 0o644) + if err := util.WriteFile(repoFs, localFilePath, remoteData, 0o644); err != nil { + return fmt.Errorf("%s: failed to write newer remote event: %w", remotePath, err) + } } return nil } + +func localMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { + refName := plumbing.NewBranchReferenceName(GitBranchName) + + ref, err := repo.Reference(refName, true) + if err != nil { + return nil, fmt.Errorf("local ref %s: %w", refName, err) + } + + return ref, nil +} + +func remoteMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { + refName := plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName) + + ref, err := repo.Reference(refName, true) + if err != nil { + return nil, fmt.Errorf("remote ref %s: %w", refName, err) + } + + return ref, nil +} + +func isAncestor(repo *gogit.Repository, ancestorHash, descendantHash plumbing.Hash) bool { + if ancestorHash == descendantHash { + return true + } + + ancestorCommit, err := repo.CommitObject(ancestorHash) + if err != nil { + fmt.Println(err) + return false + } + + descendantCommit, err := repo.CommitObject(descendantHash) + if err != nil { + fmt.Println(err) + return false + } + + ok, err := ancestorCommit.IsAncestor(descendantCommit) + if err != nil { + fmt.Println(err) + return false + } + + return ok +} From a7936fa99b06ad66e840b8fd10198d92fa31bd81 Mon Sep 17 00:00:00 2001 From: firu11 Date: Wed, 10 Jun 2026 12:12:11 +0200 Subject: [PATCH 3/8] reorganize --- pkg/core/core.go | 73 --------------- pkg/core/git.go | 132 ++++++++++++++++++++++++++++ pkg/core/{merge.go => git_merge.go} | 48 ---------- 3 files changed, 132 insertions(+), 121 deletions(-) create mode 100644 pkg/core/git.go rename pkg/core/{merge.go => git_merge.go} (80%) diff --git a/pkg/core/core.go b/pkg/core/core.go index 39a646a..47e59f1 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -11,7 +11,6 @@ import ( "github.com/git-calendar/core/pkg/filesystem" "github.com/go-git/go-billy/v5" gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/cache" gogitfs "github.com/go-git/go-git/v5/storage/filesystem" "github.com/google/uuid" @@ -115,78 +114,6 @@ func (c *Core) syncCalendar(cal *calendar) error { } } -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) - err = cal.repository.Push(&gogit.PushOptions{ - RemoteName: GitRemoteName, - RemoteURL: finalUrl.String(), - Auth: auth, - }) - if err != nil { - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - return nil // this is ok - } - return err - } - - return nil -} - -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) - err = cal.repository.Fetch(&gogit.FetchOptions{ - RemoteName: GitRemoteName, - RemoteURL: finalUrl.String(), - Auth: auth, - }) - if err != nil { - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - return nil // this is ok - } - return err - } - - return nil -} - -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 -} - func (c *Core) ExportZip(calendar string) ([]byte, error) { var buf bytes.Buffer var fs billy.Filesystem diff --git a/pkg/core/git.go b/pkg/core/git.go new file mode 100644 index 0000000..105b144 --- /dev/null +++ b/pkg/core/git.go @@ -0,0 +1,132 @@ +package core + +import ( + "errors" + "fmt" + "net/url" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +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) + err = cal.repository.Push(&gogit.PushOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + }) + if err != nil { + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return nil // this is ok + } + return err + } + + return nil +} + +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) + err = cal.repository.Fetch(&gogit.FetchOptions{ + RemoteName: GitRemoteName, + RemoteURL: finalUrl.String(), + Auth: auth, + }) + if err != nil { + if errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return nil // this is ok + } + return err + } + + return nil +} + +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 +} + +// --------------------------------------------------------------------------------------------------- + +func localMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { + refName := plumbing.NewBranchReferenceName(GitBranchName) + + ref, err := repo.Reference(refName, true) + if err != nil { + return nil, fmt.Errorf("local ref %s: %w", refName, err) + } + + return ref, nil +} + +func remoteMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { + refName := plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName) + + ref, err := repo.Reference(refName, true) + if err != nil { + return nil, fmt.Errorf("remote ref %s: %w", refName, err) + } + + return ref, nil +} + +func isAncestor(repo *gogit.Repository, ancestorHash, descendantHash plumbing.Hash) bool { + if ancestorHash == descendantHash { + return true + } + + ancestorCommit, err := repo.CommitObject(ancestorHash) + if err != nil { + fmt.Println(err) + return false + } + + descendantCommit, err := repo.CommitObject(descendantHash) + if err != nil { + fmt.Println(err) + return false + } + + ok, err := ancestorCommit.IsAncestor(descendantCommit) + if err != nil { + fmt.Println(err) + return false + } + + return ok +} diff --git a/pkg/core/merge.go b/pkg/core/git_merge.go similarity index 80% rename from pkg/core/merge.go rename to pkg/core/git_merge.go index df58113..c5ec534 100644 --- a/pkg/core/merge.go +++ b/pkg/core/git_merge.go @@ -163,51 +163,3 @@ func mergeEventFile(repoFs billy.Filesystem, remotePath string, f *object.File) return nil } - -func localMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { - refName := plumbing.NewBranchReferenceName(GitBranchName) - - ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("local ref %s: %w", refName, err) - } - - return ref, nil -} - -func remoteMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { - refName := plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName) - - ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("remote ref %s: %w", refName, err) - } - - return ref, nil -} - -func isAncestor(repo *gogit.Repository, ancestorHash, descendantHash plumbing.Hash) bool { - if ancestorHash == descendantHash { - return true - } - - ancestorCommit, err := repo.CommitObject(ancestorHash) - if err != nil { - fmt.Println(err) - return false - } - - descendantCommit, err := repo.CommitObject(descendantHash) - if err != nil { - fmt.Println(err) - return false - } - - ok, err := ancestorCommit.IsAncestor(descendantCommit) - if err != nil { - fmt.Println(err) - return false - } - - return ok -} From 25b9e14ad48274107b1130ba747114c28f61be8f Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 13 Jun 2026 00:22:56 +0200 Subject: [PATCH 4/8] working deleted remote files --- pkg/core/core.go | 19 +-- pkg/core/git.go | 76 ++-------- pkg/core/git_merge.go | 324 ++++++++++++++++++++++++++++++------------ 3 files changed, 249 insertions(+), 170 deletions(-) diff --git a/pkg/core/core.go b/pkg/core/core.go index 47e59f1..65fb033 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -79,25 +79,20 @@ func (c *Core) syncCalendar(cal *calendar) error { return err } - localRef, err := localMainRef(cal.repository) - if err != nil { - return err - } - - remoteRef, err := remoteMainRef(cal.repository) + localCommit, remoteCommit, err := getCommits(cal.repository) if err != nil { return err } switch { - case localRef.Hash() == remoteRef.Hash(): - return nil + case localCommit.Hash == remoteCommit.Hash: + return nil // already in sync - case isAncestor(cal.repository, localRef.Hash(), remoteRef.Hash()): + case isAncestor(localCommit, remoteCommit): // remote is ahead - return fastForwardCalendar(cal, remoteRef.Hash()) + return fastForwardCalendar(cal, remoteCommit.Hash) - case isAncestor(cal.repository, remoteRef.Hash(), localRef.Hash()): + case isAncestor(remoteCommit, localCommit): // local is ahead return pushCalendar(cal, c.proxyUrl) @@ -105,7 +100,7 @@ func (c *Core) syncCalendar(cal *calendar) error { // cant simply push or pull (history diverged) -> try merge fmt.Printf("Diverged history detected on %q, trying to merge...\n", cal.Name) - if err := customMerge(cal, c.proxyUrl); err != nil { + 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) diff --git a/pkg/core/git.go b/pkg/core/git.go index 105b144..b0e0998 100644 --- a/pkg/core/git.go +++ b/pkg/core/git.go @@ -18,19 +18,11 @@ func pushCalendar(cal *calendar, proxyUrl *url.URL) error { } finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) - err = cal.repository.Push(&gogit.PushOptions{ + return ignoreUpToDate(cal.repository.Push(&gogit.PushOptions{ RemoteName: GitRemoteName, RemoteURL: finalUrl.String(), Auth: auth, - }) - if err != nil { - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - return nil // this is ok - } - return err - } - - return nil + })) } func fetchCalendar(cal *calendar, proxyUrl *url.URL) error { @@ -42,19 +34,11 @@ func fetchCalendar(cal *calendar, proxyUrl *url.URL) error { } finalUrl, auth := prepareRepoUrl(repoUrl, proxyUrl) - err = cal.repository.Fetch(&gogit.FetchOptions{ + return ignoreUpToDate(cal.repository.Fetch(&gogit.FetchOptions{ RemoteName: GitRemoteName, RemoteURL: finalUrl.String(), Auth: auth, - }) - if err != nil { - if errors.Is(err, gogit.NoErrAlreadyUpToDate) { - return nil // this is ok - } - return err - } - - return nil + })) } func fastForwardCalendar(cal *calendar, hash plumbing.Hash) error { @@ -81,52 +65,10 @@ func fastForwardCalendar(cal *calendar, hash plumbing.Hash) error { return nil } -// --------------------------------------------------------------------------------------------------- - -func localMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { - refName := plumbing.NewBranchReferenceName(GitBranchName) - - ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("local ref %s: %w", refName, err) +// 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 ref, nil -} - -func remoteMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { - refName := plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName) - - ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("remote ref %s: %w", refName, err) - } - - return ref, nil -} - -func isAncestor(repo *gogit.Repository, ancestorHash, descendantHash plumbing.Hash) bool { - if ancestorHash == descendantHash { - return true - } - - ancestorCommit, err := repo.CommitObject(ancestorHash) - if err != nil { - fmt.Println(err) - return false - } - - descendantCommit, err := repo.CommitObject(descendantHash) - if err != nil { - fmt.Println(err) - return false - } - - ok, err := ancestorCommit.IsAncestor(descendantCommit) - if err != nil { - fmt.Println(err) - return false - } - - return ok + return err } diff --git a/pkg/core/git_merge.go b/pkg/core/git_merge.go index c5ec534..550971d 100644 --- a/pkg/core/git_merge.go +++ b/pkg/core/git_merge.go @@ -5,9 +5,8 @@ import ( "errors" "fmt" "io" - "net/url" - "os" "path" + "sort" "strings" "time" @@ -18,148 +17,291 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -func customMerge(cal *calendar, proxyUrl *url.URL) error { - if cal == nil { - return errors.New("cal is nil") +// mergeOriginMain performs a 3-way last-write-wins merge of origin/main -> main. +func mergeOriginMain(repo *gogit.Repository) error { + if repo != nil { + return errors.New("calendar or repository is nil") } - wt, err := cal.repository.Worktree() + // get worktree with main branch + wt, err := ensureBranch(repo, GitBranchName) if err != nil { return err } - localBranch := plumbing.NewBranchReferenceName(GitBranchName) - head, err := cal.repository.Head() + localCommit, remoteCommit, err := getCommits(repo) if err != nil { return err } - - // checkout main (just to be sure) - if head.Name() != localBranch { - if err := wt.Checkout(&gogit.CheckoutOptions{ - Branch: localBranch, - }); err != nil { - return fmt.Errorf("checkout %s: %w", localBranch, err) - } + if localCommit.Hash == remoteCommit.Hash { + return nil // already up to date } - // get local HEAD and remote Ref - localHead, err := cal.repository.Head() + // prepare trees + baseCommit, err := mergeBase(localCommit, remoteCommit) if err != nil { return err } - remoteRef, err := cal.repository.Reference(plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName), true) + baseTree, err := baseCommit.Tree() if err != nil { - return fmt.Errorf("could not find remote ref: %w", err) - } - - if localHead.Hash() == remoteRef.Hash() { // if same hash, no need to merge - return nil + return fmt.Errorf("load base tree: %w", err) } - - remoteCommit, err := cal.repository.CommitObject(remoteRef.Hash()) + localTree, err := localCommit.Tree() if err != nil { - return err + return fmt.Errorf("load local tree: %w", err) } remoteTree, err := remoteCommit.Tree() if err != nil { - return err + return fmt.Errorf("load remote tree: %w", err) } - // process changes file by file - err = remoteTree.Files().ForEach(func(f *object.File) error { - remotePath := path.Clean(f.Name) - - // merge based on file type (parent dir) - switch path.Dir(remotePath) { - case EventsDirName: - return mergeEventFile(wt.Filesystem, remotePath, f) - default: - fmt.Printf("skipping %q merge...\n", f.Name) - return nil // TODO: merge index.json, ... ? - } - }) + paths, err := collectEventPaths(baseTree, localTree, remoteTree) if err != nil { - return fmt.Errorf("failed during tree traversal: %w", err) + return err } - - // commit the merge - if _, err = wt.Add(EventsDirName); err != nil { // TODO: stage only touched files - return fmt.Errorf("failed to stage merged events: %w", err) + for _, p := range paths { + baseVer, err := readEventVersion(baseTree, p) + if err != nil { + return err + } + localVer, err := readEventVersion(localTree, p) + if err != nil { + return err + } + remoteVer, err := readEventVersion(remoteTree, p) + if err != nil { + return err + } + if err := applyLWW(wt, p, baseVer, localVer, remoteVer); err != nil { + return err + } } - commitMsg := fmt.Sprintf("Merge remote-tracking branch '%s/%s' (LWW resolution)", GitRemoteName, GitBranchName) + commitMsg := fmt.Sprintf("Merge '%s/%s' (LWW)", GitRemoteName, GitBranchName) _, err = wt.Commit(commitMsg, &gogit.CommitOptions{ - Parents: []plumbing.Hash{localHead.Hash(), remoteRef.Hash()}, + Parents: []plumbing.Hash{localCommit.Hash, remoteCommit.Hash}, Author: &object.Signature{ - Name: GitAuthorName, - Email: "", - When: time.Now(), + Name: GitAuthorName, + When: time.Now(), }, AllowEmptyCommits: true, }) + if err != nil { + return fmt.Errorf("failed to commit merge: %w", err) + } - return err + return nil } -func mergeEventFile(repoFs billy.Filesystem, remotePath string, f *object.File) error { - if path.Dir(remotePath) != EventsDirName { - 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 eventVersion) 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 writeEvent(wt, gitPath, remote) + + case local.hash == remote.hash: // identical blobs; no-op + + default: // both sides modified -> last write wins + if remote.event.UpdatedAt.After(local.event.UpdatedAt) { + return writeEvent(wt, gitPath, remote) + } + } + + return nil +} + +// writeEvent writes a remote event blob to the worktree filesystem and stages it. +func writeEvent(wt *gogit.Worktree, gitPath string, ver eventVersion) 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 path.Ext(remotePath) != ".json" { - return nil + if err := util.WriteFile(fs, localPath, ver.data, 0o644); err != nil { + return fmt.Errorf("%s: failed to write event: %w", gitPath, err) } - localFilePath := repoFs.Join(strings.Split(remotePath, "/")...) // let fs decide on the path format just in case + if _, err := wt.Add(gitPath); err != nil { + return fmt.Errorf("%s: failed to stage event: %w", gitPath, err) + } + return nil +} - // read contents - remoteReader, err := f.Reader() +// ------------------------------------ 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 fmt.Errorf("%s: failed to open remote blob as reader: %w", remotePath, err) + return nil, err } - defer remoteReader.Close() + 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 +} - remoteData, err := io.ReadAll(remoteReader) +func localMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { + name := plumbing.NewBranchReferenceName(GitBranchName) + ref, err := repo.Reference(name, true) if err != nil { - return fmt.Errorf("%s: failed to read remote blob: %w", remotePath, err) + return nil, fmt.Errorf("failed to get local ref %s: %w", name, err) } + return ref, nil +} - // if file doesn't exist locally, take the remote one - if _, err := repoFs.Stat(localFilePath); os.IsNotExist(err) { - if os.IsNotExist(err) { - // make sure events/ exists - if err := repoFs.MkdirAll(EventsDirName, 0o755); err != nil { - return fmt.Errorf("%s: failed to create events dir: %w", remotePath, err) - } - if err := util.WriteFile(repoFs, localFilePath, remoteData, 0o644); err != nil { - return fmt.Errorf("%s: failed to write remote event: %w", remotePath, err) - } - return nil - } +func remoteMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { + name := plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName) + 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 +} - return fmt.Errorf("%s: failed to stat local event: %w", remotePath, err) +// getCommits returns the local and remote HEAD commits for the main branch. +func getCommits(repo *gogit.Repository) (local, remote *object.Commit, err error) { + localRef, err := localMainRef(repo) + if err != nil { + return nil, nil, err + } + remoteRef, err := remoteMainRef(repo) + if err != nil { + return nil, nil, err } - // else collision - localData, err := util.ReadFile(repoFs, localFilePath) + 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 fmt.Errorf("%s: failed to read local event: %w", remotePath, err) + return nil, nil, fmt.Errorf("failed to load remote commit: %w", err) } - // parse json events - var localEvent, remoteEvent Event - if err := json.Unmarshal(localData, &localEvent); err != nil { - return fmt.Errorf("%s: failed to parse local event: %w", remotePath, err) + return local, remote, nil +} + +// 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 } - if err := json.Unmarshal(remoteData, &remoteEvent); err != nil { - return fmt.Errorf("%s: failed to parse remote event: %w", remotePath, err) + 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 +} - // latest update wins - if remoteEvent.UpdatedAt.After(localEvent.UpdatedAt) { - if err := util.WriteFile(repoFs, localFilePath, remoteData, 0o644); err != nil { - return fmt.Errorf("%s: failed to write newer remote event: %w", remotePath, err) +// 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 eventVersion struct { + exists bool + hash plumbing.Hash + data []byte + event Event +} + +// readEventVersion reads and parses an event at gitPath from tree. +func readEventVersion(tree *object.Tree, gitPath string) (eventVersion, error) { + f, err := tree.File(gitPath) + if errors.Is(err, object.ErrFileNotFound) { + return eventVersion{}, nil + } + if err != nil { + return eventVersion{}, fmt.Errorf("%s: failed to lookup in tree: %w", gitPath, err) + } + + r, err := f.Reader() + if err != nil { + return eventVersion{}, fmt.Errorf("%s: failed to open blob: %w", gitPath, err) + } + defer r.Close() + + data, err := io.ReadAll(r) + if err != nil { + return eventVersion{}, fmt.Errorf("%s: failed to read blob: %w", gitPath, err) + } + + var ev Event + if err := json.Unmarshal(data, &ev); err != nil { + return eventVersion{}, fmt.Errorf("%s: failed to parse event JSON: %w", gitPath, err) + } + + return eventVersion{exists: true, hash: f.Hash, data: data, event: ev}, nil +} + +// collectEventPaths returns the sorted union of event paths across all trees. +func collectEventPaths(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 { + if p := path.Clean(f.Name); isEventPath(p) { + seen[p] = struct{}{} + } + return nil + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to collect event paths: %w", err) } } - return nil + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + + sort.Strings(paths) + return paths, nil +} + +func isEventPath(gitPath string) bool { + return path.Dir(gitPath) == EventsDirName && path.Ext(gitPath) == ".json" +} + +// 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), "/")...) } From 94a18ade36e6f10ddf5f6cea89740ee62395d260 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 13 Jun 2026 01:04:31 +0200 Subject: [PATCH 5/8] small changes --- pkg/core/core.go | 1 + pkg/core/core_events.go | 2 ++ pkg/core/git_merge.go | 11 +++++------ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/core/core.go b/pkg/core/core.go index 65fb033..dfbd977 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -50,6 +50,7 @@ func (c *Core) SetCorsProxy(proxyUrl string) error { return err } +// SyncAll tries to synchronize all calendars with its remotes. func (c *Core) SyncAll() error { var resultErr error 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/git_merge.go b/pkg/core/git_merge.go index 550971d..bcdf7ec 100644 --- a/pkg/core/git_merge.go +++ b/pkg/core/git_merge.go @@ -19,8 +19,8 @@ import ( // mergeOriginMain performs a 3-way last-write-wins merge of origin/main -> main. func mergeOriginMain(repo *gogit.Repository) error { - if repo != nil { - return errors.New("calendar or repository is nil") + if repo == nil { + return errors.New("repository is nil") } // get worktree with main branch @@ -93,8 +93,7 @@ func mergeOriginMain(repo *gogit.Repository) error { return nil } -// applyLWW applies last-write-wins strategy to an event file from three -// versions (base, local, remote). +// 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 eventVersion) error { switch { case base.exists && !remote.exists: // remote deleted -> delete wins @@ -114,7 +113,7 @@ func applyLWW(wt *gogit.Worktree, gitPath string, base, local, remote eventVersi case local.hash == remote.hash: // identical blobs; no-op default: // both sides modified -> last write wins - if remote.event.UpdatedAt.After(local.event.UpdatedAt) { + if remote.event.UpdatedAt.After(local.event.UpdatedAt) { // if same, local wins i guess? return writeEvent(wt, gitPath, remote) } } @@ -141,7 +140,7 @@ func writeEvent(wt *gogit.Worktree, gitPath string, ver eventVersion) error { return nil } -// ------------------------------------ helpers ------------------------------------ +// ------------------------------------ Helpers ------------------------------------ // ensureBranch checks out branch if HEAD is elsewhere, then returns the worktree. func ensureBranch(repo *gogit.Repository, branch string) (*gogit.Worktree, error) { From 2f24e3ec493d4fe39fe903075331450a0c2d8603 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 13 Jun 2026 01:34:45 +0200 Subject: [PATCH 6/8] separate package --- pkg/core/core.go | 3 +- pkg/core/git.go | 42 +++++ pkg/{core/git_merge.go => gitmerge/merge.go} | 185 ++++++++++--------- pkg/gitmerge/options.go | 39 ++++ 4 files changed, 184 insertions(+), 85 deletions(-) rename pkg/{core/git_merge.go => gitmerge/merge.go} (61%) create mode 100644 pkg/gitmerge/options.go diff --git a/pkg/core/core.go b/pkg/core/core.go index dfbd977..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" @@ -80,7 +81,7 @@ func (c *Core) syncCalendar(cal *calendar) error { return err } - localCommit, remoteCommit, err := getCommits(cal.repository) + localCommit, remoteCommit, err := gitmerge.GetCommits(cal.repository, GitBranchName, GitRemoteName) if err != nil { return err } diff --git a/pkg/core/git.go b/pkg/core/git.go index b0e0998..99b1cd8 100644 --- a/pkg/core/git.go +++ b/pkg/core/git.go @@ -1,12 +1,17 @@ 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 { @@ -72,3 +77,40 @@ func ignoreUpToDate(err error) error { } 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/git_merge.go b/pkg/gitmerge/merge.go similarity index 61% rename from pkg/core/git_merge.go rename to pkg/gitmerge/merge.go index bcdf7ec..b7a0376 100644 --- a/pkg/core/git_merge.go +++ b/pkg/gitmerge/merge.go @@ -1,7 +1,6 @@ -package core +package gitmerge import ( - "encoding/json" "errors" "fmt" "io" @@ -17,72 +16,82 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -// mergeOriginMain performs a 3-way last-write-wins merge of origin/main -> main. -func mergeOriginMain(repo *gogit.Repository) error { +// 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 + } - // get worktree with main branch - wt, err := ensureBranch(repo, GitBranchName) + wt, err := ensureBranch(repo, opts.BranchName) if err != nil { return err } - localCommit, remoteCommit, err := getCommits(repo) + localCommit, remoteCommit, err := GetCommits(repo, opts.BranchName, opts.RemoteName) if err != nil { return err } if localCommit.Hash == remoteCommit.Hash { - return nil // already up to date + return nil } - // prepare trees 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 := collectEventPaths(baseTree, localTree, remoteTree) + paths, err := collectPaths(opts.IncludePath, baseTree, localTree, remoteTree) if err != nil { return err } + for _, p := range paths { - baseVer, err := readEventVersion(baseTree, p) + baseVer, err := readFileVersion(baseTree, p, opts.UpdatedAt) if err != nil { return err } - localVer, err := readEventVersion(localTree, p) + + localVer, err := readFileVersion(localTree, p, opts.UpdatedAt) if err != nil { return err } - remoteVer, err := readEventVersion(remoteTree, p) + + 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)", GitRemoteName, GitBranchName) + 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: GitAuthorName, - When: time.Now(), + Name: opts.AuthorName, + Email: opts.AuthorEmail, + When: time.Now(), }, AllowEmptyCommits: true, }) @@ -94,7 +103,7 @@ func mergeOriginMain(repo *gogit.Repository) error { } // 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 eventVersion) error { +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 { @@ -108,21 +117,21 @@ func applyLWW(wt *gogit.Worktree, gitPath string, base, local, remote eventVersi case !remote.exists: // remote has nothing new; local-only add or both absent case !local.exists: // remote added a new file -> take it - return writeEvent(wt, gitPath, remote) + return writeFile(wt, gitPath, remote) case local.hash == remote.hash: // identical blobs; no-op default: // both sides modified -> last write wins - if remote.event.UpdatedAt.After(local.event.UpdatedAt) { // if same, local wins i guess? - return writeEvent(wt, gitPath, remote) + if remote.updatedAt.After(local.updatedAt) { // if same, local wins i guess? + return writeFile(wt, gitPath, remote) } } return nil } -// writeEvent writes a remote event blob to the worktree filesystem and stages it. -func writeEvent(wt *gogit.Worktree, gitPath string, ver eventVersion) error { +// 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) @@ -131,12 +140,15 @@ func writeEvent(wt *gogit.Worktree, gitPath string, ver eventVersion) error { 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 event: %w", gitPath, err) + 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 event: %w", gitPath, err) + return fmt.Errorf("%s: failed to stage file: %w", gitPath, err) } + return nil } @@ -162,31 +174,18 @@ func ensureBranch(repo *gogit.Repository, branch string) (*gogit.Worktree, error return wt, nil } -func localMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { - name := plumbing.NewBranchReferenceName(GitBranchName) - 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 remoteMainRef(repo *gogit.Repository) (*plumbing.Reference, error) { - name := plumbing.NewRemoteReferenceName(GitRemoteName, GitBranchName) - 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 -} - -// getCommits returns the local and remote HEAD commits for the main branch. -func getCommits(repo *gogit.Repository) (local, remote *object.Commit, err error) { - localRef, err := localMainRef(repo) +// 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 := remoteMainRef(repo) + + remoteRef, err := remoteBranchRef(repo, remoteName, branchName) if err != nil { return nil, nil, err } @@ -195,6 +194,7 @@ func getCommits(repo *gogit.Repository) (local, remote *object.Commit, err error 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) @@ -203,17 +203,30 @@ func getCommits(repo *gogit.Repository) (local, remote *object.Commit, err error return local, remote, nil } -// 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 +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) } - ok, err := a.IsAncestor(b) + + 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 { - fmt.Printf("WARN: isAncestor %s -> %s: %v\n", a.Hash, b.Hash, err) - return false + return nil, fmt.Errorf("failed to get remote ref %s: %w", name, err) } - return ok + + return ref, nil } // mergeBase returns the best common ancestor of a and b. @@ -230,60 +243,68 @@ func mergeBase(a, b *object.Commit) (*object.Commit, error) { // ------------------------------------------------------------------------------ -type eventVersion struct { - exists bool - hash plumbing.Hash - data []byte - event Event +type fileVersion struct { + exists bool + hash plumbing.Hash + data []byte + updatedAt time.Time } -// readEventVersion reads and parses an event at gitPath from tree. -func readEventVersion(tree *object.Tree, gitPath string) (eventVersion, error) { +func readFileVersion( + tree *object.Tree, + gitPath string, + updatedAtFunc UpdatedAtFunc, +) (fileVersion, error) { f, err := tree.File(gitPath) if errors.Is(err, object.ErrFileNotFound) { - return eventVersion{}, nil + return fileVersion{}, nil } if err != nil { - return eventVersion{}, fmt.Errorf("%s: failed to lookup in tree: %w", gitPath, err) + return fileVersion{}, fmt.Errorf("%s: failed to lookup in tree: %w", gitPath, err) } r, err := f.Reader() if err != nil { - return eventVersion{}, fmt.Errorf("%s: failed to open blob: %w", gitPath, err) + return fileVersion{}, fmt.Errorf("%s: failed to open blob: %w", gitPath, err) } defer r.Close() data, err := io.ReadAll(r) if err != nil { - return eventVersion{}, fmt.Errorf("%s: failed to read blob: %w", gitPath, err) + return fileVersion{}, fmt.Errorf("%s: failed to read blob: %w", gitPath, err) } - var ev Event - if err := json.Unmarshal(data, &ev); err != nil { - return eventVersion{}, fmt.Errorf("%s: failed to parse event JSON: %w", gitPath, err) + updatedAt, err := updatedAtFunc(gitPath, data) + if err != nil { + return fileVersion{}, err } - return eventVersion{exists: true, hash: f.Hash, data: data, event: ev}, nil + return fileVersion{ + exists: true, + hash: f.Hash, + data: data, + updatedAt: updatedAt, + }, nil } -// collectEventPaths returns the sorted union of event paths across all trees. -func collectEventPaths(trees ...*object.Tree) ([]string, error) { +// 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 { - if p := path.Clean(f.Name); isEventPath(p) { - seen[p] = struct{}{} - } - return nil - }, - ) + 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 event paths: %w", err) + return nil, fmt.Errorf("failed to collect paths: %w", err) } } @@ -296,10 +317,6 @@ func collectEventPaths(trees ...*object.Tree) ([]string, error) { return paths, nil } -func isEventPath(gitPath string) bool { - return path.Dir(gitPath) == EventsDirName && path.Ext(gitPath) == ".json" -} - // 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/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 +} From 815590c5d19efc2fa76f7c9858a80ecde864c0ce Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 13 Jun 2026 01:34:52 +0200 Subject: [PATCH 7/8] some tests --- pkg/gitmerge/merge_test.go | 389 +++++++++++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 pkg/gitmerge/merge_test.go 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) + } +} From 4e26b8ee2f0b1fe33e86cff85c809abf9ef6a732 Mon Sep 17 00:00:00 2001 From: firu11 Date: Sat, 13 Jun 2026 01:45:02 +0200 Subject: [PATCH 8/8] fix tests failing on different updatedAt --- e2e/events_basic_test.go | 1 + e2e/events_repeating_test.go | 6 ++++++ 2 files changed, 7 insertions(+) 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) }