a4cd00cc4d
ci / vet, staticcheck, test, build (push) Failing after 2m54s
Docker CI containers run as root by default (golang:1.26-alpine), where os.Chmod has no effect and permission restrictions don't apply. Four tests were failing because they relied on chmod to force error paths: - gradle: TestWriteVersionReadOnly - cmd: TestInitConfigWriteFails, TestRunLatestTagFails - gitutil: TestLatestTagTagsIterFails (had broken skip logic that only fired if Chmod itself errored, which never happens as root) Replace all four with an upfront os.Getuid() == 0 check so they are skipped cleanly in root environments and still run (and must pass) on non-root local development. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1108 lines
34 KiB
Go
1108 lines
34 KiB
Go
package gitutil
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
gogit "github.com/go-git/go-git/v5"
|
|
gitconfig "github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
|
|
|
"git.k3nny.fr/releaser/internal/branch"
|
|
)
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func newTestRepo(t *testing.T) (*gogit.Repository, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
repo, err := gogit.PlainInit(dir, false)
|
|
if err != nil {
|
|
t.Fatalf("init repo: %v", err)
|
|
}
|
|
return repo, dir
|
|
}
|
|
|
|
func testSig() *object.Signature {
|
|
return &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}
|
|
}
|
|
|
|
// addCommit writes content to test.txt, stages it, and creates a commit.
|
|
func addCommit(t *testing.T, repo *gogit.Repository, dir, message, content string) plumbing.Hash {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w, err := repo.Worktree()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := w.Add("test.txt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := w.Commit(message, &gogit.CommitOptions{Author: testSig()})
|
|
if err != nil {
|
|
t.Fatalf("commit %q: %v", message, err)
|
|
}
|
|
return hash
|
|
}
|
|
|
|
func addTag(t *testing.T, repo *gogit.Repository, name string) {
|
|
t.Helper()
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := repo.CreateTag(name, head.Hash(), nil); err != nil {
|
|
t.Fatalf("create lightweight tag %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
func addAnnotatedTag(t *testing.T, repo *gogit.Repository, name string) {
|
|
t.Helper()
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = repo.CreateTag(name, head.Hash(), &gogit.CreateTagOptions{
|
|
Tagger: testSig(),
|
|
Message: "release " + name,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create annotated tag %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// ── IsWorkingTreeClean ────────────────────────────────────────────────────────
|
|
|
|
func TestIsWorkingTreeClean(t *testing.T) {
|
|
t.Run("clean after commit", func(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
clean, err := IsWorkingTreeClean(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !clean {
|
|
t.Error("expected clean working tree after commit")
|
|
}
|
|
})
|
|
|
|
t.Run("dirty when tracked file is modified", func(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("modified"), 0644)
|
|
|
|
clean, err := IsWorkingTreeClean(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if clean {
|
|
t.Error("expected dirty working tree after modification")
|
|
}
|
|
})
|
|
|
|
t.Run("dirty when new file is staged", func(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("new"), 0644)
|
|
w, _ := repo.Worktree()
|
|
w.Add("staged.txt")
|
|
|
|
clean, err := IsWorkingTreeClean(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if clean {
|
|
t.Error("expected dirty working tree with staged file")
|
|
}
|
|
})
|
|
|
|
t.Run("untracked file does not dirty the tree", func(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)
|
|
|
|
clean, err := IsWorkingTreeClean(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !clean {
|
|
t.Error("untracked file should not dirty the working tree")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── CurrentBranch ─────────────────────────────────────────────────────────────
|
|
|
|
func TestCurrentBranch(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
name, err := CurrentBranch(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if name == "" {
|
|
t.Error("branch name should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestCurrentBranchDetached(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
// Detach HEAD by replacing the symbolic ref with a hash ref
|
|
head, _ := repo.Head()
|
|
detached := plumbing.NewHashReference(plumbing.HEAD, head.Hash())
|
|
if err := repo.Storer.SetReference(detached); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := CurrentBranch(repo)
|
|
if err == nil {
|
|
t.Error("expected error for detached HEAD")
|
|
}
|
|
}
|
|
|
|
// ── LatestTag ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestLatestTagNoTags(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: first", "v1")
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tag != "" || patch != -1 {
|
|
t.Errorf("expected no tag: got %q patch=%d", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagSingle(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: first", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
addCommit(t, repo, dir, "fix: second", "v2")
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tag != "v1.2.0" || patch != 0 {
|
|
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagPicksHighestPatch(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
addCommit(t, repo, dir, "fix: c2", "v2")
|
|
addTag(t, repo, "v1.2.1")
|
|
addCommit(t, repo, dir, "feat: c3", "v3")
|
|
addTag(t, repo, "v1.2.5")
|
|
addCommit(t, repo, dir, "fix: c4", "v4") // HEAD after the last tag
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tag != "v1.2.5" || patch != 5 {
|
|
t.Errorf("got tag=%q patch=%d, want v1.2.5 patch=5", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagCrossVersionIgnored(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.2.0") // matching
|
|
addCommit(t, repo, dir, "fix: c2", "v2")
|
|
addTag(t, repo, "v1.3.0") // different minor — must be ignored
|
|
addCommit(t, repo, dir, "fix: c3", "v3")
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tag != "v1.2.0" || patch != 0 {
|
|
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0 (v1.3.0 must be ignored)", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagNoPrefixVariant(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "2.0.0") // no "v" prefix
|
|
addCommit(t, repo, dir, "fix: c2", "v2")
|
|
|
|
info := branch.Info{Major: 2, Minor: 0, TagPrefix: ""}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tag != "2.0.0" || patch != 0 {
|
|
t.Errorf("got tag=%q patch=%d, want 2.0.0 patch=0", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagAnnotated(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addAnnotatedTag(t, repo, "v1.2.0")
|
|
addCommit(t, repo, dir, "fix: c2", "v2")
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tag != "v1.2.0" || patch != 0 {
|
|
t.Errorf("annotated tag: got %q patch=%d, want v1.2.0", tag, patch)
|
|
}
|
|
}
|
|
|
|
// ── CommitsSince ──────────────────────────────────────────────────────────────
|
|
|
|
func TestCommitsSince(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: baseline", "v0")
|
|
addTag(t, repo, "v1.2.0")
|
|
addCommit(t, repo, dir, "fix: bug fix", "v1")
|
|
addCommit(t, repo, dir, "feat: new feature", "v2")
|
|
|
|
msgs, err := CommitsSince(repo, "v1.2.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("expected 2 commits since v1.2.0, got %d: %v", len(msgs), msgs)
|
|
}
|
|
// Log is reverse-chronological: newest first
|
|
if !strings.Contains(msgs[0], "feat: new feature") {
|
|
t.Errorf("msgs[0] = %q, want feat: new feature", msgs[0])
|
|
}
|
|
if !strings.Contains(msgs[1], "fix: bug fix") {
|
|
t.Errorf("msgs[1] = %q, want fix: bug fix", msgs[1])
|
|
}
|
|
}
|
|
|
|
func TestCommitsSinceTagOnHead(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
// HEAD IS the tag — no commits since
|
|
msgs, err := CommitsSince(repo, "v1.2.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msgs) != 0 {
|
|
t.Errorf("expected 0 commits since tag on HEAD, got %d", len(msgs))
|
|
}
|
|
}
|
|
|
|
// ── AllCommits ────────────────────────────────────────────────────────────────
|
|
|
|
func TestAllCommits(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addCommit(t, repo, dir, "fix: c2", "v2")
|
|
addCommit(t, repo, dir, "fix: c3", "v3")
|
|
|
|
msgs, err := AllCommits(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(msgs) != 3 {
|
|
t.Errorf("expected 3 commits, got %d", len(msgs))
|
|
}
|
|
}
|
|
|
|
// ── CommitFile ────────────────────────────────────────────────────────────────
|
|
|
|
func TestCommitFile(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: initial", "init")
|
|
|
|
pomPath := filepath.Join(dir, "pom.xml")
|
|
if err := os.WriteFile(pomPath, []byte("<project><version>1.2.4</version></project>"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
hash, err := CommitFile(repo, "pom.xml", "chore(release): v1.2.4 [skip ci]", "Releaser", "ci@example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hash.IsZero() {
|
|
t.Error("expected non-zero commit hash")
|
|
}
|
|
|
|
commit, err := repo.CommitObject(hash)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if commit.Message != "chore(release): v1.2.4 [skip ci]" {
|
|
t.Errorf("commit message = %q", commit.Message)
|
|
}
|
|
if commit.Author.Name != "Releaser" {
|
|
t.Errorf("author name = %q, want Releaser", commit.Author.Name)
|
|
}
|
|
if commit.Author.Email != "ci@example.com" {
|
|
t.Errorf("author email = %q", commit.Author.Email)
|
|
}
|
|
}
|
|
|
|
// ── CreateTag ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestCreateTag(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
|
|
if err := CreateTag(repo, "v1.2.0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ref, err := repo.Tag("v1.2.0")
|
|
if err != nil {
|
|
t.Fatalf("tag v1.2.0 not found after creation: %v", err)
|
|
}
|
|
head, _ := repo.Head()
|
|
if ref.Hash() != head.Hash() {
|
|
t.Error("lightweight tag does not point to HEAD")
|
|
}
|
|
}
|
|
|
|
func TestCreateTagDuplicate(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
if err := CreateTag(repo, "v1.2.0"); err == nil {
|
|
t.Error("expected error when creating duplicate tag")
|
|
}
|
|
}
|
|
|
|
// ── Push (error path only — no real remote needed) ───────────────────────────
|
|
|
|
func TestPushRemoteFails(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
// Remote config exists but points nowhere → Push will error on remote.Push
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{"/nonexistent/bare/repo"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := Push(repo, "master", "v1.2.0", "")
|
|
if err == nil {
|
|
t.Error("expected push error for invalid remote path")
|
|
}
|
|
}
|
|
|
|
func TestPushNoRemote(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
|
|
err := Push(repo, "master", "v1.0.0", "")
|
|
if err == nil {
|
|
t.Error("expected error when no remote is configured")
|
|
}
|
|
}
|
|
|
|
// ── bare-repo error paths ─────────────────────────────────────────────────────
|
|
|
|
func TestIsWorkingTreeCleanBareRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo, err := gogit.PlainInit(dir, true) // bare = true
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = IsWorkingTreeClean(repo)
|
|
if err == nil {
|
|
t.Error("expected error for bare repo (no worktree)")
|
|
}
|
|
}
|
|
|
|
func TestCurrentBranchEmptyRepo(t *testing.T) {
|
|
// Fresh repo with no commits — HEAD symbolic ref points to refs/heads/master
|
|
// but that ref doesn't exist yet, so Head() returns plumbing.ErrReferenceNotFound.
|
|
repo, _ := newTestRepo(t)
|
|
_, err := CurrentBranch(repo)
|
|
if err == nil {
|
|
t.Error("expected error for empty repo (no HEAD commit)")
|
|
}
|
|
}
|
|
|
|
func TestAllCommitsEmptyRepo(t *testing.T) {
|
|
repo, _ := newTestRepo(t)
|
|
_, err := AllCommits(repo)
|
|
if err == nil {
|
|
t.Error("expected error for empty repo")
|
|
}
|
|
}
|
|
|
|
func TestCreateTagEmptyRepo(t *testing.T) {
|
|
repo, _ := newTestRepo(t)
|
|
err := CreateTag(repo, "v1.0.0")
|
|
if err == nil {
|
|
t.Error("expected error when creating tag on empty repo")
|
|
}
|
|
}
|
|
|
|
func TestLatestTagEmptyRepo(t *testing.T) {
|
|
repo, _ := newTestRepo(t)
|
|
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
|
if err == nil {
|
|
t.Error("expected error for empty repo")
|
|
}
|
|
}
|
|
|
|
func TestCommitFileBareRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo, err := gogit.PlainInit(dir, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = CommitFile(repo, "pom.xml", "chore: test", "Test", "t@t.com")
|
|
if err == nil {
|
|
t.Error("expected error for bare repo (no worktree)")
|
|
}
|
|
}
|
|
|
|
func TestCommitFileNonexistentFile(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "init")
|
|
|
|
// Try to stage a file that doesn't exist in the worktree
|
|
_, err := CommitFile(repo, "nonexistent.xml", "chore: bad", "Test", "t@t.com")
|
|
if err == nil {
|
|
t.Error("expected error when staging nonexistent file")
|
|
}
|
|
}
|
|
|
|
// ── LatestTag edge cases ──────────────────────────────────────────────────────
|
|
|
|
func TestLatestTagOnHead(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.2.0") // tag is ON HEAD, not behind HEAD
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Tag at HEAD should still be found (it is the current release base)
|
|
if tag != "v1.2.0" || patch != 0 {
|
|
t.Errorf("got %q patch=%d, want v1.2.0 patch=0 when tag is on HEAD", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagNonAncestorIgnored(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
baseHash := addCommit(t, repo, dir, "chore: base", "base")
|
|
|
|
// Create sibling branch from base commit and put a v1.2.0 tag on it
|
|
w, _ := repo.Worktree()
|
|
if err := w.Checkout(&gogit.CheckoutOptions{
|
|
Branch: plumbing.NewBranchReferenceName("sibling"),
|
|
Hash: baseHash,
|
|
Create: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addCommit(t, repo, dir, "feat: sibling work", "sibling")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
// Switch back to master and add a commit (diverges from sibling)
|
|
if err := w.Checkout(&gogit.CheckoutOptions{
|
|
Branch: plumbing.NewBranchReferenceName("master"),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addCommit(t, repo, dir, "fix: mainline only", "mainline")
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// v1.2.0 is on the sibling branch — not an ancestor of current HEAD
|
|
if tag != "" || patch != -1 {
|
|
t.Errorf("non-ancestor tag should be ignored: got %q patch=%d", tag, patch)
|
|
}
|
|
}
|
|
|
|
func TestLatestTagSkipsMalformedRef(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
|
|
// Create a v1.2.0 tag reference pointing to a hash that doesn't exist.
|
|
// LatestTag must silently skip it instead of returning an error.
|
|
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
|
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
|
|
if err := repo.Storer.SetReference(fakeRef); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
|
tag, patch, err := LatestTag(repo, info)
|
|
if err != nil {
|
|
t.Fatalf("LatestTag should not error on malformed tag ref: %v", err)
|
|
}
|
|
if tag != "" || patch != -1 {
|
|
t.Errorf("malformed tag should be skipped: got %q patch=%d", tag, patch)
|
|
}
|
|
}
|
|
|
|
// ── resolveTagToCommit default case ──────────────────────────────────────────
|
|
|
|
func TestResolveTagToCommitBlobRef(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
h := addCommit(t, repo, dir, "fix: c1", "content")
|
|
|
|
// Obtain a blob hash from the commit tree to use as an adversarial ref target.
|
|
commit, err := repo.CommitObject(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil || len(tree.Entries) == 0 {
|
|
t.Fatal("need at least one tree entry")
|
|
}
|
|
blobHash := tree.Entries[0].Hash
|
|
|
|
blobRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("blob-tag"), blobHash)
|
|
_, err = resolveTagToCommit(repo, blobRef)
|
|
if err == nil {
|
|
t.Error("expected error when tag points to a blob (not a commit or tag object)")
|
|
}
|
|
}
|
|
|
|
// ── CommitsSince error paths ──────────────────────────────────────────────────
|
|
|
|
func TestCommitsSinceBadTag(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
|
|
_, err := CommitsSince(repo, "v-does-not-exist")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent tag")
|
|
}
|
|
}
|
|
|
|
func TestCommitsSinceMalformedTagRef(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
|
|
// Create a tag that references a nonexistent hash so resolveTagToCommit fails.
|
|
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
|
|
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
|
|
if err := repo.Storer.SetReference(fakeRef); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := CommitsSince(repo, "v1.2.0")
|
|
if err == nil {
|
|
t.Error("expected error when resolveTagToCommit fails on a malformed tag ref")
|
|
}
|
|
}
|
|
|
|
// ── AuthorFromConfig ──────────────────────────────────────────────────────────
|
|
|
|
func TestAuthorFromConfigDoesNotPanic(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "init")
|
|
|
|
// May return empty strings in CI where ~/.gitconfig has no user section — must not panic
|
|
name, email := AuthorFromConfig(repo)
|
|
_ = name
|
|
_ = email
|
|
}
|
|
|
|
func TestAuthorFromConfigLocalOverride(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "init")
|
|
|
|
// Write a local git config with user.name / user.email
|
|
localCfg := `[user]
|
|
name = LocalUser
|
|
email = local@example.com
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte(localCfg), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Reload repo so it picks up the config file we just wrote
|
|
repo2, err := gogit.PlainOpen(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
name, email := AuthorFromConfig(repo2)
|
|
if name != "LocalUser" {
|
|
t.Errorf("name = %q, want LocalUser", name)
|
|
}
|
|
if email != "local@example.com" {
|
|
t.Errorf("email = %q, want local@example.com", email)
|
|
}
|
|
}
|
|
|
|
// ── Push with a real bare remote ──────────────────────────────────────────────
|
|
|
|
func TestPushWithBareRemote(t *testing.T) {
|
|
// Create a bare repo to act as the remote
|
|
remoteDir := t.TempDir()
|
|
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create the working repo
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: something", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
// Wire the bare repo as origin
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{remoteDir},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Push with a token (exercises the auth branch even though local transport ignores it)
|
|
err := Push(repo, "master", "v1.2.0", "dummy-token")
|
|
if err != nil {
|
|
t.Fatalf("Push to bare remote failed: %v", err)
|
|
}
|
|
|
|
// Push again — should hit the NoErrAlreadyUpToDate branch and return nil
|
|
err = Push(repo, "master", "v1.2.0", "dummy-token")
|
|
if err != nil {
|
|
t.Fatalf("second Push returned unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── IsWorkingTreeClean: w.Status() error path ────────────────────────────────
|
|
|
|
func TestIsWorkingTreeCleanCorruptIndex(t *testing.T) {
|
|
// Use a filesystem repo so we can corrupt the on-disk index.
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
// Overwrite .git/index with garbage so go-git fails to parse it.
|
|
indexPath := filepath.Join(dir, ".git", "index")
|
|
if err := os.WriteFile(indexPath, []byte("not a valid git index"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Reopen — fresh repository object with no cached index.
|
|
repo2, err := gogit.PlainOpen(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = IsWorkingTreeClean(repo2)
|
|
if err == nil {
|
|
t.Error("expected error when git index is corrupt")
|
|
}
|
|
}
|
|
|
|
// ── LatestTag: head commit object missing ────────────────────────────────────
|
|
|
|
func TestLatestTagHeadCommitMissing(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
// Detach HEAD to a fake hash that has no backing commit object.
|
|
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
|
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
|
if err == nil {
|
|
t.Error("expected error when HEAD commit object is missing")
|
|
}
|
|
}
|
|
|
|
// ── LatestTag: Tags() iterator fails ────────────────────────────────────────
|
|
|
|
func TestLatestTagTagsIterFails(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
|
|
if os.Getuid() == 0 {
|
|
t.Skip("skipping: chmod restrictions do not apply when running as root")
|
|
}
|
|
|
|
// Make .git/refs/tags/ unreadable so that go-git's walkReferencesTree
|
|
// returns EPERM when it tries to list the directory, triggering the
|
|
// Tags() error path.
|
|
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
|
|
os.Chmod(tagsDir, 0000)
|
|
t.Cleanup(func() { os.Chmod(tagsDir, 0755) })
|
|
|
|
// Reopen so the filesystem storer holds no cached state.
|
|
repo2, err := gogit.PlainOpen(dir)
|
|
|
|
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
|
if err == nil {
|
|
t.Error("expected error when refs/tags is unreadable")
|
|
}
|
|
}
|
|
|
|
// ── LatestTag: IsAncestor fails → ForEach propagates error ───────────────────
|
|
|
|
func TestLatestTagIsAncestorFails(t *testing.T) {
|
|
// Topology: c0 (base) → c1 (sibling branch, tagged v1.2.0)
|
|
// → c2 (master HEAD — diverged from sibling)
|
|
// The tag is NOT an ancestor of HEAD. IsAncestor must walk master's history
|
|
// all the way back to c0; corrupting c0 makes that walk fail.
|
|
repo, dir := newTestRepo(t)
|
|
c0 := addCommit(t, repo, dir, "chore: base", "base")
|
|
|
|
w, _ := repo.Worktree()
|
|
if err := w.Checkout(&gogit.CheckoutOptions{
|
|
Branch: plumbing.NewBranchReferenceName("sibling"),
|
|
Hash: c0,
|
|
Create: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addCommit(t, repo, dir, "feat: sibling work", "sibling")
|
|
addTag(t, repo, "v1.2.0") // tag on the sibling commit (not an ancestor of master)
|
|
|
|
if err := w.Checkout(&gogit.CheckoutOptions{
|
|
Branch: plumbing.NewBranchReferenceName("master"),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addCommit(t, repo, dir, "fix: mainline", "mainline") // HEAD on master
|
|
|
|
// Corrupt c0 (the common base) so that IsAncestor's commit-graph walk
|
|
// fails when it tries to read c0 as a parent of the master HEAD commit.
|
|
hashStr := c0.String()
|
|
objPath := filepath.Join(dir, ".git", "objects", hashStr[:2], hashStr[2:])
|
|
if err := os.Chmod(objPath, 0644); err != nil {
|
|
t.Fatalf("chmod object: %v", err)
|
|
}
|
|
if err := os.WriteFile(objPath, []byte("corrupt"), 0444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
repo2, err := gogit.PlainOpen(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The ForEach callback propagates the IsAncestor error, so LatestTag
|
|
// must return a non-nil error (covers the refs.ForEach error path).
|
|
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
|
if err == nil {
|
|
t.Error("expected error when commit graph is corrupt during IsAncestor")
|
|
}
|
|
}
|
|
|
|
// ── CommitsSince: repo.Head() fails after tag resolve ────────────────────────
|
|
|
|
func TestCommitsSinceHeadRemoved(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
|
|
// Remove HEAD so repo.Head() returns ErrReferenceNotFound.
|
|
if err := repo.Storer.RemoveReference(plumbing.HEAD); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := CommitsSince(repo, "v1.2.0")
|
|
if err == nil {
|
|
t.Error("expected error when HEAD reference is missing")
|
|
}
|
|
}
|
|
|
|
// ── CommitsSince: repo.Log() fails ───────────────────────────────────────────
|
|
|
|
func TestCommitsSinceFakeHead(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.2.0")
|
|
addCommit(t, repo, dir, "fix: c2", "v2")
|
|
|
|
// Point HEAD directly to a non-existent commit hash.
|
|
// repo.Head() succeeds (returns the hash) but repo.Log() fails eagerly.
|
|
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
|
|
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := CommitsSince(repo, "v1.2.0")
|
|
if err == nil {
|
|
t.Error("expected error when HEAD commit object is missing")
|
|
}
|
|
}
|
|
|
|
// ── AllCommits: repo.Log() fails ─────────────────────────────────────────────
|
|
|
|
func TestAllCommitsFakeHead(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
|
|
// Point HEAD to a non-existent commit hash so repo.Log() fails eagerly.
|
|
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
|
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := AllCommits(repo)
|
|
if err == nil {
|
|
t.Error("expected error when HEAD commit object is missing")
|
|
}
|
|
}
|
|
|
|
// ── CommitFiles: w.Commit() fails ────────────────────────────────────────────
|
|
|
|
func TestCommitFilesUnchanged(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "chore: init", "initial")
|
|
|
|
// test.txt already committed and unchanged — w.Add succeeds, w.Commit fails
|
|
// (go-git rejects empty commits when AllowEmptyCommits is false).
|
|
_, err := CommitFiles(repo, []string{"test.txt"}, "chore: empty", "Test", "t@t.com")
|
|
if err == nil {
|
|
t.Error("expected error when committing unchanged file (empty commit)")
|
|
}
|
|
}
|
|
|
|
// ── Push: SSH agent success / failure paths ──────────────────────────────────
|
|
|
|
func TestPushSSHAgentSucceeds(t *testing.T) {
|
|
// Mock sshPush so it succeeds without a real SSH agent.
|
|
orig := sshPush
|
|
sshPush = func(_ *gogit.Repository, _, _ string) error { return nil }
|
|
defer func() { sshPush = orig }()
|
|
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{"git@example.com:owner/repo.git"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := Push(repo, "master", "v1.0.0", ""); err != nil {
|
|
t.Fatalf("Push with mocked SSH agent should succeed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPushSSHAgentFailsFallsBackToCLI(t *testing.T) {
|
|
// SSH URL remote + sshPush fails → falls through to pushWithCLI.
|
|
orig := sshPush
|
|
sshPush = func(_ *gogit.Repository, _, _ string) error { return fmt.Errorf("no agent") }
|
|
defer func() { sshPush = orig }()
|
|
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{"git@example.com:owner/repo.git"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// CLI push will fail (no real remote) — we just verify it ran at all.
|
|
err := Push(repo, "master", "v1.0.0", "")
|
|
if err == nil {
|
|
t.Error("expected error after SSH fallback to CLI with unreachable remote")
|
|
}
|
|
}
|
|
|
|
// ── pushWithSSHAgent internals ────────────────────────────────────────────────
|
|
|
|
func TestPushWithSSHAgentAuthFails(t *testing.T) {
|
|
orig := newSSHAgentAuth
|
|
newSSHAgentAuth = func(_ string) (*gitssh.PublicKeysCallback, error) {
|
|
return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
|
|
}
|
|
defer func() { newSSHAgentAuth = orig }()
|
|
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
|
|
err := pushWithSSHAgent(repo, "master", "v1.0.0")
|
|
if err == nil {
|
|
t.Error("expected error when SSH agent auth fails")
|
|
}
|
|
}
|
|
|
|
func TestPushWithSSHAgentNoRemote(t *testing.T) {
|
|
orig := newSSHAgentAuth
|
|
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
|
return &gitssh.PublicKeysCallback{User: user}, nil
|
|
}
|
|
defer func() { newSSHAgentAuth = orig }()
|
|
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
// No remote configured → repo.Remote("origin") fails.
|
|
|
|
err := pushWithSSHAgent(repo, "master", "v1.0.0")
|
|
if err == nil {
|
|
t.Error("expected error when no origin remote is configured")
|
|
}
|
|
}
|
|
|
|
func TestPushWithSSHAgentPushFails(t *testing.T) {
|
|
orig := newSSHAgentAuth
|
|
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
|
return &gitssh.PublicKeysCallback{User: user}, nil
|
|
}
|
|
defer func() { newSSHAgentAuth = orig }()
|
|
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.0.0")
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{"/nonexistent/bare/repo"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := pushWithSSHAgent(repo, "master", "v1.0.0")
|
|
if err == nil {
|
|
t.Error("expected error when remote push fails")
|
|
}
|
|
}
|
|
|
|
func TestPushWithSSHAgentSuccess(t *testing.T) {
|
|
remoteDir := t.TempDir()
|
|
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
orig := newSSHAgentAuth
|
|
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
|
return &gitssh.PublicKeysCallback{User: user}, nil
|
|
}
|
|
defer func() { newSSHAgentAuth = orig }()
|
|
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.0.0")
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{remoteDir},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Local transport ignores auth — push succeeds regardless of the mock callback.
|
|
if err := pushWithSSHAgent(repo, "master", "v1.0.0"); err != nil {
|
|
t.Fatalf("expected success pushing to local bare remote: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── pushWithGoGit error paths ─────────────────────────────────────────────────
|
|
|
|
func TestPushWithGoGitNoRemote(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
// No remote → repo.Remote("origin") fails inside pushWithGoGit.
|
|
err := Push(repo, "master", "v1.0.0", "some-token")
|
|
if err == nil {
|
|
t.Error("expected error when no origin remote is configured")
|
|
}
|
|
}
|
|
|
|
func TestPushWithGoGitPushFails(t *testing.T) {
|
|
repo, dir := newTestRepo(t)
|
|
addCommit(t, repo, dir, "fix: c1", "v1")
|
|
addTag(t, repo, "v1.0.0")
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{"/nonexistent/bare/repo"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := Push(repo, "master", "v1.0.0", "some-token")
|
|
if err == nil {
|
|
t.Error("expected error when remote push fails")
|
|
}
|
|
}
|
|
|
|
// ── pushWithCLI: bare repo → no worktree ────────────────────────────────────
|
|
|
|
func TestPushWithCLIBareRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo, err := gogit.PlainInit(dir, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No token, no SSH URL → goes to pushWithCLI → Worktree() fails for bare repo.
|
|
err = Push(repo, "master", "v1.0.0", "")
|
|
if err == nil {
|
|
t.Error("expected error for bare repo (no worktree)")
|
|
}
|
|
}
|
|
|
|
func TestPushWithCLISuccess(t *testing.T) {
|
|
// Non-bare repo + local bare remote + no token + no SSH URL → pushWithCLI → success.
|
|
repo, dir := newTestRepo(t)
|
|
sig := testSig()
|
|
|
|
wt, _ := repo.Worktree()
|
|
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wt.Add("f.txt")
|
|
hash, err := wt.Commit("init", &gogit.CommitOptions{Author: sig})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := repo.CreateTag("v1.0.0", hash, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
remoteDir := t.TempDir()
|
|
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
|
Name: "origin",
|
|
URLs: []string{remoteDir},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Detect default branch name (go-git uses "master" but git config may differ).
|
|
head, _ := repo.Head()
|
|
branchName := head.Name().Short()
|
|
|
|
if err := Push(repo, branchName, "v1.0.0", ""); err != nil {
|
|
t.Fatalf("pushWithCLI success: %v", err)
|
|
}
|
|
}
|