Files
Fabric/cmd/generate_changelog/internal/git/walker.go
Kayvan Sylvan d2ebe99e0e fix: use native git CLI for add/commit in worktrees
go-git has issues with worktrees where the object database isn't properly
shared, causing 'invalid object' errors when trying to commit. Switching
to native git CLI for add and commit operations resolves this.

This fixes generate_changelog failing in worktrees with errors like:
- 'cannot create empty commit: clean working tree'
- 'error: invalid object ... Error building trees'

Co-Authored-By: Warp <agent@warp.dev>
2026-01-03 14:29:18 -08:00

596 lines
16 KiB
Go

package git
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/danielmiessler/fabric/cmd/generate_changelog/util"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
var (
// The versionPattern matches version commit messages with or without the optional "chore(release): " prefix.
// Examples of matching commit messages:
// - "chore(release): Update version to v1.2.3"
// - "Update version to v1.2.3"
// Examples of non-matching commit messages:
// - "fix: Update version to v1.2.3" (missing "chore(release): " or "Update version to")
// - "chore(release): Update version to 1.2.3" (missing "v" prefix in version)
// - "Update version to v1.2" (incomplete version number)
versionPattern = regexp.MustCompile(`(?:chore\(release\): )?Update version to (v\d+\.\d+\.\d+)`)
prPattern = regexp.MustCompile(`Merge pull request #(\d+)`)
)
type Walker struct {
repo *git.Repository
}
func NewWalker(repoPath string) (*Walker, error) {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
return &Walker{repo: repo}, nil
}
// GetLatestTag returns the name of the most recent tag by committer date
func (w *Walker) GetLatestTag() (string, error) {
tagRefs, err := w.repo.Tags()
if err != nil {
return "", err
}
var latestTagCommit *object.Commit
var latestTagName string
err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error {
revision := plumbing.Revision(tagRef.Name().String())
tagCommitHash, err := w.repo.ResolveRevision(revision)
if err != nil {
return err
}
commit, err := w.repo.CommitObject(*tagCommitHash)
if err != nil {
return err
}
if latestTagCommit == nil {
latestTagCommit = commit
latestTagName = tagRef.Name().Short() // Get short name like "v1.4.245"
}
if commit.Committer.When.After(latestTagCommit.Committer.When) {
latestTagCommit = commit
latestTagName = tagRef.Name().Short()
}
return nil
})
if err != nil {
return "", err
}
return latestTagName, nil
}
// WalkCommitsSinceTag walks commits from the specified tag to HEAD and returns only "Unreleased" version
func (w *Walker) WalkCommitsSinceTag(tagName string) (*Version, error) {
// Get the tag reference
tagRef, err := w.repo.Tag(tagName)
if err != nil {
return nil, fmt.Errorf("failed to find tag %s: %w", tagName, err)
}
// Get the commit that the tag points to
tagCommit, err := w.repo.CommitObject(tagRef.Hash())
if err != nil {
return nil, fmt.Errorf("failed to get tag commit: %w", err)
}
// Get HEAD
headRef, err := w.repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD: %w", err)
}
// Walk from HEAD back to the tag commit (exclusive)
commitIter, err := w.repo.Log(&git.LogOptions{
From: headRef.Hash(),
Order: git.LogOrderCommitterTime,
})
if err != nil {
return nil, fmt.Errorf("failed to get commit log: %w", err)
}
version := &Version{
Name: "Unreleased",
Commits: []*Commit{},
}
prNumbers := []int{}
err = commitIter.ForEach(func(c *object.Commit) error {
// Stop when we reach the tag commit (don't include it)
if c.Hash == tagCommit.Hash {
return fmt.Errorf("reached tag commit") // Use error to break out of iteration
}
commit := &Commit{
SHA: c.Hash.String(),
Message: strings.TrimSpace(c.Message),
Date: c.Committer.When,
}
// Check for version patterns
if versionMatch := versionPattern.FindStringSubmatch(commit.Message); versionMatch != nil {
commit.IsVersion = true
}
// Check for PR merge patterns
if prMatch := prPattern.FindStringSubmatch(commit.Message); prMatch != nil {
if prNumber, err := strconv.Atoi(prMatch[1]); err == nil {
commit.PRNumber = prNumber
prNumbers = append(prNumbers, prNumber)
}
}
version.Commits = append(version.Commits, commit)
return nil
})
// Ignore the "reached tag commit" error - it's expected
if err != nil && !strings.Contains(err.Error(), "reached tag commit") {
return nil, fmt.Errorf("failed to walk commits: %w", err)
}
// Remove duplicates from prNumbers and set them
prNumbersMap := make(map[int]bool)
for _, prNum := range prNumbers {
prNumbersMap[prNum] = true
}
version.PRNumbers = make([]int, 0, len(prNumbersMap))
for prNum := range prNumbersMap {
version.PRNumbers = append(version.PRNumbers, prNum)
}
return version, nil
}
func (w *Walker) WalkHistory() (map[string]*Version, error) {
ref, err := w.repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD: %w", err)
}
commitIter, err := w.repo.Log(&git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
})
if err != nil {
return nil, fmt.Errorf("failed to get commit log: %w", err)
}
versions := make(map[string]*Version)
currentVersion := "Unreleased"
versions[currentVersion] = &Version{
Name: currentVersion,
Commits: []*Commit{},
}
prNumbers := make(map[string][]int)
err = commitIter.ForEach(func(c *object.Commit) error {
// c.Message = Summarize(c.Message)
commit := &Commit{
SHA: c.Hash.String(),
Message: strings.TrimSpace(c.Message),
Author: c.Author.Name,
Email: c.Author.Email,
Date: c.Author.When,
IsMerge: len(c.ParentHashes) > 1,
}
if matches := versionPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
commit.IsVersion = true
commit.Version = matches[1]
currentVersion = commit.Version
if _, exists := versions[currentVersion]; !exists {
versions[currentVersion] = &Version{
Name: currentVersion,
Date: commit.Date,
CommitSHA: commit.SHA,
Commits: []*Commit{},
}
}
return nil
}
if matches := prPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
prNumber := 0
fmt.Sscanf(matches[1], "%d", &prNumber)
commit.PRNumber = prNumber
prNumbers[currentVersion] = append(prNumbers[currentVersion], prNumber)
}
versions[currentVersion].Commits = append(versions[currentVersion].Commits, commit)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk commits: %w", err)
}
for version, prs := range prNumbers {
versions[version].PRNumbers = dedupInts(prs)
}
return versions, nil
}
func (w *Walker) GetRepoInfo() (owner string, name string, err error) {
remotes, err := w.repo.Remotes()
if err != nil {
return "", "", fmt.Errorf("failed to get remotes: %w", err)
}
// First try upstream (preferred for forks)
for _, remote := range remotes {
if remote.Config().Name == "upstream" {
urls := remote.Config().URLs
if len(urls) > 0 {
owner, name = parseGitHubURL(urls[0])
if owner != "" && name != "" {
return owner, name, nil
}
}
}
}
// Then try origin
for _, remote := range remotes {
if remote.Config().Name == "origin" {
urls := remote.Config().URLs
if len(urls) > 0 {
owner, name = parseGitHubURL(urls[0])
if owner != "" && name != "" {
return owner, name, nil
}
}
}
}
return "danielmiessler", "fabric", nil
}
func parseGitHubURL(url string) (owner, repo string) {
patterns := []string{
`github\.com[:/]([^/]+)/([^/.]+)`,
`github\.com[:/]([^/]+)/([^/]+)\.git$`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(url)
if len(matches) > 2 {
return matches[1], matches[2]
}
}
return "", ""
}
// WalkHistorySinceTag walks git history from HEAD down to (but not including) the specified tag
// and returns any version commits found along the way
func (w *Walker) WalkHistorySinceTag(sinceTag string) (map[string]*Version, error) {
// Get the commit SHA for the sinceTag
tagRef, err := w.repo.Tag(sinceTag)
if err != nil {
return nil, fmt.Errorf("failed to get tag %s: %w", sinceTag, err)
}
tagCommit, err := w.repo.CommitObject(tagRef.Hash())
if err != nil {
return nil, fmt.Errorf("failed to get commit for tag %s: %w", sinceTag, err)
}
// Get HEAD reference
ref, err := w.repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD: %w", err)
}
// Walk from HEAD down to the tag commit (excluding it)
commitIter, err := w.repo.Log(&git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
})
if err != nil {
return nil, fmt.Errorf("failed to create commit iterator: %w", err)
}
defer commitIter.Close()
versions := make(map[string]*Version)
currentVersion := "Unreleased"
prNumbers := make(map[string][]int)
err = commitIter.ForEach(func(c *object.Commit) error {
// Stop iteration when the hash of the current commit matches the hash of the specified sinceTag commit
if c.Hash == tagCommit.Hash {
return storer.ErrStop
}
commit := &Commit{
SHA: c.Hash.String(),
Message: strings.TrimSpace(c.Message),
Author: c.Author.Name,
Email: c.Author.Email,
Date: c.Author.When,
IsMerge: len(c.ParentHashes) > 1,
}
// Check for version pattern
if matches := versionPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
commit.IsVersion = true
commit.Version = matches[1]
currentVersion = commit.Version
if _, exists := versions[currentVersion]; !exists {
versions[currentVersion] = &Version{
Name: currentVersion,
Date: commit.Date,
CommitSHA: commit.SHA,
Commits: []*Commit{},
}
}
return nil
}
// Check for PR merge pattern
if matches := prPattern.FindStringSubmatch(commit.Message); len(matches) > 1 {
prNumber, err := strconv.Atoi(matches[1])
if err != nil {
// Handle parsing error (e.g., log it or skip processing)
return fmt.Errorf("failed to parse PR number: %v", err)
}
commit.PRNumber = prNumber
prNumbers[currentVersion] = append(prNumbers[currentVersion], prNumber)
}
// Add commit to current version
if _, exists := versions[currentVersion]; !exists {
versions[currentVersion] = &Version{
Name: currentVersion,
Date: time.Time{}, // Zero value, will be set by version commit
CommitSHA: "",
Commits: []*Commit{},
}
}
versions[currentVersion].Commits = append(versions[currentVersion].Commits, commit)
return nil
})
// Handle the stop condition - storer.ErrStop is expected
if err == storer.ErrStop {
err = nil
}
// Assign collected PR numbers to each version
for version, prs := range prNumbers {
versions[version].PRNumbers = dedupInts(prs)
}
return versions, err
}
func dedupInts(ints []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, i := range ints {
if !seen[i] {
seen[i] = true
result = append(result, i)
}
}
return result
}
// Worktree returns the git worktree for performing git operations
func (w *Walker) Worktree() (*git.Worktree, error) {
return w.repo.Worktree()
}
// Repository returns the underlying git repository
func (w *Walker) Repository() *git.Repository {
return w.repo
}
// IsWorkingDirectoryClean checks if the working directory has any uncommitted changes
func (w *Walker) IsWorkingDirectoryClean() (bool, error) {
worktree, err := w.repo.Worktree()
if err != nil {
return false, fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return false, fmt.Errorf("failed to get git status: %w", err)
}
worktreePath := worktree.Filesystem.Root()
// In worktrees, files staged in the main repo may appear in status but not exist in the worktree
// We need to check both the working directory status AND filesystem existence
for file, fileStatus := range status {
// Check if there are any changes in the working directory
if fileStatus.Worktree != git.Unmodified && fileStatus.Worktree != git.Untracked {
return false, nil
}
// For staged files (Added, Modified in index), verify they exist in this worktree's filesystem
// This handles the worktree case where the main repo has staged files that don't exist here
if fileStatus.Staging != git.Unmodified && fileStatus.Staging != git.Untracked {
filePath := filepath.Join(worktreePath, file)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// File is staged but doesn't exist in this worktree - ignore it
continue
}
// File is staged AND exists in this worktree - not clean
return false, nil
}
}
return true, nil
}
// GetStatusDetails returns a detailed status of the working directory
func (w *Walker) GetStatusDetails() (string, error) {
worktree, err := w.repo.Worktree()
if err != nil {
return "", fmt.Errorf("failed to get worktree: %w", err)
}
status, err := worktree.Status()
if err != nil {
return "", fmt.Errorf("failed to get git status: %w", err)
}
var details strings.Builder
for file, fileStatus := range status {
// Only include files with actual working directory changes
if fileStatus.Worktree != git.Unmodified && fileStatus.Worktree != git.Untracked {
details.WriteString(fmt.Sprintf(" %c%c %s\n", fileStatus.Staging, fileStatus.Worktree, file))
}
}
return details.String(), nil
}
// AddFile adds a file to the git index
// Uses native git CLI instead of go-git to properly handle worktree scenarios
func (w *Walker) AddFile(filename string) error {
worktree, err := w.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
worktreePath := worktree.Filesystem.Root()
// Use native git add command to avoid go-git worktree issues
cmd := exec.Command("git", "add", filename)
cmd.Dir = worktreePath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to add file %s: %w (output: %s)", filename, err, string(output))
}
return nil
}
// CommitChanges creates a commit with the given message
// Uses native git CLI instead of go-git to properly handle worktree scenarios
func (w *Walker) CommitChanges(message string) (plumbing.Hash, error) {
worktree, err := w.repo.Worktree()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err)
}
worktreePath := worktree.Filesystem.Root()
// Use native git commit command to avoid go-git worktree issues
cmd := exec.Command("git", "commit", "-m", message)
cmd.Dir = worktreePath
output, err := cmd.CombinedOutput()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to commit: %w (output: %s)", err, string(output))
}
// Get the commit hash from HEAD
ref, err := w.repo.Head()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get HEAD after commit: %w", err)
}
return ref.Hash(), nil
}
// PushToRemote pushes the current branch to the remote repository
// It automatically detects GitHub repositories and uses token authentication when available
func (w *Walker) PushToRemote() error {
pushOptions := &git.PushOptions{}
// Check if we have a GitHub token for authentication
if githubToken := util.GetTokenFromEnv(""); githubToken != "" {
// Get remote URL to check if it's a GitHub repository
remotes, err := w.repo.Remotes()
if err == nil && len(remotes) > 0 {
// Get the origin remote (or first remote if origin doesn't exist)
var remote *git.Remote
for _, r := range remotes {
if r.Config().Name == "origin" {
remote = r
break
}
}
if remote == nil {
remote = remotes[0]
}
// Check if this is a GitHub repository
urls := remote.Config().URLs
if len(urls) > 0 {
url := urls[0]
if strings.Contains(url, "github.com") {
// Use token authentication for GitHub repositories
pushOptions.Auth = &http.BasicAuth{
Username: "token", // GitHub expects "token" as username
Password: githubToken,
}
}
}
}
}
err := w.repo.Push(pushOptions)
if err != nil {
return fmt.Errorf("failed to push: %w", err)
}
return nil
}
// RemoveFile removes a file from both the working directory and git index
func (w *Walker) RemoveFile(filename string) error {
worktree, err := w.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
_, err = worktree.Remove(filename)
if err != nil {
return fmt.Errorf("failed to remove file %s: %w", filename, err)
}
return nil
}