diff --git a/internal/handler/integration/handler.go b/internal/handler/integration/handler.go new file mode 100644 index 000000000..1a4584227 --- /dev/null +++ b/internal/handler/integration/handler.go @@ -0,0 +1,845 @@ +// Package integration implements the gs integration command tree. +// +// Integration branches are repo-scoped singletons that combine the tips +// of multiple tracked branches by sequentially merging them onto trunk. +// They are deliberately separate from tracked stack branches: +// they do not receive PRs and are invisible to gs branch commands. +package integration + +import ( + "context" + "errors" + "fmt" + "iter" + "slices" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" +) + +//go:generate mockgen -typed -destination mocks_test.go -package integration -write_package_comment=false . GitRepository,GitWorktree,Store,Service + +// GitRepository is the subset of [git.Repository] used by the handler. +type GitRepository interface { + PeelToCommit(ctx context.Context, ref string) (git.Hash, error) + ListRemoteRefs(ctx context.Context, remote string, opts *git.ListRemoteRefsOptions) iter.Seq2[git.RemoteRef, error] + Worktrees(ctx context.Context) iter.Seq2[*git.WorktreeListItem, error] +} + +var _ GitRepository = (*git.Repository)(nil) + +// GitWorktree is the subset of [git.Worktree] used by the handler. +type GitWorktree interface { + RootDir() string + CurrentBranch(ctx context.Context) (string, error) + CheckoutBranch(ctx context.Context, branch string) error + CheckoutNewBranch(ctx context.Context, req git.CheckoutNewBranchRequest) error + Merge(ctx context.Context, opts git.MergeOptions) error + IsClean(ctx context.Context) (bool, error) + Push(ctx context.Context, opts git.PushOptions) error +} + +var _ GitWorktree = (*git.Worktree)(nil) + +// Store is the subset of [state.Store] used by the handler. +type Store interface { + Trunk() string + Remote() (state.Remote, error) + Integration(ctx context.Context) (*state.IntegrationInfo, error) + SetIntegration(ctx context.Context, info *state.IntegrationInfo) error + PendingIntegrationRebuild(ctx context.Context) (*state.IntegrationRebuild, error) + SetPendingIntegrationRebuild(ctx context.Context, rb *state.IntegrationRebuild) error + ClearPendingIntegrationRebuild(ctx context.Context) error +} + +var _ Store = (*state.Store)(nil) + +// Service is the subset of [spice.Service] used by the handler. +type Service interface { + LookupBranch(ctx context.Context, name string) (*spice.LookupBranchResponse, error) +} + +var _ Service = (*spice.Service)(nil) + +// Handler implements integration branch operations. +type Handler struct { + Log *silog.Logger // required + Repository GitRepository // required + Worktree GitWorktree // required + Store Store // required + Service Service // required +} + +// ErrNotConfigured indicates that no integration branch is configured. +var ErrNotConfigured = errors.New("no integration branch configured") + +// ErrAlreadyConfigured indicates that an integration branch is already +// configured. There can be at most one per repo. +var ErrAlreadyConfigured = errors.New("integration branch already configured") + +// CreateRequest is a request to create the integration branch +// configuration. +type CreateRequest struct { + // Name is the local branch name of the integration branch. + Name string // required + + // UpstreamBranch is the remote-side branch name. + // Defaults to Name if empty. + UpstreamBranch string + + // Tips lists tracked branches whose tips compose the integration + // branch. + Tips []string +} + +// Create sets up the singleton integration branch configuration. +// Returns [ErrAlreadyConfigured] if one is already configured. +func (h *Handler) Create(ctx context.Context, req *CreateRequest) error { + if req.Name == "" { + return errors.New("integration branch name is required") + } + if req.Name == h.Store.Trunk() { + return errors.New("integration branch name must not equal trunk") + } + + switch _, err := h.Store.Integration(ctx); { + case err == nil: + return ErrAlreadyConfigured + case errors.Is(err, state.ErrNotExist): + // ok + default: + return fmt.Errorf("get integration: %w", err) + } + + tips := make([]state.IntegrationTip, 0, len(req.Tips)) + seen := make(map[string]struct{}, len(req.Tips)) + for _, name := range req.Tips { + if err := h.validateTipName(ctx, req.Name, name, seen); err != nil { + return err + } + tips = append(tips, state.IntegrationTip{Name: name}) + seen[name] = struct{}{} + } + + info := &state.IntegrationInfo{ + Name: req.Name, + UpstreamBranch: req.UpstreamBranch, + Tips: tips, + } + if err := h.Store.SetIntegration(ctx, info); err != nil { + return fmt.Errorf("save integration: %w", err) + } + return nil +} + +// Checkout switches the worktree to the configured integration branch. +// Returns [ErrNotConfigured] if no integration is configured, or an +// error if the integration branch does not yet exist (e.g., has never +// been rebuilt). +func (h *Handler) Checkout(ctx context.Context) error { + info, err := h.loadConfigured(ctx) + if err != nil { + return err + } + + if _, err := h.Repository.PeelToCommit(ctx, info.Name); err != nil { + return fmt.Errorf("integration branch %q does not exist; run 'gs integration rebuild' first", info.Name) + } + + if err := h.Worktree.CheckoutBranch(ctx, info.Name); err != nil { + return fmt.Errorf("checkout: %w", err) + } + return nil +} + +// Delete clears the integration branch configuration. +// The underlying git branch (if any) is not touched. +func (h *Handler) Delete(ctx context.Context) error { + switch _, err := h.Store.Integration(ctx); { + case err == nil: + // ok + case errors.Is(err, state.ErrNotExist): + return ErrNotConfigured + default: + return fmt.Errorf("get integration: %w", err) + } + + if err := h.Store.SetIntegration(ctx, nil); err != nil { + return fmt.Errorf("clear integration: %w", err) + } + return nil +} + +// AddTip adds a branch to the integration tip list. +func (h *Handler) AddTip(ctx context.Context, branch string) error { + info, err := h.loadConfigured(ctx) + if err != nil { + return err + } + + seen := make(map[string]struct{}, len(info.Tips)) + for _, tip := range info.Tips { + seen[tip.Name] = struct{}{} + } + if _, exists := seen[branch]; exists { + return fmt.Errorf("tip %q is already configured", branch) + } + if err := h.validateTipName(ctx, info.Name, branch, seen); err != nil { + return err + } + + info.Tips = append(info.Tips, state.IntegrationTip{Name: branch}) + if err := h.Store.SetIntegration(ctx, info); err != nil { + return fmt.Errorf("save integration: %w", err) + } + return nil +} + +// RemoveTip removes a branch from the integration tip list. +func (h *Handler) RemoveTip(ctx context.Context, branch string) error { + info, err := h.loadConfigured(ctx) + if err != nil { + return err + } + + idx := slices.IndexFunc(info.Tips, func(t state.IntegrationTip) bool { + return t.Name == branch + }) + if idx < 0 { + return fmt.Errorf("tip %q is not configured", branch) + } + + info.Tips = slices.Delete(info.Tips, idx, idx+1) + if err := h.Store.SetIntegration(ctx, info); err != nil { + return fmt.Errorf("save integration: %w", err) + } + return nil +} + +// Status describes the current state of the integration branch. +type Status struct { + // Name is the integration branch name. + Name string + + // UpstreamBranch is the remote branch name. + UpstreamBranch string + + // LastPushedHash is the hash recorded at the last successful push. + LastPushedHash git.Hash + + // Tips lists each configured tip with its recorded and current + // hashes. Drift = StoredHash != CurrentHash. + Tips []TipStatus +} + +// TipStatus reports drift for a single tip. +type TipStatus struct { + Name string + StoredHash git.Hash + CurrentHash git.Hash + // Missing is true if the branch no longer exists in the repository. + Missing bool +} + +// Drifted reports whether the tip's current hash differs from the stored +// hash. A missing tip is also considered drifted. +func (s TipStatus) Drifted() bool { + return s.Missing || s.CurrentHash != s.StoredHash +} + +// Show returns the current configuration and per-tip drift status. +// Returns [ErrNotConfigured] if no integration is configured. +func (h *Handler) Show(ctx context.Context) (*Status, error) { + info, err := h.loadConfigured(ctx) + if err != nil { + return nil, err + } + + out := &Status{ + Name: info.Name, + UpstreamBranch: info.UpstreamBranch, + LastPushedHash: info.LastPushedHash, + Tips: make([]TipStatus, 0, len(info.Tips)), + } + for _, tip := range info.Tips { + ts := TipStatus{Name: tip.Name, StoredHash: tip.Hash} + hash, err := h.Repository.PeelToCommit(ctx, tip.Name) + if err != nil { + ts.Missing = true + } else { + ts.CurrentHash = hash + } + out.Tips = append(out.Tips, ts) + } + return out, nil +} + +// RebuildResult summarizes a successful Rebuild operation. +type RebuildResult struct { + // Name is the integration branch name. + Name string + + // TipHashes holds the hash of each tip used in the rebuild. + TipHashes []git.Hash +} + +// ConflictError indicates that a rebuild was interrupted by a merge +// conflict. The conflict is left in the worktree for the user to +// resolve. +type ConflictError struct { + // Tip is the name of the tip whose merge conflicted. + Tip string + + // Paths are the files with unresolved conflicts. + Paths []string +} + +func (e *ConflictError) Error() string { + return fmt.Sprintf("merge of tip %q conflicted in %d file(s)", e.Tip, len(e.Paths)) +} + +// Rebuild regenerates the integration branch by sequentially merging +// each configured tip onto trunk. +// +// If a previous rebuild was interrupted by a conflict and the user has +// since resolved it (committed via 'git merge --continue'), Rebuild +// resumes from where it left off. +// +// On conflict, the merge is left in the worktree for the user to +// resolve, and a [*ConflictError] is returned. After resolving and +// committing the merge, the user re-runs Rebuild to continue. +func (h *Handler) Rebuild(ctx context.Context) (*RebuildResult, error) { + info, err := h.loadConfigured(ctx) + if err != nil { + return nil, err + } + + if err := h.ensureWorktreeSafe(ctx, info); err != nil { + return nil, err + } + + pending, err := h.Store.PendingIntegrationRebuild(ctx) + switch { + case err == nil: + if pending.Integration != info.Name { + h.Log.Warnf("Discarding pending rebuild for stale integration %q", pending.Integration) + if err := h.Store.ClearPendingIntegrationRebuild(ctx); err != nil { + return nil, fmt.Errorf("clear stale pending rebuild: %w", err) + } + pending = nil + } + case errors.Is(err, state.ErrNotExist): + pending = nil + default: + return nil, fmt.Errorf("check pending rebuild: %w", err) + } + + if pending != nil { + return h.resumeRebuild(ctx, info, pending) + } + return h.freshRebuild(ctx, info) +} + +// ensureWorktreeSafe refuses to run an integration rebuild in a worktree +// it does not own. +// +// Rebuild force-checks-out the integration branch in the current worktree, +// merges the tips, and restores the original branch on completion. Doing +// that in a linked worktree that holds a tracked feature branch silently +// reverts that worktree's working tree to trunk content (observed as an +// AUTO_MERGE artifact plus a reset-to-HEAD in the worktree's gitdir). +// +// Two cases are rejected: +// +// - The integration branch is checked out in a different worktree. +// That worktree owns it; rebuilding here would either steal it +// (failing opaquely inside git) or clobber it. +// - We are in a multi-worktree repository and the current worktree has +// a tracked feature branch checked out. Borrowing and restoring it +// would revert that worktree. A single-worktree repository is always +// safe to borrow, which is the normal interactive flow. +func (h *Handler) ensureWorktreeSafe( + ctx context.Context, + info *state.IntegrationInfo, +) error { + currentWT := h.Worktree.RootDir() + + var integrationWT, currentBranch string + var nonBare int + for item, err := range h.Repository.Worktrees(ctx) { + if err != nil { + return fmt.Errorf("list worktrees: %w", err) + } + if item.Bare { + continue + } + nonBare++ + if item.Branch == info.Name { + integrationWT = item.Path + } + if item.Path == currentWT { + currentBranch = item.Branch + } + } + + if integrationWT != "" && integrationWT != currentWT { + return fmt.Errorf( + "integration branch %q is checked out in another worktree (%s); "+ + "run the rebuild from there", + info.Name, integrationWT) + } + + // In a multi-worktree repo, refuse to hijack a worktree that has a + // tracked feature branch checked out. An untracked or detached + // checkout (currentBranch matched no branch / trunk) stays borrowable. + if nonBare > 1 && integrationWT == "" && + currentBranch != "" && + currentBranch != info.Name && + currentBranch != h.Store.Trunk() { + if _, err := h.Service.LookupBranch(ctx, currentBranch); err == nil { + return fmt.Errorf( + "refusing to rebuild integration branch %q here: "+ + "tracked branch %q is checked out in this worktree and "+ + "would be reverted; run the rebuild from the trunk or the "+ + "primary worktree, or check out %[1]q first", + info.Name, currentBranch) + } + } + + return nil +} + +func (h *Handler) freshRebuild(ctx context.Context, info *state.IntegrationInfo) (*RebuildResult, error) { + currentBranch, err := h.Worktree.CurrentBranch(ctx) + if err != nil { + return nil, fmt.Errorf("current branch: %w", err) + } + + clean, err := h.Worktree.IsClean(ctx) + if err != nil { + return nil, fmt.Errorf("check worktree: %w", err) + } + if !clean { + return nil, errors.New("worktree has uncommitted changes; commit or stash them first") + } + + trunk := h.Store.Trunk() + trunkHash, err := h.Repository.PeelToCommit(ctx, trunk) + if err != nil { + return nil, fmt.Errorf("resolve trunk %q: %w", trunk, err) + } + + tips := make([]state.IntegrationTip, 0, len(info.Tips)) + for _, tip := range info.Tips { + if _, err := h.Service.LookupBranch(ctx, tip.Name); err != nil { + return nil, fmt.Errorf("tip %q: %w", tip.Name, err) + } + hash, err := h.Repository.PeelToCommit(ctx, tip.Name) + if err != nil { + return nil, fmt.Errorf("resolve tip %q: %w", tip.Name, err) + } + tips = append(tips, state.IntegrationTip{Name: tip.Name, Hash: hash}) + } + + if err := h.Worktree.CheckoutNewBranch(ctx, git.CheckoutNewBranchRequest{ + Name: info.Name, + StartPoint: trunkHash.String(), + Force: true, + }); err != nil { + return nil, fmt.Errorf("create integration branch: %w", err) + } + + return h.runMerges(ctx, info, tips, 0, currentBranch) +} + +func (h *Handler) resumeRebuild( + ctx context.Context, + info *state.IntegrationInfo, + pending *state.IntegrationRebuild, +) (*RebuildResult, error) { + clean, err := h.Worktree.IsClean(ctx) + if err != nil { + return nil, fmt.Errorf("check worktree: %w", err) + } + if !clean { + return nil, errors.New("worktree has uncommitted changes (likely an unfinished merge); resolve and 'git merge --continue', or 'git merge --abort'") + } + + currentBranch, err := h.Worktree.CurrentBranch(ctx) + if err != nil { + return nil, fmt.Errorf("current branch: %w", err) + } + if currentBranch != info.Name { + if err := h.Worktree.CheckoutBranch(ctx, info.Name); err != nil { + return nil, fmt.Errorf("switch to integration branch: %w", err) + } + } + + h.Log.Infof("Resuming integration rebuild at tip %d of %d", + pending.NextTipIndex+1, len(pending.Tips)) + return h.runMerges(ctx, info, pending.Tips, pending.NextTipIndex, currentBranch) +} + +// runMerges merges tips[start:] onto HEAD, finalizes the rebuild on +// success, and saves pending state + returns a [*ConflictError] on +// conflict (without aborting the merge). +func (h *Handler) runMerges( + ctx context.Context, + info *state.IntegrationInfo, + tips []state.IntegrationTip, + start int, + originalBranch string, +) (*RebuildResult, error) { + for i := start; i < len(tips); i++ { + tip := tips[i] + err := h.Worktree.Merge(ctx, git.MergeOptions{ + Refs: []string{tip.Hash.String()}, + NoFF: true, + Message: fmt.Sprintf("Merge %s into %s", tip.Name, info.Name), + EnableRerere: true, + LeaveConflict: true, + }) + if err == nil { + continue + } + + conflict := new(git.MergeConflictError) + if errors.As(err, &conflict) { + if saveErr := h.Store.SetPendingIntegrationRebuild(ctx, &state.IntegrationRebuild{ + Integration: info.Name, + Tips: tips, + NextTipIndex: i + 1, + }); saveErr != nil { + h.Log.Warnf("save pending rebuild: %v", saveErr) + } + return nil, &ConflictError{Tip: tip.Name, Paths: conflict.ConflictPaths} + } + return nil, fmt.Errorf("merge tip %q: %w", tip.Name, err) + } + + info.Tips = tips + if err := h.Store.SetIntegration(ctx, info); err != nil { + return nil, fmt.Errorf("save integration state: %w", err) + } + if err := h.Store.ClearPendingIntegrationRebuild(ctx); err != nil { + h.Log.Warnf("clear pending rebuild: %v", err) + } + + if originalBranch != "" && originalBranch != info.Name { + if err := h.Worktree.CheckoutBranch(ctx, originalBranch); err != nil { + h.Log.Warnf("Could not restore branch %q: %v", originalBranch, err) + } + } + + hashes := make([]git.Hash, len(tips)) + for i, t := range tips { + hashes[i] = t.Hash + } + return &RebuildResult{ + Name: info.Name, + TipHashes: hashes, + }, nil +} + +// PushRejectedError indicates that [Handler.Submit] could not push +// because the remote integration branch already exists at a hash that +// the local state does not recognize as previously-pushed. +// +// The cause is typically one of: +// - A previous push happened via raw git, bypassing +// gs's [state.IntegrationInfo.LastPushedHash] tracking. +// - The same integration branch is being pushed from another +// checkout (multi-checkout collision — inherently lossy). +// - The spice state was reset (fresh clone, manual ref edit). +// +// Pulling is *not* the right resolution: the integration branch is a +// local throwaway with no durable upstream history. +type PushRejectedError struct { + // Branch is the local integration branch name. + Branch string + + // Remote is the configured push remote (e.g., "origin"). + Remote string + + // UpstreamBranch is the remote-side branch name. + UpstreamBranch string + + // RemoteHash is the hash currently on the remote. + RemoteHash git.Hash + + // LocalHash is the hash gs would have pushed. + LocalHash git.Hash +} + +func (e *PushRejectedError) Error() string { + return fmt.Sprintf( + "integration branch %q on %s is at %s; gs has no record of pushing it from this checkout", + e.UpstreamBranch, e.Remote, e.RemoteHash.Short(), + ) +} + +// Submit pushes the integration branch to the configured remote. +// Uses --force-with-lease against [state.IntegrationInfo.LastPushedHash] +// when available; otherwise pushes plainly. +// +// If the remote already has the branch and no LastPushedHash is +// recorded, returns [*PushRejectedError] without attempting the push. +// The user can reconcile state with [Handler.MarkPushed] and re-submit. +// +// No forge API is called; no PR is created. +func (h *Handler) Submit(ctx context.Context) error { + info, err := h.loadConfigured(ctx) + if err != nil { + return err + } + + remote, err := h.Store.Remote() + if err != nil { + return fmt.Errorf("get remote: %w", err) + } + pushRemote := remote.Push + if pushRemote == "" { + pushRemote = remote.Upstream + } + if pushRemote == "" { + return errors.New("no push remote configured") + } + + upstream := info.UpstreamBranch + if upstream == "" { + upstream = info.Name + } + + currentHash, err := h.Repository.PeelToCommit(ctx, info.Name) + if err != nil { + return fmt.Errorf("resolve integration branch: %w", err) + } + + // If there is no recorded LastPushedHash but the remote already has + // this branch, a plain push would be rejected as non-fast-forward. + // Detect this and surface a tailored error instead of letting git's + // "use git pull" hint mislead the user. + if info.LastPushedHash == "" { + remoteHash, lookupErr := h.lookupRemoteRef(ctx, pushRemote, upstream) + if lookupErr == nil && remoteHash != "" && remoteHash != currentHash { + return &PushRejectedError{ + Branch: info.Name, + Remote: pushRemote, + UpstreamBranch: upstream, + RemoteHash: remoteHash, + LocalHash: currentHash, + } + } + } + + opts := git.PushOptions{ + Remote: pushRemote, + Refspec: git.Refspec(info.Name + ":" + upstream), + } + if info.LastPushedHash != "" { + opts.ForceWithLease = upstream + ":" + info.LastPushedHash.String() + } + + if err := h.Worktree.Push(ctx, opts); err != nil { + return fmt.Errorf("push: %w", err) + } + + info.LastPushedHash = currentHash + if err := h.Store.SetIntegration(ctx, info); err != nil { + return fmt.Errorf("save integration state: %w", err) + } + return nil +} + +// lookupRemoteRef returns the hash of refs/heads/ on the named +// remote, or empty hash if the branch does not exist. +func (h *Handler) lookupRemoteRef( + ctx context.Context, remote, branch string, +) (git.Hash, error) { + for ref, err := range h.Repository.ListRemoteRefs(ctx, remote, &git.ListRemoteRefsOptions{ + Heads: true, + Patterns: []string{branch}, + }) { + if err != nil { + return "", err + } + if ref.Name == "refs/heads/"+branch { + return ref.Hash, nil + } + } + return "", nil +} + +// MarkPushed records hash as the integration branch's last-pushed +// value. If hash is empty, auto-discovers it from the configured push +// remote. +// +// Used to reconcile gs state after a [PushRejectedError]: the user +// either trusts the remote and records that hash (then subsequent +// [Handler.Submit] uses --force-with-lease to overwrite), or +// investigates the divergence first. +func (h *Handler) MarkPushed(ctx context.Context, hash git.Hash) error { + info, err := h.loadConfigured(ctx) + if err != nil { + return err + } + + if hash == "" { + remote, err := h.Store.Remote() + if err != nil { + return fmt.Errorf("get remote: %w", err) + } + pushRemote := remote.Push + if pushRemote == "" { + pushRemote = remote.Upstream + } + if pushRemote == "" { + return errors.New("no push remote configured") + } + + upstream := info.UpstreamBranch + if upstream == "" { + upstream = info.Name + } + + remoteHash, err := h.lookupRemoteRef(ctx, pushRemote, upstream) + if err != nil { + return fmt.Errorf("lookup remote ref: %w", err) + } + if remoteHash == "" { + return fmt.Errorf( + "remote %s has no branch %q to mark as pushed", + pushRemote, upstream) + } + hash = remoteHash + } + + info.LastPushedHash = hash + if err := h.Store.SetIntegration(ctx, info); err != nil { + return fmt.Errorf("save integration state: %w", err) + } + return nil +} + +// MaybeRebuild rebuilds the integration branch if any tracked tip's +// hash differs from the stored hash. No-op if not configured. +// +// A conflict during auto-rebuild is logged as a warning and does not +// return an error, since this is called from wrappers whose primary +// operation has already succeeded. +func (h *Handler) MaybeRebuild(ctx context.Context) error { + info, err := h.Store.Integration(ctx) + if errors.Is(err, state.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("get integration: %w", err) + } + + drifted, err := h.driftedTips(ctx, info) + if err != nil { + return err + } + if !drifted { + return nil + } + + h.Log.Infof("Rebuilding integration branch %q", info.Name) + if _, err := h.Rebuild(ctx); err != nil { + conflict := new(git.MergeConflictError) + if errors.As(err, &conflict) { + h.Log.Warnf("Integration rebuild failed: %v", err) + return nil + } + return err + } + return nil +} + +// MaybeRebuildAndSubmit invokes [MaybeRebuild] and, if the integration +// has previously been pushed (non-empty LastPushedHash), also submits. +// +// Used as a hook from gs stack/upstack submit. The first manual +// gs integration submit serves as the user's signal of intent to +// publish; afterward this hook keeps the branch in sync. +func (h *Handler) MaybeRebuildAndSubmit(ctx context.Context) error { + if err := h.MaybeRebuild(ctx); err != nil { + return err + } + + info, err := h.Store.Integration(ctx) + if errors.Is(err, state.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("get integration: %w", err) + } + if info.LastPushedHash == "" { + return nil + } + + h.Log.Infof("Submitting integration branch %q", info.Name) + if err := h.Submit(ctx); err != nil { + h.Log.Warnf("Integration submit failed: %v", err) + return nil + } + return nil +} + +// loadConfigured returns the current integration config or +// [ErrNotConfigured]. +func (h *Handler) loadConfigured(ctx context.Context) (*state.IntegrationInfo, error) { + info, err := h.Store.Integration(ctx) + if errors.Is(err, state.ErrNotExist) { + return nil, ErrNotConfigured + } + if err != nil { + return nil, fmt.Errorf("get integration: %w", err) + } + return info, nil +} + +// driftedTips reports whether any tip's current hash differs from its +// stored hash. +func (h *Handler) driftedTips(ctx context.Context, info *state.IntegrationInfo) (bool, error) { + for _, tip := range info.Tips { + hash, err := h.Repository.PeelToCommit(ctx, tip.Name) + if err != nil { + // A missing tip counts as drift; let Rebuild surface the + // detailed error. + return true, nil + } + if hash != tip.Hash { + return true, nil + } + } + return false, nil +} + +// validateTipName ensures the proposed tip name is a tracked branch and +// distinct from trunk/integration/itself. seen is consulted for +// duplicate detection. +func (h *Handler) validateTipName( + ctx context.Context, + integrationName, tipName string, + seen map[string]struct{}, +) error { + if tipName == "" { + return errors.New("tip name is empty") + } + if tipName == h.Store.Trunk() { + return errors.New("tip must not equal trunk") + } + if tipName == integrationName { + return errors.New("tip must not equal integration branch name") + } + if _, dup := seen[tipName]; dup { + return fmt.Errorf("duplicate tip %q", tipName) + } + if _, err := h.Service.LookupBranch(ctx, tipName); err != nil { + return fmt.Errorf("tip %q is not tracked: %w", tipName, err) + } + return nil +} diff --git a/internal/handler/integration/handler_test.go b/internal/handler/integration/handler_test.go new file mode 100644 index 000000000..8a7afe8bb --- /dev/null +++ b/internal/handler/integration/handler_test.go @@ -0,0 +1,963 @@ +package integration + +import ( + "errors" + "iter" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog/silogtest" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + gomock "go.uber.org/mock/gomock" +) + +// newHandler returns a handler whose worktree-safety guard is satisfied by +// a single, borrowable worktree (the common case). Tests that need a +// different worktree topology use newHandlerRaw and wire Worktrees/RootDir +// themselves. +func newHandler(t *testing.T) (*Handler, *handlerMocks) { + t.Helper() + h, mocks := newHandlerRaw(t) + expectBorrowableWorktree(mocks) + return h, mocks +} + +func newHandlerRaw(t *testing.T) (*Handler, *handlerMocks) { + t.Helper() + mockCtrl := gomock.NewController(t) + mocks := &handlerMocks{ + Repository: NewMockGitRepository(mockCtrl), + Worktree: NewMockGitWorktree(mockCtrl), + Store: NewMockStore(mockCtrl), + Service: NewMockService(mockCtrl), + } + h := &Handler{ + Log: silogtest.New(t), + Repository: mocks.Repository, + Worktree: mocks.Worktree, + Store: mocks.Store, + Service: mocks.Service, + } + return h, mocks +} + +type handlerMocks struct { + Repository *MockGitRepository + Worktree *MockGitWorktree + Store *MockStore + Service *MockService +} + +func TestHandler_Create_rejectsTrunkName(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + + err := h.Create(t.Context(), &CreateRequest{Name: "main"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not equal trunk") +} + +func TestHandler_Create_rejectsEmptyName(t *testing.T) { + h, _ := newHandler(t) + + err := h.Create(t.Context(), &CreateRequest{Name: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestHandler_Create_rejectsAlreadyConfigured(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + + err := h.Create(t.Context(), &CreateRequest{Name: "preview"}) + require.ErrorIs(t, err, ErrAlreadyConfigured) +} + +func TestHandler_Create_validatesTips(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "nonexistent"). + Return(nil, state.ErrNotExist) + + err := h.Create(t.Context(), &CreateRequest{ + Name: "preview", + Tips: []string{"nonexistent"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not tracked") +} + +func TestHandler_Create_persists(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-a"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-b"). + Return(&spice.LookupBranchResponse{}, nil) + + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a"}, + {Name: "feat-b"}, + }, + }). + Return(nil) + + require.NoError(t, h.Create(t.Context(), &CreateRequest{ + Name: "preview", + Tips: []string{"feat-a", "feat-b"}, + })) +} + +func TestHandler_Checkout(t *testing.T) { + t.Run("Success", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash("abc"), nil) + mocks.Worktree.EXPECT(). + CheckoutBranch(gomock.Any(), "preview"). + Return(nil) + + require.NoError(t, h.Checkout(t.Context())) + }) + + t.Run("NotConfigured", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(nil, state.ErrNotExist) + + err := h.Checkout(t.Context()) + require.ErrorIs(t, err, ErrNotConfigured) + }) + + t.Run("BranchMissing", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash(""), errors.New("not found")) + + err := h.Checkout(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") + }) +} + +func TestHandler_Delete_notConfigured(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(nil, state.ErrNotExist) + + err := h.Delete(t.Context()) + require.ErrorIs(t, err, ErrNotConfigured) +} + +func TestHandler_Delete_clears(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Nil()). + Return(nil) + + require.NoError(t, h.Delete(t.Context())) +} + +func TestHandler_AddTip(t *testing.T) { + t.Run("AppendsToList", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a"}}, + }, nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-b"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a"}, + {Name: "feat-b"}, + }, + }). + Return(nil) + + require.NoError(t, h.AddTip(t.Context(), "feat-b")) + }) + + t.Run("RejectsDuplicate", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a"}}, + }, nil) + + err := h.AddTip(t.Context(), "feat-a") + require.Error(t, err) + assert.Contains(t, err.Error(), "already configured") + }) + + t.Run("RejectsTrunk", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + + err := h.AddTip(t.Context(), "main") + require.Error(t, err) + assert.Contains(t, err.Error(), "must not equal trunk") + }) + + t.Run("RejectsIntegrationName", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + + err := h.AddTip(t.Context(), "preview") + require.Error(t, err) + assert.Contains(t, err.Error(), "must not equal integration") + }) +} + +func TestHandler_RemoveTip(t *testing.T) { + t.Run("Removes", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a"}, + {Name: "feat-b"}, + }, + }, nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-b"}}, + }). + Return(nil) + + require.NoError(t, h.RemoveTip(t.Context(), "feat-a")) + }) + + t.Run("NotConfigured", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(nil, state.ErrNotExist) + + err := h.RemoveTip(t.Context(), "feat-a") + require.ErrorIs(t, err, ErrNotConfigured) + }) + + t.Run("UnknownTip", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a"}}, + }, nil) + + err := h.RemoveTip(t.Context(), "feat-b") + require.Error(t, err) + assert.Contains(t, err.Error(), "is not configured") + }) +} + +func TestHandler_Show(t *testing.T) { + t.Run("ReportsDrift", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a", Hash: "stored-a"}, + {Name: "feat-b", Hash: "stored-b"}, + }, + }, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("stored-a"), nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-b"). + Return(git.Hash("current-b"), nil) + + st, err := h.Show(t.Context()) + require.NoError(t, err) + + require.Len(t, st.Tips, 2) + assert.False(t, st.Tips[0].Drifted()) + assert.True(t, st.Tips[1].Drifted()) + }) + + t.Run("MissingTip", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "gone", Hash: "stored"}}, + }, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "gone"). + Return(git.Hash(""), errors.New("not found")) + + st, err := h.Show(t.Context()) + require.NoError(t, err) + require.Len(t, st.Tips, 1) + assert.True(t, st.Tips[0].Missing) + assert.True(t, st.Tips[0].Drifted()) + }) +} + +func TestHandler_Rebuild(t *testing.T) { + t.Run("Success", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a"}, + {Name: "feat-b"}, + }, + }, nil) + mocks.Store.EXPECT(). + PendingIntegrationRebuild(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Worktree.EXPECT(). + CurrentBranch(gomock.Any()). + Return("main", nil) + mocks.Worktree.EXPECT(). + IsClean(gomock.Any()). + Return(true, nil) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "main"). + Return(git.Hash("trunk-hash"), nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-a"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("hash-a"), nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-b"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-b"). + Return(git.Hash("hash-b"), nil) + + mocks.Worktree.EXPECT(). + CheckoutNewBranch(gomock.Any(), git.CheckoutNewBranchRequest{ + Name: "preview", + StartPoint: "trunk-hash", + Force: true, + }). + Return(nil) + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Cond(func(o git.MergeOptions) bool { + return len(o.Refs) == 1 && o.Refs[0] == "hash-a" && + o.NoFF && o.EnableRerere + })). + Return(nil) + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Cond(func(o git.MergeOptions) bool { + return len(o.Refs) == 1 && o.Refs[0] == "hash-b" && + o.NoFF && o.EnableRerere + })). + Return(nil) + + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Cond(func(info *state.IntegrationInfo) bool { + return assert.Equal(t, []state.IntegrationTip{ + {Name: "feat-a", Hash: "hash-a"}, + {Name: "feat-b", Hash: "hash-b"}, + }, info.Tips) + })). + Return(nil) + mocks.Store.EXPECT(). + ClearPendingIntegrationRebuild(gomock.Any()). + Return(nil) + + mocks.Worktree.EXPECT(). + CheckoutBranch(gomock.Any(), "main"). + Return(nil) + + res, err := h.Rebuild(t.Context()) + require.NoError(t, err) + assert.Equal(t, "preview", res.Name) + assert.Equal(t, []git.Hash{"hash-a", "hash-b"}, res.TipHashes) + }) + + t.Run("AlreadyOnIntegrationBranch", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a"}}, + }, nil) + mocks.Store.EXPECT(). + PendingIntegrationRebuild(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Worktree.EXPECT(). + CurrentBranch(gomock.Any()). + Return("preview", nil) + mocks.Worktree.EXPECT(). + IsClean(gomock.Any()). + Return(true, nil) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "main"). + Return(git.Hash("trunk-hash"), nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-a"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("hash-a"), nil) + + mocks.Worktree.EXPECT(). + CheckoutNewBranch(gomock.Any(), git.CheckoutNewBranchRequest{ + Name: "preview", + StartPoint: "trunk-hash", + Force: true, + }). + Return(nil) + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Store.EXPECT(). + ClearPendingIntegrationRebuild(gomock.Any()). + Return(nil) + // No final CheckoutBranch call expected since we started on + // the integration branch already. + + _, err := h.Rebuild(t.Context()) + require.NoError(t, err) + }) + + t.Run("RefusesDirtyWorktree", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + PendingIntegrationRebuild(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Worktree.EXPECT(). + CurrentBranch(gomock.Any()). + Return("main", nil) + mocks.Worktree.EXPECT(). + IsClean(gomock.Any()). + Return(false, nil) + + _, err := h.Rebuild(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "uncommitted") + }) + + t.Run("ConflictSavesPending", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a"}, + {Name: "feat-b"}, + }, + }, nil) + mocks.Store.EXPECT(). + PendingIntegrationRebuild(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Worktree.EXPECT(). + CurrentBranch(gomock.Any()). + Return("main", nil) + mocks.Worktree.EXPECT(). + IsClean(gomock.Any()). + Return(true, nil) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "main"). + Return(git.Hash("trunk"), nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-a"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("a"), nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-b"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-b"). + Return(git.Hash("b"), nil) + + mocks.Worktree.EXPECT(). + CheckoutNewBranch(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Return(&git.MergeConflictError{ + Refs: []string{"a"}, + ConflictPaths: []string{"shared.txt"}, + }) + + // Pending state saved with the tip AFTER the conflicting one + // recorded as next. + mocks.Store.EXPECT(). + SetPendingIntegrationRebuild(gomock.Any(), + gomock.Cond(func(rb *state.IntegrationRebuild) bool { + return rb.Integration == "preview" && + rb.NextTipIndex == 1 && + len(rb.Tips) == 2 + })). + Return(nil) + // No CheckoutBranch: the conflict is left in the worktree. + + _, err := h.Rebuild(t.Context()) + require.Error(t, err) + var conflict *ConflictError + assert.True(t, errors.As(err, &conflict)) + assert.Equal(t, "feat-a", conflict.Tip) + }) + + t.Run("ResumeAfterConflict", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a"}, + {Name: "feat-b"}, + }, + }, nil) + mocks.Store.EXPECT(). + PendingIntegrationRebuild(gomock.Any()). + Return(&state.IntegrationRebuild{ + Integration: "preview", + Tips: []state.IntegrationTip{ + {Name: "feat-a", Hash: "a"}, + {Name: "feat-b", Hash: "b"}, + }, + NextTipIndex: 1, + }, nil) + mocks.Worktree.EXPECT(). + IsClean(gomock.Any()). + Return(true, nil) + mocks.Worktree.EXPECT(). + CurrentBranch(gomock.Any()). + Return("preview", nil) + + // Resume picks up at feat-b only. + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Cond(func(o git.MergeOptions) bool { + return o.Refs[0] == "b" + })). + Return(nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Cond(func(info *state.IntegrationInfo) bool { + return assert.Equal(t, []state.IntegrationTip{ + {Name: "feat-a", Hash: "a"}, + {Name: "feat-b", Hash: "b"}, + }, info.Tips) + })). + Return(nil) + mocks.Store.EXPECT(). + ClearPendingIntegrationRebuild(gomock.Any()). + Return(nil) + + _, err := h.Rebuild(t.Context()) + require.NoError(t, err) + }) + + t.Run("RefusesWhenIntegrationBranchInAnotherWorktree", func(t *testing.T) { + h, mocks := newHandlerRaw(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Worktree.EXPECT().RootDir().Return("/repo").AnyTimes() + mocks.Repository.EXPECT(). + Worktrees(gomock.Any()). + Return(worktreesSeq( + &git.WorktreeListItem{Path: "/repo", Branch: "main"}, + &git.WorktreeListItem{Path: "/wt/preview", Branch: "preview"}, + )) + + _, err := h.Rebuild(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "another worktree") + }) + + t.Run("RefusesWhenHijackingFeatureWorktree", func(t *testing.T) { + h, mocks := newHandlerRaw(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Worktree.EXPECT().RootDir().Return("/wt/feat").AnyTimes() + mocks.Repository.EXPECT(). + Worktrees(gomock.Any()). + Return(worktreesSeq( + &git.WorktreeListItem{Path: "/repo", Branch: "main"}, + &git.WorktreeListItem{Path: "/wt/feat", Branch: "feat-x"}, + )) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-x"). + Return(&spice.LookupBranchResponse{}, nil) + + _, err := h.Rebuild(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "would be reverted") + }) +} + +func TestHandler_Submit(t *testing.T) { + t.Run("ForceWithLease", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + LastPushedHash: "old", + }, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash("new"), nil) + mocks.Worktree.EXPECT(). + Push(gomock.Any(), gomock.Cond(func(opts git.PushOptions) bool { + return opts.Remote == "origin" && + opts.ForceWithLease == "preview:old" && + opts.Refspec == "preview:preview" + })). + Return(nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Cond(func(info *state.IntegrationInfo) bool { + return info.LastPushedHash == "new" + })). + Return(nil) + + require.NoError(t, h.Submit(t.Context())) + }) + + t.Run("FirstPushPlain", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash("hash"), nil) + // No LastPushedHash + no remote branch → plain push proceeds. + mocks.Repository.EXPECT(). + ListRemoteRefs(gomock.Any(), "origin", gomock.Any()). + Return(emptyRemoteRefs()) + mocks.Worktree.EXPECT(). + Push(gomock.Any(), gomock.Cond(func(opts git.PushOptions) bool { + return opts.ForceWithLease == "" && !opts.Force + })). + Return(nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + + require.NoError(t, h.Submit(t.Context())) + }) + + t.Run("ForkModeUsesPushRemote", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "upstream", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash("hash"), nil) + mocks.Repository.EXPECT(). + ListRemoteRefs(gomock.Any(), "origin", gomock.Any()). + Return(emptyRemoteRefs()) + mocks.Worktree.EXPECT(). + Push(gomock.Any(), gomock.Cond(func(opts git.PushOptions) bool { + return opts.Remote == "origin" + })). + Return(nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + + require.NoError(t, h.Submit(t.Context())) + }) + + t.Run("RejectedWhenRemoteAheadOfState", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash("local-hash"), nil) + // No LastPushedHash + remote branch exists → reject. + mocks.Repository.EXPECT(). + ListRemoteRefs(gomock.Any(), "origin", gomock.Any()). + Return(singleRemoteRef(git.RemoteRef{ + Name: "refs/heads/preview", + Hash: git.Hash("remote-hash"), + })) + + err := h.Submit(t.Context()) + require.Error(t, err) + var rejected *PushRejectedError + require.ErrorAs(t, err, &rejected) + assert.Equal(t, "preview", rejected.UpstreamBranch) + assert.Equal(t, "origin", rejected.Remote) + assert.Equal(t, git.Hash("remote-hash"), rejected.RemoteHash) + assert.Equal(t, git.Hash("local-hash"), rejected.LocalHash) + }) + + t.Run("ForceWithLeaseSkipsRemoteProbe", func(t *testing.T) { + // With LastPushedHash set, Submit uses --force-with-lease; + // no remote probe is needed. + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + LastPushedHash: "old", + }, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "preview"). + Return(git.Hash("new"), nil) + mocks.Worktree.EXPECT(). + Push(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + + require.NoError(t, h.Submit(t.Context())) + }) +} + +func TestHandler_MarkPushed(t *testing.T) { + t.Run("ExplicitHash", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Cond(func(info *state.IntegrationInfo) bool { + return info.LastPushedHash == "explicit-hash" + })). + Return(nil) + + require.NoError(t, + h.MarkPushed(t.Context(), git.Hash("explicit-hash"))) + }) + + t.Run("AutoDiscoversFromRemote", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + ListRemoteRefs(gomock.Any(), "origin", gomock.Any()). + Return(singleRemoteRef(git.RemoteRef{ + Name: "refs/heads/preview", + Hash: git.Hash("discovered"), + })) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Cond(func(info *state.IntegrationInfo) bool { + return info.LastPushedHash == "discovered" + })). + Return(nil) + + require.NoError(t, h.MarkPushed(t.Context(), "")) + }) + + t.Run("AutoDiscoverFailsWhenRemoteEmpty", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{Name: "preview"}, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + ListRemoteRefs(gomock.Any(), "origin", gomock.Any()). + Return(emptyRemoteRefs()) + + err := h.MarkPushed(t.Context(), "") + require.Error(t, err) + assert.Contains(t, err.Error(), "no branch") + }) + + t.Run("UsesUpstreamBranchOverride", func(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + UpstreamBranch: "remote-preview", + }, nil) + mocks.Store.EXPECT(). + Remote(). + Return(state.Remote{Upstream: "origin", Push: "origin"}, nil) + mocks.Repository.EXPECT(). + ListRemoteRefs(gomock.Any(), "origin", + gomock.Cond(func(opts *git.ListRemoteRefsOptions) bool { + return len(opts.Patterns) == 1 && opts.Patterns[0] == "remote-preview" + })). + Return(singleRemoteRef(git.RemoteRef{ + Name: "refs/heads/remote-preview", + Hash: git.Hash("hash"), + })) + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + + require.NoError(t, h.MarkPushed(t.Context(), "")) + }) +} + +func TestHandler_MaybeRebuild_noConfig(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(nil, state.ErrNotExist) + + require.NoError(t, h.MaybeRebuild(t.Context())) +} + +func TestHandler_MaybeRebuild_noDrift(t *testing.T) { + h, mocks := newHandler(t) + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a", Hash: "abc"}}, + }, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("abc"), nil) + + require.NoError(t, h.MaybeRebuild(t.Context())) +} + +func TestHandler_MaybeRebuildAndSubmit_skipsWhenNotPreviouslyPushed(t *testing.T) { + h, mocks := newHandler(t) + // First call: MaybeRebuild - returns no-drift no-op + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a", Hash: "abc"}}, + }, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("abc"), nil) + // Second call: from MaybeRebuildAndSubmit checking LastPushedHash + mocks.Store.EXPECT(). + Integration(gomock.Any()). + Return(&state.IntegrationInfo{ + Name: "preview", + LastPushedHash: "", // never pushed + }, nil) + + require.NoError(t, h.MaybeRebuildAndSubmit(t.Context())) +} + +// worktreesSeq returns an iter.Seq2 yielding the given worktree items, +// for mocking GitRepository.Worktrees. +func worktreesSeq(items ...*git.WorktreeListItem) iter.Seq2[*git.WorktreeListItem, error] { + return func(yield func(*git.WorktreeListItem, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } +} + +// expectBorrowableWorktree wires the worktree-safety guard for a +// single-worktree (always borrowable) repository rooted at /repo. +func expectBorrowableWorktree(mocks *handlerMocks) { + mocks.Worktree.EXPECT().RootDir().Return("/repo").AnyTimes() + mocks.Repository.EXPECT(). + Worktrees(gomock.Any()). + Return(worktreesSeq(&git.WorktreeListItem{Path: "/repo"})). + AnyTimes() +} + +// emptyRemoteRefs returns an empty iter.Seq2 for use as a mock return +// value when the remote has no matching branch. +func emptyRemoteRefs() iter.Seq2[git.RemoteRef, error] { + return func(_ func(git.RemoteRef, error) bool) {} +} + +// singleRemoteRef returns an iter.Seq2 that yields ref once. +func singleRemoteRef(ref git.RemoteRef) iter.Seq2[git.RemoteRef, error] { + return func(yield func(git.RemoteRef, error) bool) { + yield(ref, nil) + } +} diff --git a/internal/handler/integration/mocks_test.go b/internal/handler/integration/mocks_test.go new file mode 100644 index 000000000..928ef7a5e --- /dev/null +++ b/internal/handler/integration/mocks_test.go @@ -0,0 +1,807 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: go.abhg.dev/gs/internal/handler/integration (interfaces: GitRepository,GitWorktree,Store,Service) +// +// Generated by this command: +// +// mockgen -typed -destination mocks_test.go -package integration -write_package_comment=false . GitRepository,GitWorktree,Store,Service +// + +package integration + +import ( + context "context" + iter "iter" + reflect "reflect" + + git "go.abhg.dev/gs/internal/git" + spice "go.abhg.dev/gs/internal/spice" + state "go.abhg.dev/gs/internal/spice/state" + gomock "go.uber.org/mock/gomock" +) + +// MockGitRepository is a mock of GitRepository interface. +type MockGitRepository struct { + ctrl *gomock.Controller + recorder *MockGitRepositoryMockRecorder + isgomock struct{} +} + +// MockGitRepositoryMockRecorder is the mock recorder for MockGitRepository. +type MockGitRepositoryMockRecorder struct { + mock *MockGitRepository +} + +// NewMockGitRepository creates a new mock instance. +func NewMockGitRepository(ctrl *gomock.Controller) *MockGitRepository { + mock := &MockGitRepository{ctrl: ctrl} + mock.recorder = &MockGitRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitRepository) EXPECT() *MockGitRepositoryMockRecorder { + return m.recorder +} + +// ListRemoteRefs mocks base method. +func (m *MockGitRepository) ListRemoteRefs(ctx context.Context, remote string, opts *git.ListRemoteRefsOptions) iter.Seq2[git.RemoteRef, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRemoteRefs", ctx, remote, opts) + ret0, _ := ret[0].(iter.Seq2[git.RemoteRef, error]) + return ret0 +} + +// ListRemoteRefs indicates an expected call of ListRemoteRefs. +func (mr *MockGitRepositoryMockRecorder) ListRemoteRefs(ctx, remote, opts any) *MockGitRepositoryListRemoteRefsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRemoteRefs", reflect.TypeOf((*MockGitRepository)(nil).ListRemoteRefs), ctx, remote, opts) + return &MockGitRepositoryListRemoteRefsCall{Call: call} +} + +// MockGitRepositoryListRemoteRefsCall wrap *gomock.Call +type MockGitRepositoryListRemoteRefsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitRepositoryListRemoteRefsCall) Return(arg0 iter.Seq2[git.RemoteRef, error]) *MockGitRepositoryListRemoteRefsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitRepositoryListRemoteRefsCall) Do(f func(context.Context, string, *git.ListRemoteRefsOptions) iter.Seq2[git.RemoteRef, error]) *MockGitRepositoryListRemoteRefsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitRepositoryListRemoteRefsCall) DoAndReturn(f func(context.Context, string, *git.ListRemoteRefsOptions) iter.Seq2[git.RemoteRef, error]) *MockGitRepositoryListRemoteRefsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PeelToCommit mocks base method. +func (m *MockGitRepository) PeelToCommit(ctx context.Context, ref string) (git.Hash, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PeelToCommit", ctx, ref) + ret0, _ := ret[0].(git.Hash) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PeelToCommit indicates an expected call of PeelToCommit. +func (mr *MockGitRepositoryMockRecorder) PeelToCommit(ctx, ref any) *MockGitRepositoryPeelToCommitCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PeelToCommit", reflect.TypeOf((*MockGitRepository)(nil).PeelToCommit), ctx, ref) + return &MockGitRepositoryPeelToCommitCall{Call: call} +} + +// MockGitRepositoryPeelToCommitCall wrap *gomock.Call +type MockGitRepositoryPeelToCommitCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitRepositoryPeelToCommitCall) Return(arg0 git.Hash, arg1 error) *MockGitRepositoryPeelToCommitCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitRepositoryPeelToCommitCall) Do(f func(context.Context, string) (git.Hash, error)) *MockGitRepositoryPeelToCommitCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitRepositoryPeelToCommitCall) DoAndReturn(f func(context.Context, string) (git.Hash, error)) *MockGitRepositoryPeelToCommitCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Worktrees mocks base method. +func (m *MockGitRepository) Worktrees(ctx context.Context) iter.Seq2[*git.WorktreeListItem, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Worktrees", ctx) + ret0, _ := ret[0].(iter.Seq2[*git.WorktreeListItem, error]) + return ret0 +} + +// Worktrees indicates an expected call of Worktrees. +func (mr *MockGitRepositoryMockRecorder) Worktrees(ctx any) *MockGitRepositoryWorktreesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Worktrees", reflect.TypeOf((*MockGitRepository)(nil).Worktrees), ctx) + return &MockGitRepositoryWorktreesCall{Call: call} +} + +// MockGitRepositoryWorktreesCall wrap *gomock.Call +type MockGitRepositoryWorktreesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitRepositoryWorktreesCall) Return(arg0 iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitRepositoryWorktreesCall) Do(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitRepositoryWorktreesCall) DoAndReturn(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockGitWorktree is a mock of GitWorktree interface. +type MockGitWorktree struct { + ctrl *gomock.Controller + recorder *MockGitWorktreeMockRecorder + isgomock struct{} +} + +// MockGitWorktreeMockRecorder is the mock recorder for MockGitWorktree. +type MockGitWorktreeMockRecorder struct { + mock *MockGitWorktree +} + +// NewMockGitWorktree creates a new mock instance. +func NewMockGitWorktree(ctrl *gomock.Controller) *MockGitWorktree { + mock := &MockGitWorktree{ctrl: ctrl} + mock.recorder = &MockGitWorktreeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitWorktree) EXPECT() *MockGitWorktreeMockRecorder { + return m.recorder +} + +// CheckoutBranch mocks base method. +func (m *MockGitWorktree) CheckoutBranch(ctx context.Context, branch string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckoutBranch", ctx, branch) + ret0, _ := ret[0].(error) + return ret0 +} + +// CheckoutBranch indicates an expected call of CheckoutBranch. +func (mr *MockGitWorktreeMockRecorder) CheckoutBranch(ctx, branch any) *MockGitWorktreeCheckoutBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckoutBranch", reflect.TypeOf((*MockGitWorktree)(nil).CheckoutBranch), ctx, branch) + return &MockGitWorktreeCheckoutBranchCall{Call: call} +} + +// MockGitWorktreeCheckoutBranchCall wrap *gomock.Call +type MockGitWorktreeCheckoutBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeCheckoutBranchCall) Return(arg0 error) *MockGitWorktreeCheckoutBranchCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeCheckoutBranchCall) Do(f func(context.Context, string) error) *MockGitWorktreeCheckoutBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeCheckoutBranchCall) DoAndReturn(f func(context.Context, string) error) *MockGitWorktreeCheckoutBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CheckoutNewBranch mocks base method. +func (m *MockGitWorktree) CheckoutNewBranch(ctx context.Context, req git.CheckoutNewBranchRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckoutNewBranch", ctx, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// CheckoutNewBranch indicates an expected call of CheckoutNewBranch. +func (mr *MockGitWorktreeMockRecorder) CheckoutNewBranch(ctx, req any) *MockGitWorktreeCheckoutNewBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckoutNewBranch", reflect.TypeOf((*MockGitWorktree)(nil).CheckoutNewBranch), ctx, req) + return &MockGitWorktreeCheckoutNewBranchCall{Call: call} +} + +// MockGitWorktreeCheckoutNewBranchCall wrap *gomock.Call +type MockGitWorktreeCheckoutNewBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeCheckoutNewBranchCall) Return(arg0 error) *MockGitWorktreeCheckoutNewBranchCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeCheckoutNewBranchCall) Do(f func(context.Context, git.CheckoutNewBranchRequest) error) *MockGitWorktreeCheckoutNewBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeCheckoutNewBranchCall) DoAndReturn(f func(context.Context, git.CheckoutNewBranchRequest) error) *MockGitWorktreeCheckoutNewBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CurrentBranch mocks base method. +func (m *MockGitWorktree) CurrentBranch(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CurrentBranch", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CurrentBranch indicates an expected call of CurrentBranch. +func (mr *MockGitWorktreeMockRecorder) CurrentBranch(ctx any) *MockGitWorktreeCurrentBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentBranch", reflect.TypeOf((*MockGitWorktree)(nil).CurrentBranch), ctx) + return &MockGitWorktreeCurrentBranchCall{Call: call} +} + +// MockGitWorktreeCurrentBranchCall wrap *gomock.Call +type MockGitWorktreeCurrentBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeCurrentBranchCall) Return(arg0 string, arg1 error) *MockGitWorktreeCurrentBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeCurrentBranchCall) Do(f func(context.Context) (string, error)) *MockGitWorktreeCurrentBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeCurrentBranchCall) DoAndReturn(f func(context.Context) (string, error)) *MockGitWorktreeCurrentBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// IsClean mocks base method. +func (m *MockGitWorktree) IsClean(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsClean", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsClean indicates an expected call of IsClean. +func (mr *MockGitWorktreeMockRecorder) IsClean(ctx any) *MockGitWorktreeIsCleanCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClean", reflect.TypeOf((*MockGitWorktree)(nil).IsClean), ctx) + return &MockGitWorktreeIsCleanCall{Call: call} +} + +// MockGitWorktreeIsCleanCall wrap *gomock.Call +type MockGitWorktreeIsCleanCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeIsCleanCall) Return(arg0 bool, arg1 error) *MockGitWorktreeIsCleanCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeIsCleanCall) Do(f func(context.Context) (bool, error)) *MockGitWorktreeIsCleanCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeIsCleanCall) DoAndReturn(f func(context.Context) (bool, error)) *MockGitWorktreeIsCleanCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Merge mocks base method. +func (m *MockGitWorktree) Merge(ctx context.Context, opts git.MergeOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Merge", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// Merge indicates an expected call of Merge. +func (mr *MockGitWorktreeMockRecorder) Merge(ctx, opts any) *MockGitWorktreeMergeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Merge", reflect.TypeOf((*MockGitWorktree)(nil).Merge), ctx, opts) + return &MockGitWorktreeMergeCall{Call: call} +} + +// MockGitWorktreeMergeCall wrap *gomock.Call +type MockGitWorktreeMergeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeMergeCall) Return(arg0 error) *MockGitWorktreeMergeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeMergeCall) Do(f func(context.Context, git.MergeOptions) error) *MockGitWorktreeMergeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeMergeCall) DoAndReturn(f func(context.Context, git.MergeOptions) error) *MockGitWorktreeMergeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Push mocks base method. +func (m *MockGitWorktree) Push(ctx context.Context, opts git.PushOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Push", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// Push indicates an expected call of Push. +func (mr *MockGitWorktreeMockRecorder) Push(ctx, opts any) *MockGitWorktreePushCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockGitWorktree)(nil).Push), ctx, opts) + return &MockGitWorktreePushCall{Call: call} +} + +// MockGitWorktreePushCall wrap *gomock.Call +type MockGitWorktreePushCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreePushCall) Return(arg0 error) *MockGitWorktreePushCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreePushCall) Do(f func(context.Context, git.PushOptions) error) *MockGitWorktreePushCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreePushCall) DoAndReturn(f func(context.Context, git.PushOptions) error) *MockGitWorktreePushCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// RootDir mocks base method. +func (m *MockGitWorktree) RootDir() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RootDir") + ret0, _ := ret[0].(string) + return ret0 +} + +// RootDir indicates an expected call of RootDir. +func (mr *MockGitWorktreeMockRecorder) RootDir() *MockGitWorktreeRootDirCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootDir", reflect.TypeOf((*MockGitWorktree)(nil).RootDir)) + return &MockGitWorktreeRootDirCall{Call: call} +} + +// MockGitWorktreeRootDirCall wrap *gomock.Call +type MockGitWorktreeRootDirCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeRootDirCall) Return(arg0 string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeRootDirCall) Do(f func() string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeRootDirCall) DoAndReturn(f func() string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder + isgomock struct{} +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// ClearPendingIntegrationRebuild mocks base method. +func (m *MockStore) ClearPendingIntegrationRebuild(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearPendingIntegrationRebuild", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// ClearPendingIntegrationRebuild indicates an expected call of ClearPendingIntegrationRebuild. +func (mr *MockStoreMockRecorder) ClearPendingIntegrationRebuild(ctx any) *MockStoreClearPendingIntegrationRebuildCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearPendingIntegrationRebuild", reflect.TypeOf((*MockStore)(nil).ClearPendingIntegrationRebuild), ctx) + return &MockStoreClearPendingIntegrationRebuildCall{Call: call} +} + +// MockStoreClearPendingIntegrationRebuildCall wrap *gomock.Call +type MockStoreClearPendingIntegrationRebuildCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreClearPendingIntegrationRebuildCall) Return(arg0 error) *MockStoreClearPendingIntegrationRebuildCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreClearPendingIntegrationRebuildCall) Do(f func(context.Context) error) *MockStoreClearPendingIntegrationRebuildCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreClearPendingIntegrationRebuildCall) DoAndReturn(f func(context.Context) error) *MockStoreClearPendingIntegrationRebuildCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Integration mocks base method. +func (m *MockStore) Integration(ctx context.Context) (*state.IntegrationInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Integration", ctx) + ret0, _ := ret[0].(*state.IntegrationInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Integration indicates an expected call of Integration. +func (mr *MockStoreMockRecorder) Integration(ctx any) *MockStoreIntegrationCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Integration", reflect.TypeOf((*MockStore)(nil).Integration), ctx) + return &MockStoreIntegrationCall{Call: call} +} + +// MockStoreIntegrationCall wrap *gomock.Call +type MockStoreIntegrationCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreIntegrationCall) Return(arg0 *state.IntegrationInfo, arg1 error) *MockStoreIntegrationCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreIntegrationCall) Do(f func(context.Context) (*state.IntegrationInfo, error)) *MockStoreIntegrationCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreIntegrationCall) DoAndReturn(f func(context.Context) (*state.IntegrationInfo, error)) *MockStoreIntegrationCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PendingIntegrationRebuild mocks base method. +func (m *MockStore) PendingIntegrationRebuild(ctx context.Context) (*state.IntegrationRebuild, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PendingIntegrationRebuild", ctx) + ret0, _ := ret[0].(*state.IntegrationRebuild) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PendingIntegrationRebuild indicates an expected call of PendingIntegrationRebuild. +func (mr *MockStoreMockRecorder) PendingIntegrationRebuild(ctx any) *MockStorePendingIntegrationRebuildCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PendingIntegrationRebuild", reflect.TypeOf((*MockStore)(nil).PendingIntegrationRebuild), ctx) + return &MockStorePendingIntegrationRebuildCall{Call: call} +} + +// MockStorePendingIntegrationRebuildCall wrap *gomock.Call +type MockStorePendingIntegrationRebuildCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStorePendingIntegrationRebuildCall) Return(arg0 *state.IntegrationRebuild, arg1 error) *MockStorePendingIntegrationRebuildCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStorePendingIntegrationRebuildCall) Do(f func(context.Context) (*state.IntegrationRebuild, error)) *MockStorePendingIntegrationRebuildCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStorePendingIntegrationRebuildCall) DoAndReturn(f func(context.Context) (*state.IntegrationRebuild, error)) *MockStorePendingIntegrationRebuildCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Remote mocks base method. +func (m *MockStore) Remote() (state.Remote, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remote") + ret0, _ := ret[0].(state.Remote) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Remote indicates an expected call of Remote. +func (mr *MockStoreMockRecorder) Remote() *MockStoreRemoteCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remote", reflect.TypeOf((*MockStore)(nil).Remote)) + return &MockStoreRemoteCall{Call: call} +} + +// MockStoreRemoteCall wrap *gomock.Call +type MockStoreRemoteCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreRemoteCall) Return(arg0 state.Remote, arg1 error) *MockStoreRemoteCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreRemoteCall) Do(f func() (state.Remote, error)) *MockStoreRemoteCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreRemoteCall) DoAndReturn(f func() (state.Remote, error)) *MockStoreRemoteCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SetIntegration mocks base method. +func (m *MockStore) SetIntegration(ctx context.Context, info *state.IntegrationInfo) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetIntegration", ctx, info) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetIntegration indicates an expected call of SetIntegration. +func (mr *MockStoreMockRecorder) SetIntegration(ctx, info any) *MockStoreSetIntegrationCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetIntegration", reflect.TypeOf((*MockStore)(nil).SetIntegration), ctx, info) + return &MockStoreSetIntegrationCall{Call: call} +} + +// MockStoreSetIntegrationCall wrap *gomock.Call +type MockStoreSetIntegrationCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreSetIntegrationCall) Return(arg0 error) *MockStoreSetIntegrationCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreSetIntegrationCall) Do(f func(context.Context, *state.IntegrationInfo) error) *MockStoreSetIntegrationCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreSetIntegrationCall) DoAndReturn(f func(context.Context, *state.IntegrationInfo) error) *MockStoreSetIntegrationCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SetPendingIntegrationRebuild mocks base method. +func (m *MockStore) SetPendingIntegrationRebuild(ctx context.Context, rb *state.IntegrationRebuild) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetPendingIntegrationRebuild", ctx, rb) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetPendingIntegrationRebuild indicates an expected call of SetPendingIntegrationRebuild. +func (mr *MockStoreMockRecorder) SetPendingIntegrationRebuild(ctx, rb any) *MockStoreSetPendingIntegrationRebuildCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPendingIntegrationRebuild", reflect.TypeOf((*MockStore)(nil).SetPendingIntegrationRebuild), ctx, rb) + return &MockStoreSetPendingIntegrationRebuildCall{Call: call} +} + +// MockStoreSetPendingIntegrationRebuildCall wrap *gomock.Call +type MockStoreSetPendingIntegrationRebuildCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreSetPendingIntegrationRebuildCall) Return(arg0 error) *MockStoreSetPendingIntegrationRebuildCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreSetPendingIntegrationRebuildCall) Do(f func(context.Context, *state.IntegrationRebuild) error) *MockStoreSetPendingIntegrationRebuildCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreSetPendingIntegrationRebuildCall) DoAndReturn(f func(context.Context, *state.IntegrationRebuild) error) *MockStoreSetPendingIntegrationRebuildCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Trunk mocks base method. +func (m *MockStore) Trunk() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Trunk") + ret0, _ := ret[0].(string) + return ret0 +} + +// Trunk indicates an expected call of Trunk. +func (mr *MockStoreMockRecorder) Trunk() *MockStoreTrunkCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trunk", reflect.TypeOf((*MockStore)(nil).Trunk)) + return &MockStoreTrunkCall{Call: call} +} + +// MockStoreTrunkCall wrap *gomock.Call +type MockStoreTrunkCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreTrunkCall) Return(arg0 string) *MockStoreTrunkCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreTrunkCall) Do(f func() string) *MockStoreTrunkCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreTrunkCall) DoAndReturn(f func() string) *MockStoreTrunkCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder + isgomock struct{} +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// LookupBranch mocks base method. +func (m *MockService) LookupBranch(ctx context.Context, name string) (*spice.LookupBranchResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LookupBranch", ctx, name) + ret0, _ := ret[0].(*spice.LookupBranchResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LookupBranch indicates an expected call of LookupBranch. +func (mr *MockServiceMockRecorder) LookupBranch(ctx, name any) *MockServiceLookupBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupBranch", reflect.TypeOf((*MockService)(nil).LookupBranch), ctx, name) + return &MockServiceLookupBranchCall{Call: call} +} + +// MockServiceLookupBranchCall wrap *gomock.Call +type MockServiceLookupBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockServiceLookupBranchCall) Return(arg0 *spice.LookupBranchResponse, arg1 error) *MockServiceLookupBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockServiceLookupBranchCall) Do(f func(context.Context, string) (*spice.LookupBranchResponse, error)) *MockServiceLookupBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockServiceLookupBranchCall) DoAndReturn(f func(context.Context, string) (*spice.LookupBranchResponse, error)) *MockServiceLookupBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +}