diff --git a/internal/runtime/executor/helps/responses_ttft_helpers_test.go b/internal/runtime/executor/helps/responses_ttft_helpers_test.go index 4472e37d..434c66fe 100644 --- a/internal/runtime/executor/helps/responses_ttft_helpers_test.go +++ b/internal/runtime/executor/helps/responses_ttft_helpers_test.go @@ -3,6 +3,7 @@ package helps import ( "context" "testing" + "time" ) func TestIsResponsesTokenEvent_Classification(t *testing.T) { @@ -247,6 +248,7 @@ func TestObserveResponsesTokenEvent_Behavior(t *testing.T) { ctx := context.Background() reporter := NewUsageReporter(ctx, "codex", "gpt-5.6-luna", nil) reporter.StartResponseTTFT() + time.Sleep(10 * time.Millisecond) // 1. Initial state: TTFT not set if reporter.IsTTFTSet() { diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index 496deb33..82eefcda 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -599,6 +599,7 @@ func TestUsageReporterTrackHTTPClientRoundTripOnly_DoesNotTriggerOnBodyRead(t *t reporter := NewUsageReporter(context.Background(), "codex", "gpt-5.6-luna", nil) client := reporter.TrackHTTPClientRoundTripOnly(&http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + time.Sleep(10 * time.Millisecond) return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index 0bea92a5..ebe01e8a 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -23,6 +23,7 @@ import ( "github.com/go-git/go-git/v6/plumbing/transport/http" "github.com/go-git/go-git/v6/storage/filesystem/dotgit" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" ) const ( @@ -114,7 +115,7 @@ func (s *GitTokenStore) EnsureRepository() error { return s.ensureRepositoryLocked() } -func (s *GitTokenStore) ensureRepositoryLocked() error { +func (s *GitTokenStore) ensureRepositoryLocked() (errResult error) { s.dirLock.Lock() if s.remote == "" { s.dirLock.Unlock() @@ -149,7 +150,7 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { if s.branch != "" { cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch) } - if _, errClone := git.PlainClone(repoDir, cloneOpts); errClone != nil { + if cloned, errClone := git.PlainClone(repoDir, cloneOpts); errClone != nil { if errors.Is(errClone, transport.ErrEmptyRemoteRepository) { _ = os.RemoveAll(gitDir) repo, errInit := git.PlainInit(repoDir, false) @@ -157,6 +158,16 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: init empty repo: %w", errInit) } + defer func() { + if errClose := repo.Close(); errClose != nil { + errCloseWrap := fmt.Errorf("git token store: close initialized empty repo: %w", errClose) + if errResult == nil { + errResult = errCloseWrap + } else { + errResult = errors.Join(errResult, errCloseWrap) + } + } + }() if s.branch != "" { headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(s.branch)) if errHead := repo.Storer.SetReference(headRef); errHead != nil { @@ -197,6 +208,11 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: clone remote: %w", errClone) } + } else if cloned != nil { + if errClose := cloned.Close(); errClose != nil { + s.dirLock.Unlock() + return fmt.Errorf("git token store: close cloned repo: %w", errClose) + } } } else if err != nil { s.dirLock.Unlock() @@ -207,6 +223,18 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: open repo: %w", errOpen) } + defer func() { + if repo != nil { + if errClose := repo.Close(); errClose != nil { + errCloseWrap := fmt.Errorf("git token store: close repo: %w", errClose) + if errResult == nil { + errResult = errCloseWrap + } else { + errResult = errors.Join(errResult, errCloseWrap) + } + } + } + }() worktree, errWorktree := repo.Worktree() if errWorktree != nil { s.dirLock.Unlock() @@ -217,9 +245,11 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: verify repository before pull: %w", errVerify) } - if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, nil, nil); errRecover != nil { + errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, nil, nil, (*git.Repository).Close, os.Rename) + repo = nil + if errRecover != nil { s.dirLock.Unlock() - return fmt.Errorf("git token store: verify repository before pull: %w; recovery failed: %v", errVerify, errRecover) + return fmt.Errorf("git token store: verify repository before pull: %w; recovery failed: %w", errVerify, errRecover) } repo, errOpen = git.PlainOpen(repoDir) if errOpen != nil { @@ -282,9 +312,11 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: repair index after up-to-date pull: %w", errReset) } - if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename) + repo = nil + if errRecover != nil { s.dirLock.Unlock() - return fmt.Errorf("git token store: repair index after up-to-date pull: %w; recovery failed: %v", errReset, errRecover) + return fmt.Errorf("git token store: repair index after up-to-date pull: %w; recovery failed: %w", errReset, errRecover) } repositoryRecovered = true } @@ -298,9 +330,11 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: reconcile remote changes: %w", errReconcile) } - if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename) + repo = nil + if errRecover != nil { s.dirLock.Unlock() - return fmt.Errorf("git token store: reconcile remote changes: %w; recovery failed: %v", errReconcile, errRecover) + return fmt.Errorf("git token store: reconcile remote changes: %w; recovery failed: %w", errReconcile, errRecover) } repositoryRecovered = true } @@ -314,9 +348,11 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { } // Ignore missing references only when following the remote default branch. case isRepositoryCorruptionError(errPull): - if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename) + repo = nil + if errRecover != nil { s.dirLock.Unlock() - return fmt.Errorf("git token store: pull: %w; recovery failed: %v", errPull, errRecover) + return fmt.Errorf("git token store: pull: %w; recovery failed: %w", errPull, errRecover) } repositoryRecovered = true default: @@ -330,9 +366,11 @@ func (s *GitTokenStore) ensureRepositoryLocked() error { s.dirLock.Unlock() return fmt.Errorf("git token store: verify repository after pull: %w", errVerify) } - if errRecover := s.recoverRepositoryLocked(repoDir, authMethod, prePullTree, dirtyPaths); errRecover != nil { + errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename) + repo = nil + if errRecover != nil { s.dirLock.Unlock() - return fmt.Errorf("git token store: verify repository after pull: %w; recovery failed: %v", errVerify, errRecover) + return fmt.Errorf("git token store: verify repository after pull: %w; recovery failed: %w", errVerify, errRecover) } repositoryRecovered = true } @@ -577,7 +615,7 @@ func (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, path return s.commitAndPushLocked(message, filtered...) } -func (s *GitTokenStore) guardWatcherAuthRemovalLocked(message string, relPaths []string) (bool, error) { +func (s *GitTokenStore) guardWatcherAuthRemovalLocked(message string, relPaths []string) (handled bool, errResult error) { if !strings.HasPrefix(strings.TrimSpace(message), "Remove auth ") { return false, nil } @@ -589,6 +627,16 @@ func (s *GitTokenStore) guardWatcherAuthRemovalLocked(message string, relPaths [ if errOpen != nil { return true, fmt.Errorf("git token store: open repo for watcher removal guard: %w", errOpen) } + defer func() { + if errClose := repo.Close(); errClose != nil { + errCloseWrap := fmt.Errorf("git token store: close repo for watcher removal guard: %w", errClose) + if errResult == nil { + errResult = errCloseWrap + } else { + errResult = errors.Join(errResult, errCloseWrap) + } + } + }() head, errHead := repo.Head() if errHead != nil { if errors.Is(errHead, plumbing.ErrReferenceNotFound) { @@ -763,11 +811,21 @@ func (s *GitTokenStore) repoDirSnapshot() string { return s.repoDir } -func disableGitCommitSigning(repoDir string) error { +func disableGitCommitSigning(repoDir string) (errResult error) { repo, errOpen := git.PlainOpen(repoDir) if errOpen != nil { return fmt.Errorf("git token store: open repository config: %w", errOpen) } + defer func() { + if errClose := repo.Close(); errClose != nil { + errCloseWrap := fmt.Errorf("git token store: close repository config: %w", errClose) + if errResult == nil { + errResult = errCloseWrap + } else { + errResult = errors.Join(errResult, errCloseWrap) + } + } + }() cfg, errConfig := repo.Config() if errConfig != nil { return fmt.Errorf("git token store: get repository config: %w", errConfig) @@ -1103,10 +1161,33 @@ func applyTreePaths(tree *object.Tree, repoDir string, paths []string) error { return nil } -func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []client.Option, baselineTree *object.Tree, dirtyPaths map[string]struct{}) (errRecovery error) { +func closeRecoveryRepositories(closeRepository func(*git.Repository) error, baselineRepo, clonedRepo *git.Repository) error { + var errClose error + if baselineRepo != nil { + if err := closeRepository(baselineRepo); err != nil { + errClose = errors.Join(errClose, fmt.Errorf("close recovery baseline repository: %w", err)) + } + } + if clonedRepo != nil { + if err := closeRepository(clonedRepo); err != nil { + errClose = errors.Join(errClose, fmt.Errorf("close cloned recovery repository: %w", err)) + } + } + return errClose +} + +func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []client.Option, callerRepo *git.Repository, baselineTree *object.Tree, dirtyPaths map[string]struct{}, closeRepository func(*git.Repository) error, renameWorktreeEntry func(string, string) error) (errRecovery error) { parentDir := filepath.Dir(repoDir) recoveryRoot, errTemp := os.MkdirTemp(parentDir, ".gitstore-recovery-") if errTemp != nil { + if callerRepo != nil { + if errClose := closeRepository(callerRepo); errClose != nil { + return errors.Join( + fmt.Errorf("create recovery directory: %w", errTemp), + fmt.Errorf("close recovery caller repository: %w", errClose), + ) + } + } return fmt.Errorf("create recovery directory: %w", errTemp) } cleanupRecovery := true @@ -1124,14 +1205,24 @@ func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []cli } }() + var baselineRepo *git.Repository if baselineTree == nil { - inspectedTree, inspectedDirtyPaths, errInspect := inspectRecoveryBaseline(repoDir) + if callerRepo != nil { + if errClose := closeRepository(callerRepo); errClose != nil { + return fmt.Errorf("close recovery caller repository before baseline inspection: %w", errClose) + } + } + inspectedRepo, inspectedTree, inspectedDirtyPaths, errInspect := inspectRecoveryBaseline(repoDir) if errInspect != nil { return fmt.Errorf("inspect recovery baseline: %w", errInspect) } + baselineRepo = inspectedRepo baselineTree = inspectedTree dirtyPaths = inspectedDirtyPaths + } else { + baselineRepo = callerRepo } + cloneDir := filepath.Join(recoveryRoot, "clone") cloneOpts := &git.CloneOptions{ClientOptions: authMethod, URL: s.remote} if s.branch != "" { @@ -1139,8 +1230,21 @@ func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []cli } clonedRepo, errClone := git.PlainClone(cloneDir, cloneOpts) if errClone != nil { + if baselineRepo != nil { + if errClose := closeRepository(baselineRepo); errClose != nil { + return errors.Join( + fmt.Errorf("clone remote repository: %w", errClone), + fmt.Errorf("close recovery baseline repository: %w", errClose), + ) + } + } return fmt.Errorf("clone remote repository: %w", errClone) } + defer func() { + if errClose := closeRecoveryRepositories(closeRepository, baselineRepo, clonedRepo); errClose != nil { + errRecovery = errors.Join(errRecovery, errClose) + } + }() if errVerify := verifyRepositoryHead(clonedRepo); errVerify != nil { return fmt.Errorf("verify cloned repository: %w", errVerify) } @@ -1164,8 +1268,20 @@ func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []cli return fmt.Errorf("preserve local worktree changes: %w", errApply) } + errClose := closeRecoveryRepositories(closeRepository, baselineRepo, clonedRepo) + baselineRepo = nil + clonedRepo = nil + if errClose != nil { + return errClose + } + backupWorktreeDir := filepath.Join(recoveryRoot, "worktree") - if errBackup := moveWorktreeEntries(repoDir, backupWorktreeDir); errBackup != nil { + retainWorktreeBackup, errBackup := moveWorktreeEntries(repoDir, backupWorktreeDir, renameWorktreeEntry) + if errBackup != nil { + if retainWorktreeBackup { + cleanupRecovery = false + return fmt.Errorf("backup existing worktree; backup retained at %s: %w", backupWorktreeDir, errBackup) + } return fmt.Errorf("backup existing worktree: %w", errBackup) } gitDir := filepath.Join(repoDir, ".git") @@ -1176,15 +1292,15 @@ func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []cli cleanupRecovery = false } if errInstall != nil { - if errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir); errRestore != nil { + if _, errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir, renameWorktreeEntry); errRestore != nil { cleanupRecovery = false return errors.Join(errInstall, fmt.Errorf("restore worktree; backup retained at %s: %w", backupWorktreeDir, errRestore)) } return errInstall } - if errMove := moveWorktreeEntries(cloneDir, repoDir); errMove != nil { + if _, errMove := moveWorktreeEntries(cloneDir, repoDir, renameWorktreeEntry); errMove != nil { errMoveWorktree := fmt.Errorf("install recovered worktree: %w", errMove) - if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir); errRollback != nil { + if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir, renameWorktreeEntry); errRollback != nil { cleanupRecovery = false return errors.Join(errMoveWorktree, fmt.Errorf("rollback recovered repository; backup retained at %s: %w", recoveryRoot, errRollback)) } @@ -1192,11 +1308,19 @@ func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []cli } recoveredRepo, errOpen := git.PlainOpen(repoDir) if errOpen == nil { - errOpen = verifyRepositoryHead(recoveredRepo) + errVerify := verifyRepositoryHead(recoveredRepo) + errClose := closeRepository(recoveredRepo) + if errVerify != nil && errClose != nil { + errOpen = errors.Join(errVerify, fmt.Errorf("close recovered repository: %w", errClose)) + } else if errVerify != nil { + errOpen = errVerify + } else if errClose != nil { + errOpen = fmt.Errorf("close recovered repository: %w", errClose) + } } if errOpen != nil { errRecovered := fmt.Errorf("verify recovered repository: %w", errOpen) - if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir); errRollback != nil { + if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir, renameWorktreeEntry); errRollback != nil { cleanupRecovery = false return errors.Join(errRecovered, fmt.Errorf("rollback recovered repository; backup retained at %s: %w", recoveryRoot, errRollback)) } @@ -1205,32 +1329,40 @@ func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []cli return nil } -func inspectRecoveryBaseline(repoDir string) (*object.Tree, map[string]struct{}, error) { - repo, errOpen := git.PlainOpen(repoDir) +func inspectRecoveryBaseline(repoDir string) (repoResult *git.Repository, treeResult *object.Tree, dirtyResult map[string]struct{}, errResult error) { + openedRepo, errOpen := git.PlainOpen(repoDir) if errOpen != nil { - return nil, nil, fmt.Errorf("open repository: %w", errOpen) + return nil, nil, nil, fmt.Errorf("open repository: %w", errOpen) } - worktree, errWorktree := repo.Worktree() + defer func() { + if errResult != nil && openedRepo != nil { + if errClose := openedRepo.Close(); errClose != nil { + errResult = errors.Join(errResult, fmt.Errorf("close baseline repository on inspection failure: %w", errClose)) + } + openedRepo = nil + } + }() + worktree, errWorktree := openedRepo.Worktree() if errWorktree != nil { - return nil, nil, fmt.Errorf("open worktree: %w", errWorktree) + return nil, nil, nil, fmt.Errorf("open worktree: %w", errWorktree) } dirtyPaths, errDirty := worktreeDirtyPaths(worktree) if errDirty != nil { - return nil, nil, fmt.Errorf("inspect worktree changes: %w", errDirty) + return nil, nil, nil, fmt.Errorf("inspect worktree changes: %w", errDirty) } - head, errHead := repo.Head() + head, errHead := openedRepo.Head() if errHead != nil { - return nil, nil, fmt.Errorf("inspect head: %w", errHead) + return nil, nil, nil, fmt.Errorf("inspect head: %w", errHead) } - commit, errCommit := repo.CommitObject(head.Hash()) + commit, errCommit := openedRepo.CommitObject(head.Hash()) if errCommit != nil { - return nil, nil, fmt.Errorf("inspect head commit: %w", errCommit) + return nil, nil, nil, fmt.Errorf("inspect head commit: %w", errCommit) } tree, errTree := commit.Tree() if errTree != nil { - return nil, nil, fmt.Errorf("inspect head tree: %w", errTree) + return nil, nil, nil, fmt.Errorf("inspect head tree: %w", errTree) } - return tree, dirtyPaths, nil + return openedRepo, tree, dirtyPaths, nil } func recoveryPreservedPaths(baselineTree, remoteTree *object.Tree, dirtyPaths map[string]struct{}) (map[string]struct{}, error) { @@ -1298,13 +1430,16 @@ func applyRecoveryLocalChanges(sourceDir, targetDir string, paths map[string]str return nil } -func moveWorktreeEntries(sourceDir, targetDir string) error { +func moveWorktreeEntries(sourceDir, targetDir string, rename func(string, string) error) (bool, error) { + if rename == nil { + return false, fmt.Errorf("rename function is nil") + } if errMkdir := os.MkdirAll(targetDir, 0o700); errMkdir != nil { - return errMkdir + return false, errMkdir } entries, errRead := os.ReadDir(sourceDir) if errRead != nil { - return errRead + return false, errRead } moved := make([]string, 0, len(entries)) for _, entry := range entries { @@ -1313,19 +1448,21 @@ func moveWorktreeEntries(sourceDir, targetDir string) error { } source := filepath.Join(sourceDir, entry.Name()) target := filepath.Join(targetDir, entry.Name()) - if errRename := os.Rename(source, target); errRename != nil { + if errRename := rename(source, target); errRename != nil { errMove := fmt.Errorf("move %s: %w", entry.Name(), errRename) + retainTarget := false for index := len(moved) - 1; index >= 0; index-- { name := moved[index] - if errRestore := os.Rename(filepath.Join(targetDir, name), filepath.Join(sourceDir, name)); errRestore != nil { + if errRestore := rename(filepath.Join(targetDir, name), filepath.Join(sourceDir, name)); errRestore != nil { + retainTarget = true errMove = errors.Join(errMove, fmt.Errorf("restore %s: %w", name, errRestore)) } } - return errMove + return retainTarget, errMove } moved = append(moved, entry.Name()) } - return nil + return false, nil } func removeWorktreeEntries(repoDir string) error { @@ -1344,14 +1481,14 @@ func removeWorktreeEntries(repoDir string) error { return nil } -func rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir string) error { +func rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir string, renameWorktreeEntry func(string, string) error) error { if errRemove := removeWorktreeEntries(repoDir); errRemove != nil { return fmt.Errorf("remove recovered worktree: %w", errRemove) } if errRollback := rollbackRecoveredGitDirectory(gitDir, backupGitDir); errRollback != nil { return errRollback } - if errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir); errRestore != nil { + if _, errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir, renameWorktreeEntry); errRestore != nil { return fmt.Errorf("restore original worktree: %w", errRestore) } return nil @@ -1518,7 +1655,7 @@ func (s *GitTokenStore) commitAndPushInitialLocked(message string, relPaths ...s return s.commitAndPushWithOptionsLocked(message, true, relPaths...) } -func (s *GitTokenStore) commitAndPushWithOptionsLocked(message string, allowMissingRemote bool, relPaths ...string) error { +func (s *GitTokenStore) commitAndPushWithOptionsLocked(message string, allowMissingRemote bool, relPaths ...string) (errResult error) { repoDir := s.repoDirSnapshot() if repoDir == "" { return fmt.Errorf("git token store: repository path not configured") @@ -1527,6 +1664,16 @@ func (s *GitTokenStore) commitAndPushWithOptionsLocked(message string, allowMiss if err != nil { return fmt.Errorf("git token store: open repo: %w", err) } + defer func() { + if errClose := repo.Close(); errClose != nil { + errCloseWrap := fmt.Errorf("git token store: close repo: %w", errClose) + if errResult == nil { + errResult = errCloseWrap + } else { + errResult = errors.Join(errResult, errCloseWrap) + } + } + }() worktree, err := repo.Worktree() if err != nil { return fmt.Errorf("git token store: worktree: %w", err) @@ -1781,8 +1928,14 @@ func (s *GitTokenStore) maybeRunGC(repoDir string) { repo, err := git.PlainOpen(repoDir) if err != nil { + log.Warnf("git token store: open repository for GC: %v", err) return } + defer func() { + if errClose := repo.Close(); errClose != nil { + log.Warnf("git token store: close repository after GC: %v", errClose) + } + }() pruneOpts := git.PruneOptions{ OnlyObjectsOlderThan: now.Add(-gcPruneGracePeriod), diff --git a/internal/store/gitstore_test.go b/internal/store/gitstore_test.go index df82ff8f..280c701e 100644 --- a/internal/store/gitstore_test.go +++ b/internal/store/gitstore_test.go @@ -128,8 +128,12 @@ func TestEnsureRepositoryReturnsErrorForMissingConfiguredBranchOnExistingReposit func TestEnsureRepositoryInitializesEmptyRemoteUsingConfiguredBranch(t *testing.T) { root := t.TempDir() remoteDir := filepath.Join(root, "remote.git") - if _, err := git.PlainInit(remoteDir, true); err != nil { - t.Fatalf("init bare remote: %v", err) + remoteRepo, errInit := git.PlainInit(remoteDir, true) + if errInit != nil { + t.Fatalf("init bare remote: %v", errInit) + } + if errClose := remoteRepo.Close(); errClose != nil { + t.Fatalf("close bare remote: %v", errClose) } branch := "feature/gemini-fix" @@ -467,6 +471,14 @@ func TestGitTokenStorePersistConfigDropsUnrelatedStagedDeletions(t *testing.T) { if err != nil { t.Fatalf("open workspace repo: %v", err) } + repoClosed := false + defer func() { + if !repoClosed { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close workspace repo after test failure: %v", errClose) + } + } + }() worktree, err := repo.Worktree() if err != nil { t.Fatalf("open workspace worktree: %v", err) @@ -474,6 +486,11 @@ func TestGitTokenStorePersistConfigDropsUnrelatedStagedDeletions(t *testing.T) { if _, err := worktree.Remove("auths/protected.json"); err != nil { t.Fatalf("stage unexpected auth removal: %v", err) } + errClose := repo.Close() + repoClosed = true + if errClose != nil { + t.Fatalf("close workspace repo after staged removal: %v", errClose) + } if _, err := os.Stat(authPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("removed auth stat error = %v, want not exist", err) } @@ -666,15 +683,36 @@ func TestGitTokenStoreConcurrentInitializationDoesNotOverwriteCreatedBranch(t *t if errInitRemote != nil { t.Fatalf("init bare remote: %v", errInitRemote) } + remoteRepoClosed := false + defer func() { + if !remoteRepoClosed { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close initialized remote repo after test failure: %v", errClose) + } + } + }() if errHead := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("master"))); errHead != nil { t.Fatalf("set remote HEAD: %v", errHead) } + errCloseRemote := remoteRepo.Close() + remoteRepoClosed = true + if errCloseRemote != nil { + t.Fatalf("close initialized remote repo: %v", errCloseRemote) + } workspaceDir := filepath.Join(root, "workspace") localRepo, errInitLocal := git.PlainInit(workspaceDir, false) if errInitLocal != nil { t.Fatalf("init local repository: %v", errInitLocal) } + localRepoClosed := false + defer func() { + if !localRepoClosed { + if errClose := localRepo.Close(); errClose != nil { + t.Errorf("close initialized local repo after test failure: %v", errClose) + } + } + }() if errSigning := disableGitCommitSigning(workspaceDir); errSigning != nil { t.Fatalf("disable local commit signing: %v", errSigning) } @@ -690,12 +728,25 @@ func TestGitTokenStoreConcurrentInitializationDoesNotOverwriteCreatedBranch(t *t t.Fatalf("write local placeholder: %v", errWrite) } } + errCloseLocal := localRepo.Close() + localRepoClosed = true + if errCloseLocal != nil { + t.Fatalf("close initialized local repo: %v", errCloseLocal) + } winnerDir := filepath.Join(root, "winner") winnerRepo, errInitWinner := git.PlainInit(winnerDir, false) if errInitWinner != nil { t.Fatalf("init winning repository: %v", errInitWinner) } + winnerRepoClosed := false + defer func() { + if !winnerRepoClosed { + if errClose := winnerRepo.Close(); errClose != nil { + t.Errorf("close winning repository after test failure: %v", errClose) + } + } + }() if errSigning := disableGitCommitSigning(winnerDir); errSigning != nil { t.Fatalf("disable winner commit signing: %v", errSigning) } @@ -733,6 +784,11 @@ func TestGitTokenStoreConcurrentInitializationDoesNotOverwriteCreatedBranch(t *t if errPush := winnerRepo.Push(&git.PushOptions{RemoteName: "origin", RefSpecs: []gitconfig.RefSpec{"refs/heads/master:refs/heads/master"}}); errPush != nil { t.Fatalf("push winning initialization: %v", errPush) } + errCloseWinner := winnerRepo.Close() + winnerRepoClosed = true + if errCloseWinner != nil { + t.Fatalf("close winning repository: %v", errCloseWinner) + } store := NewGitTokenStore(remoteDir, "", "", "master") store.SetBaseDir(filepath.Join(workspaceDir, "auths")) @@ -777,6 +833,14 @@ func TestEnsureRepositoryRetryRestoresTrackedAuthOnUpToDatePull(t *testing.T) { if errOpen != nil { t.Fatalf("open workspace repository: %v", errOpen) } + repoClosed := false + defer func() { + if !repoClosed { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close workspace repository after test failure: %v", errClose) + } + } + }() worktree, errWorktree := repo.Worktree() if errWorktree != nil { t.Fatalf("open workspace worktree: %v", errWorktree) @@ -792,6 +856,11 @@ func TestEnsureRepositoryRetryRestoresTrackedAuthOnUpToDatePull(t *testing.T) { if errSetConfig := repo.SetConfig(cfg); errSetConfig != nil { t.Fatalf("break workspace origin: %v", errSetConfig) } + errClose := repo.Close() + repoClosed = true + if errClose != nil { + t.Fatalf("close workspace repository before unavailable remote check: %v", errClose) + } if errEnsure := store.EnsureRepository(); errEnsure == nil { t.Fatal("EnsureRepository with unavailable remote error = nil, want retryable failure") } @@ -799,10 +868,27 @@ func TestEnsureRepositoryRetryRestoresTrackedAuthOnUpToDatePull(t *testing.T) { t.Fatalf("missing auth stat error = %v, want not exist", errStat) } + repoRestore, errOpen := git.PlainOpen(filepath.Join(root, "workspace")) + if errOpen != nil { + t.Fatalf("reopen workspace repository: %v", errOpen) + } + repoRestoreClosed := false + defer func() { + if !repoRestoreClosed { + if errClose := repoRestore.Close(); errClose != nil { + t.Errorf("close restored workspace repository after test failure: %v", errClose) + } + } + }() cfg.Remotes["origin"].URLs = []string{remoteDir} - if errSetConfig := repo.SetConfig(cfg); errSetConfig != nil { + if errSetConfig := repoRestore.SetConfig(cfg); errSetConfig != nil { t.Fatalf("restore workspace origin: %v", errSetConfig) } + errCloseRestore := repoRestore.Close() + repoRestoreClosed = true + if errCloseRestore != nil { + t.Fatalf("close workspace repository before retry: %v", errCloseRestore) + } if errEnsure := store.EnsureRepository(); errEnsure != nil { t.Fatalf("EnsureRepository retry: %v", errEnsure) } @@ -1029,6 +1115,173 @@ func TestInstallRecoveredGitDirectoryRetainsBackupWhenRestoreFails(t *testing.T) } } +func TestRecoverRepositoryCloseFailuresAbortBeforeMutation(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(root, "workspace", "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + + repoDir := filepath.Join(root, "workspace") + baselineRepo, errOpen := git.PlainOpen(repoDir) + if errOpen != nil { + t.Fatalf("open baseline repository: %v", errOpen) + } + baselineRepoClosed := false + defer func() { + if !baselineRepoClosed { + if errClose := baselineRepo.Close(); errClose != nil { + t.Errorf("close baseline repository after test failure: %v", errClose) + } + } + }() + head, errHead := baselineRepo.Head() + if errHead != nil { + t.Fatalf("read baseline head: %v", errHead) + } + commit, errCommit := baselineRepo.CommitObject(head.Hash()) + if errCommit != nil { + t.Fatalf("read baseline commit: %v", errCommit) + } + baselineTree, errTree := commit.Tree() + if errTree != nil { + t.Fatalf("read baseline tree: %v", errTree) + } + gitMarkerPath := filepath.Join(repoDir, ".git", "recovery-close-marker") + if errWrite := os.WriteFile(gitMarkerPath, []byte("original git\n"), 0o600); errWrite != nil { + t.Fatalf("write repository marker: %v", errWrite) + } + worktreeMarkerPath := filepath.Join(repoDir, "recovery-close-marker.txt") + if errWrite := os.WriteFile(worktreeMarkerPath, []byte("original worktree\n"), 0o600); errWrite != nil { + t.Fatalf("write worktree marker: %v", errWrite) + } + + errBaselineClose := errors.New("close baseline failed") + errClonedClose := errors.New("close clone failed") + closeCalls := 0 + errRecover := store.recoverRepositoryLocked(repoDir, nil, baselineRepo, baselineTree, nil, func(repo *git.Repository) error { + closeCalls++ + errClose := repo.Close() + if repo == baselineRepo { + baselineRepoClosed = true + } + if errClose != nil { + return errClose + } + if repo == baselineRepo { + return errBaselineClose + } + return errClonedClose + }, os.Rename) + if !errors.Is(errRecover, errBaselineClose) || !errors.Is(errRecover, errClonedClose) { + t.Fatalf("recoverRepositoryLocked() error = %v, want both close failures", errRecover) + } + if closeCalls != 2 { + t.Fatalf("repository close calls = %d, want 2", closeCalls) + } + assertLocalFileContents(t, gitMarkerPath, "original git\n") + assertLocalFileContents(t, worktreeMarkerPath, "original worktree\n") + recoveryDirs, errGlob := filepath.Glob(filepath.Join(root, ".gitstore-recovery-*")) + if errGlob != nil { + t.Fatalf("glob recovery directories: %v", errGlob) + } + if len(recoveryDirs) != 0 { + t.Fatalf("recovery directories = %v, want none", recoveryDirs) + } +} + +func TestRecoverRepositoryWorktreeBackupRollbackFailureRetainsRecoveryDirectory(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(root, "workspace", "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + + repoDir := filepath.Join(root, "workspace") + if errWrite := os.WriteFile(filepath.Join(repoDir, "a.txt"), []byte("alpha\n"), 0o600); errWrite != nil { + t.Fatalf("write file a: %v", errWrite) + } + if errWrite := os.WriteFile(filepath.Join(repoDir, "z.txt"), []byte("zeta\n"), 0o600); errWrite != nil { + t.Fatalf("write file z: %v", errWrite) + } + + errMoveZ := errors.New("move z failed") + errRestoreA := errors.New("restore a failed") + errRecover := store.recoverRepositoryLocked(repoDir, nil, nil, nil, nil, (*git.Repository).Close, func(source, target string) error { + if strings.HasSuffix(source, "z.txt") && strings.Contains(target, ".gitstore-recovery-") { + return errMoveZ + } + if strings.HasSuffix(source, "a.txt") && strings.Contains(source, ".gitstore-recovery-") { + return errRestoreA + } + return os.Rename(source, target) + }) + + if !errors.Is(errRecover, errMoveZ) || !errors.Is(errRecover, errRestoreA) { + t.Fatalf("recoverRepositoryLocked error = %v, want both move and restore errors", errRecover) + } + if !strings.Contains(errRecover.Error(), "backup retained at") { + t.Fatalf("recoverRepositoryLocked error = %q, want backup retention notice", errRecover) + } + recoveryDirs, errGlob := filepath.Glob(filepath.Join(root, ".gitstore-recovery-*")) + if errGlob != nil { + t.Fatalf("glob recovery directories: %v", errGlob) + } + if len(recoveryDirs) != 1 { + t.Fatalf("recovery directories count = %d, want 1 retained", len(recoveryDirs)) + } +} + +func TestRecoverRepositoryRecoveredCloseFailureTriggersRollback(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(root, "workspace", "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + + repoDir := filepath.Join(root, "workspace") + originalMarkerPath := filepath.Join(repoDir, ".git", "pre-recovery-marker") + if errWrite := os.WriteFile(originalMarkerPath, []byte("pre-recovery git\n"), 0o600); errWrite != nil { + t.Fatalf("write pre-recovery marker: %v", errWrite) + } + + errRecoveredClose := errors.New("close recovered repo failed") + closeCallCount := 0 + errRecover := store.recoverRepositoryLocked(repoDir, nil, nil, nil, nil, func(repo *git.Repository) error { + closeCallCount++ + if err := repo.Close(); err != nil { + return err + } + if closeCallCount == 3 { + return errRecoveredClose + } + return nil + }, os.Rename) + if !errors.Is(errRecover, errRecoveredClose) { + t.Fatalf("recoverRepositoryLocked() error = %v, want recovered close failure", errRecover) + } + assertLocalFileContents(t, originalMarkerPath, "pre-recovery git\n") + recoveryDirs, errGlob := filepath.Glob(filepath.Join(root, ".gitstore-recovery-*")) + if errGlob != nil { + t.Fatalf("glob recovery directories: %v", errGlob) + } + if len(recoveryDirs) != 0 { + t.Fatalf("recovery directories = %v, want none", recoveryDirs) + } +} + func TestGitTokenStoreCorruptionRecoveryUsesLatestRemoteAuthTree(t *testing.T) { tests := []struct { name string @@ -1272,13 +1525,10 @@ func TestGitTokenStoreMissingPackfileRecoveryFailsClosedWithoutBaseline(t *testi t.Fatalf("Save: %v", errSave) } - repo := corruptGitRepository(t, filepath.Join(root, "workspace")) + corruptGitRepository(t, filepath.Join(root, "workspace")) if errRemove := os.Remove(authPath); errRemove != nil { t.Fatalf("remove local auth before recovery: %v", errRemove) } - if errVerify := verifyRepositoryHead(repo); !isRepositoryCorruptionError(errVerify) { - t.Fatalf("verifyRepositoryHead error = %v, want repository corruption", errVerify) - } errEnsure := store.EnsureRepository() if errEnsure == nil || !strings.Contains(errEnsure.Error(), "inspect recovery baseline") { @@ -1290,6 +1540,46 @@ func TestGitTokenStoreMissingPackfileRecoveryFailsClosedWithoutBaseline(t *testi assertRemoteTreePath(t, remoteDir, "master", "auths/recover.json", true) } +func TestInspectRecoveryBaselineFailureClosesRepositoryHandle(t *testing.T) { + root := t.TempDir() + remoteDir := setupGitRemoteRepository(t, root, "master", + testBranchSpec{name: "master", contents: "remote master branch\n"}, + ) + repoDir := filepath.Join(root, "workspace") + store := NewGitTokenStore(remoteDir, "", "", "") + store.SetBaseDir(filepath.Join(repoDir, "auths")) + if errEnsure := store.EnsureRepository(); errEnsure != nil { + t.Fatalf("EnsureRepository: %v", errEnsure) + } + + corruptGitRepository(t, repoDir) + + repo, tree, dirty, errInspect := inspectRecoveryBaseline(repoDir) + if errInspect == nil { + t.Fatal("inspectRecoveryBaseline error = nil, want corruption error") + } + if repo != nil || tree != nil || dirty != nil { + t.Fatalf("inspectRecoveryBaseline returned non-nil values on error: repo=%v, tree=%v, dirty=%v", repo, tree, dirty) + } + + gitDir := filepath.Join(repoDir, ".git") + renamedGitDir := filepath.Join(repoDir, ".git.renamed") + if errRename := os.Rename(gitDir, renamedGitDir); errRename != nil { + t.Fatalf("rename .git after inspectRecoveryBaseline failure: %v", errRename) + } + if errRenameBack := os.Rename(renamedGitDir, gitDir); errRenameBack != nil { + t.Fatalf("restore renamed .git: %v", errRenameBack) + } + + if errRemove := os.RemoveAll(repoDir); errRemove != nil { + t.Fatalf("remove repoDir after handle release: %v", errRemove) + } + if errRetry := store.EnsureRepository(); errRetry != nil { + t.Fatalf("EnsureRepository retry after cleanup: %v", errRetry) + } + assertRemoteBranchContents(t, remoteDir, "master", "remote master branch\n") +} + func TestCommitAndPushLockedPushesBeforeRunningGC(t *testing.T) { root := t.TempDir() remoteDir := setupGitRemoteRepository(t, root, "master", @@ -1378,6 +1668,14 @@ func TestEnsureRepositoryKeepsCurrentBranchWhenRemoteDefaultCannotBeResolved(t * if err != nil { t.Fatalf("open workspace repo: %v", err) } + repoClosed := false + defer func() { + if !repoClosed { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close workspace repo after test failure: %v", errClose) + } + } + }() cfg, err := repo.Config() if err != nil { t.Fatalf("read repo config: %v", err) @@ -1386,6 +1684,11 @@ func TestEnsureRepositoryKeepsCurrentBranchWhenRemoteDefaultCannotBeResolved(t * if err := repo.SetConfig(cfg); err != nil { t.Fatalf("set repo config: %v", err) } + errClose := repo.Close() + repoClosed = true + if errClose != nil { + t.Fatalf("close workspace repo before fallback check: %v", errClose) + } reopened := NewGitTokenStore(remoteDir, "", "", "") reopened.SetBaseDir(baseDir) @@ -1403,6 +1706,11 @@ func removeHeadFileObject(t *testing.T, repoDir, path string) { if errOpen != nil { t.Fatalf("open repository before object removal: %v", errOpen) } + defer func() { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close repository: %v", errClose) + } + }() worktree, errWorktree := repo.Worktree() if errWorktree != nil { t.Fatalf("open worktree before object removal: %v", errWorktree) @@ -1444,13 +1752,18 @@ func removeHeadFileObject(t *testing.T, repoDir, path string) { } } -func corruptGitRepository(t *testing.T, repoDir string) *git.Repository { +func corruptGitRepository(t *testing.T, repoDir string) { t.Helper() repo, errOpen := git.PlainOpen(repoDir) if errOpen != nil { t.Fatalf("open repository before corruption: %v", errOpen) } + defer func() { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close corrupted repository: %v", errClose) + } + }() if errRepack := repo.RepackObjects(&git.RepackConfig{}); errRepack != nil { t.Fatalf("repack repository objects: %v", errRepack) } @@ -1478,15 +1791,21 @@ func corruptGitRepository(t *testing.T, repoDir string) *git.Repository { t.Fatalf("remove packfile %s: %v", filepath.Base(packfile), errRemove) } } - return repo + if errVerify := verifyRepositoryHead(repo); !isRepositoryCorruptionError(errVerify) { + t.Fatalf("verifyRepositoryHead error = %v, want repository corruption", errVerify) + } } func setupGitRemoteRepository(t *testing.T, root, defaultBranch string, branches ...testBranchSpec) string { t.Helper() remoteDir := filepath.Join(root, "remote.git") - if _, err := git.PlainInit(remoteDir, true); err != nil { - t.Fatalf("init bare remote: %v", err) + remoteRepo, errInit := git.PlainInit(remoteDir, true) + if errInit != nil { + t.Fatalf("init bare remote: %v", errInit) + } + if errClose := remoteRepo.Close(); errClose != nil { + t.Fatalf("close initialized bare remote: %v", errClose) } seedDir := filepath.Join(root, "seed") @@ -1494,6 +1813,11 @@ func setupGitRemoteRepository(t *testing.T, root, defaultBranch string, branches if err != nil { t.Fatalf("init seed repo: %v", err) } + defer func() { + if errClose := seedRepo.Close(); errClose != nil { + t.Errorf("close seed repo: %v", errClose) + } + }() seedConfig, errConfig := seedRepo.Config() if errConfig != nil { t.Fatalf("get seed repo config: %v", errConfig) @@ -1540,10 +1864,15 @@ func setupGitRemoteRepository(t *testing.T, root, defaultBranch string, branches t.Fatalf("push seed branches: %v", err) } - remoteRepo, err := git.PlainOpen(remoteDir) + remoteRepo, err = git.PlainOpen(remoteDir) if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() if err := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(defaultBranch))); err != nil { t.Fatalf("set remote HEAD: %v", err) } @@ -1578,6 +1907,11 @@ func advanceRemoteBranch(t *testing.T, seedDir, remoteDir, branch, contents, mes if err != nil { t.Fatalf("open seed repo: %v", err) } + defer func() { + if errClose := seedRepo.Close(); errClose != nil { + t.Errorf("close seed repo: %v", errClose) + } + }() worktree, err := seedRepo.Worktree() if err != nil { t.Fatalf("open seed worktree: %v", err) @@ -1603,6 +1937,11 @@ func advanceRemoteBranchFromNewBranch(t *testing.T, seedDir, remoteDir, branch, if err != nil { t.Fatalf("open seed repo: %v", err) } + defer func() { + if errClose := seedRepo.Close(); errClose != nil { + t.Errorf("close seed repo: %v", errClose) + } + }() worktree, err := seedRepo.Worktree() if err != nil { t.Fatalf("open seed worktree: %v", err) @@ -1668,6 +2007,11 @@ func assertRemoteTreePath(t *testing.T, remoteDir, branch, path string, want boo if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true) if err != nil { t.Fatalf("read remote branch %s: %v", branch, err) @@ -1697,6 +2041,11 @@ func assertRemoteFileContents(t *testing.T, remoteDir, branch, path, wantContent if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true) if err != nil { t.Fatalf("read remote branch %s: %v", branch, err) @@ -1729,6 +2078,11 @@ func assertRepositoryBranchAndContents(t *testing.T, repoDir, branch, wantConten if err != nil { t.Fatalf("open local repo: %v", err) } + defer func() { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close local repo: %v", errClose) + } + }() head, err := repo.Head() if err != nil { t.Fatalf("local repo head: %v", err) @@ -1752,6 +2106,11 @@ func assertRepositoryHeadBranch(t *testing.T, repoDir, branch string) { if err != nil { t.Fatalf("open local repo: %v", err) } + defer func() { + if errClose := repo.Close(); errClose != nil { + t.Errorf("close local repo: %v", errClose) + } + }() head, err := repo.Head() if err != nil { t.Fatalf("local repo head: %v", err) @@ -1768,6 +2127,11 @@ func assertRemoteHeadBranch(t *testing.T, remoteDir, branch string) { if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() head, err := remoteRepo.Reference(plumbing.HEAD, false) if err != nil { t.Fatalf("read remote HEAD: %v", err) @@ -1784,6 +2148,11 @@ func setRemoteHeadBranch(t *testing.T, remoteDir, branch string) { if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() if err := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))); err != nil { t.Fatalf("set remote HEAD to %s: %v", branch, err) } @@ -1796,6 +2165,11 @@ func assertRemoteBranchExistsWithCommit(t *testing.T, remoteDir, branch string) if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() ref, err := remoteRepo.Reference(plumbing.NewBranchReferenceName(branch), false) if err != nil { t.Fatalf("read remote branch %s: %v", branch, err) @@ -1812,6 +2186,11 @@ func assertRemoteBranchDoesNotExist(t *testing.T, remoteDir, branch string) { if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() if _, err := remoteRepo.Reference(plumbing.NewBranchReferenceName(branch), false); err == nil { t.Fatalf("remote branch %s exists, want missing", branch) } else if err != plumbing.ErrReferenceNotFound { @@ -1826,6 +2205,11 @@ func assertRemoteBranchContents(t *testing.T, remoteDir, branch, wantContents st if err != nil { t.Fatalf("open remote repo: %v", err) } + defer func() { + if errClose := remoteRepo.Close(); errClose != nil { + t.Errorf("close remote repo: %v", errClose) + } + }() ref, err := remoteRepo.Reference(plumbing.NewBranchReferenceName(branch), false) if err != nil { t.Fatalf("read remote branch %s: %v", branch, err)