diff --git a/internal/git/git_service.go b/internal/git/git_service.go index 0b122aa..d5fb504 100644 --- a/internal/git/git_service.go +++ b/internal/git/git_service.go @@ -3,6 +3,7 @@ package git import ( "context" "fmt" + "os" "os/exec" "regexp" "sort" @@ -31,11 +32,32 @@ func (s *GitService) SetFallback(name, email string) { // HasStagedChanges checks if there are changes in the staging area func (s *GitService) HasStagedChanges(ctx context.Context) bool { + log := logger.FromContext(ctx) + + repoRoot, err := s.getRepoRoot(ctx) + if err != nil { + log.Error("failed to get repo root", "error", err) + return false + } + cmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--quiet") - err := cmd.Run() + cmd.Dir = repoRoot + err = cmd.Run() - // If the command returns an error (exit status 1), it means there are staged changes - return err != nil && cmd.ProcessState.ExitCode() == 1 + hasChanges := err != nil && cmd.ProcessState.ExitCode() == 1 + + statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + statusCmd.Dir = repoRoot + statusOutput, _ := statusCmd.Output() + statusStr := strings.TrimSpace(string(statusOutput)) + + log.Debug("checking staged changes", + "repo_root", repoRoot, + "has_staged_changes", hasChanges, + "exit_code", cmd.ProcessState.ExitCode(), + "git_status", statusStr) + + return hasChanges } func (s *GitService) GetChangedFiles(ctx context.Context) ([]string, error) { @@ -109,7 +131,6 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { } } - // If still no diff after checking untracked files if combinedDiff == "" { log.Warn("no differences detected in repository") return "", errors.ErrNoDiff @@ -132,10 +153,16 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error { return errors.ErrNoChanges } + repoRoot, err := s.getRepoRoot(ctx) + if err != nil { + return err + } + log.Debug("creating git commit", "message_length", len(message)) cmd := exec.CommandContext(ctx, "git", "commit", "-m", message) + cmd.Dir = repoRoot var stderr strings.Builder cmd.Stderr = &stderr @@ -167,20 +194,48 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error { } func (s *GitService) AddFileToStaging(ctx context.Context, file string) error { + log := logger.FromContext(ctx) + repoRoot, err := s.getRepoRoot(ctx) if err != nil { return err } - cmd := exec.CommandContext(ctx, "git", "add", "-A", "--", file) + log.Debug("adding file to staging", + "file", file, + "repo_root", repoRoot) + + fullPath := repoRoot + "/" + file + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + log.Error("file does not exist", + "file", file, + "full_path", fullPath) + return errors.ErrAddFile.WithError(err).WithContext("file", file).WithContext("reason", "file_not_found") + } + + cmd := exec.CommandContext(ctx, "git", "add", "--", file) cmd.Dir = repoRoot var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { stderrStr := strings.TrimSpace(stderr.String()) + log.Error("failed to add file to staging", + "file", file, + "error", err, + "stderr", stderrStr) return errors.ErrAddFile.WithError(err).WithContext("file", file).WithContext("stderr", stderrStr) } + + statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--", file) + statusCmd.Dir = repoRoot + statusOutput, _ := statusCmd.Output() + log.Debug("git status after add", + "file", file, + "status", strings.TrimSpace(string(statusOutput))) + + log.Debug("file added to staging successfully", + "file", file) return nil } @@ -277,7 +332,6 @@ func (s *GitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]mode var args []string if tag == "" { - // if no previous tag exists, get all commits args = []string{"log", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"} } else { if err := s.ValidateTagExists(ctx, tag); err != nil { diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 6968b94..149709f 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -362,7 +362,10 @@ func (s *ReleaseService) UpdateLocalChangelog(release *models.Release, notes *mo log.Warn("failed to move Unreleased section", "error", err) } else { log.Info("moved Unreleased section to new version", "version", release.Version) - return nil + if s.hasUnreleasedContent(content) { + return nil + } + log.Debug("unreleased section was empty, will add new version entry") } } } @@ -464,11 +467,12 @@ func (s *ReleaseService) prependToChangelog(filename, newContent string) error { func (s *ReleaseService) prependToChangelogLegacy(filename, current, newContent string) error { var sb strings.Builder - idx := strings.Index(current, "\n## ") + versionPattern := regexp.MustCompile(`\n## \[[^]]+]`) + loc := versionPattern.FindStringIndex(current) - if idx != -1 { - pre := current[:idx] - post := current[idx:] + if loc != nil { + pre := current[:loc[0]] + post := current[loc[0]:] sb.WriteString(strings.TrimSpace(pre)) sb.WriteString("\n\n") @@ -490,11 +494,20 @@ func (s *ReleaseService) prependToChangelogLegacy(filename, current, newContent result := sb.String() + // Remove empty Unreleased sections that might be between versions + result = s.removeEmptyUnreleasedSections(result) + result = s.consolidateLinkDefinitions(result) return os.WriteFile(filename, []byte(result), 0644) } +// removeEmptyUnreleasedSections removes empty ## [Unreleased] sections that appear between versions +func (s *ReleaseService) removeEmptyUnreleasedSections(content string) string { + emptyUnreleasedPattern := regexp.MustCompile(`(?s)## \[Unreleased]\s*\n(?=## \[)`) + return emptyUnreleasedPattern.ReplaceAllString(content, "") +} + // consolidateLinkDefinitions removes duplicate link reference definitions func (s *ReleaseService) consolidateLinkDefinitions(content string) string { linkDefPattern := regexp.MustCompile(`(?m)^\[([^]]+)]:\s*(.+)$`) @@ -560,12 +573,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 return nil } - idx := strings.Index(current, "\n## ") - if idx == -1 { + versionPattern := regexp.MustCompile(`\n## \[[^]]+]`) + loc := versionPattern.FindStringIndex(current) + + if loc == nil { current = strings.TrimSpace(current) + "\n\n## [Unreleased]\n\n" } else { - pre := current[:idx] - post := current[idx:] + pre := current[:loc[0]] + post := current[loc[0]:] current = strings.TrimSpace(pre) + "\n\n## [Unreleased]\n\n" + post } @@ -584,6 +599,11 @@ func (s *ReleaseService) parseUnreleasedSection(content string) string { return strings.TrimSpace(matches[1]) } +// hasUnreleasedContent checks if the Unreleased section has actual content +func (s *ReleaseService) hasUnreleasedContent(content []byte) bool { + return s.parseUnreleasedSection(string(content)) != "" +} + // MoveUnreleasedToVersion moves Unreleased section content to a new version func (s *ReleaseService) MoveUnreleasedToVersion(filename string, release *models.Release, notes *models.ReleaseNotes) error { content, err := os.ReadFile(filename) @@ -601,11 +621,17 @@ func (s *ReleaseService) MoveUnreleasedToVersion(filename string, release *model unreleasedContent := s.parseUnreleasedSection(current) + log := logger.FromContext(context.Background()) + log.Debug("parsed unreleased section", + "unreleased_content", unreleasedContent, + "unreleased_content_length", len(unreleasedContent), + "is_empty", unreleasedContent == "") + if unreleasedContent == "" { + log.Info("unreleased section is empty, skipping migration") return nil } - log := logger.FromContext(context.Background()) log.Info("moving Unreleased section to new version", "version", release.Version, "unreleased_content_length", len(unreleasedContent)) @@ -617,6 +643,10 @@ func (s *ReleaseService) MoveUnreleasedToVersion(filename string, release *model unreleasedPattern := regexp.MustCompile(`(?s)## \[Unreleased]\s*\n.*?(\n## \[|$)`) current = unreleasedPattern.ReplaceAllString(current, "$1") + log.Debug("writing modified changelog without unreleased section", + "filename", filename, + "content_length", len(current)) + if err := os.WriteFile(filename, []byte(current), 0644); err != nil { return domainErrors.NewAppError( domainErrors.TypeInternal, @@ -628,6 +658,8 @@ func (s *ReleaseService) MoveUnreleasedToVersion(filename string, release *model ) } + log.Debug("changelog written successfully, prepending new version") + if err := s.prependToChangelog(filename, versionEntry); err != nil { return domainErrors.NewAppError( domainErrors.TypeInternal, @@ -974,9 +1006,14 @@ func (s *ReleaseService) formatReleaseItem(item models.ReleaseItem) string { } func (s *ReleaseService) CommitChangelog(ctx context.Context, version string) error { + log := logger.FromContext(ctx) + log.Info("starting changelog commit process", "version", version) + if err := s.git.AddFileToStaging(ctx, "CHANGELOG.md"); err != nil { + log.Error("failed to add CHANGELOG.md to staging", "error", err) return domainErrors.NewAppError(domainErrors.TypeGit, "failed to add CHANGELOG.md to staging", err) } + log.Debug("CHANGELOG.md added to staging") versionFile, _, err := s.FindVersionFile(ctx) if err != nil { @@ -988,19 +1025,30 @@ func (s *ReleaseService) CommitChangelog(ctx context.Context, version string) er } if _, err := os.Stat(versionFile); err == nil { + log.Debug("adding version file to staging", "file", versionFile) if err := s.git.AddFileToStaging(ctx, versionFile); err != nil { + log.Error("failed to add version file to staging", "file", versionFile, "error", err) return domainErrors.NewAppError(domainErrors.TypeGit, fmt.Sprintf("failed to add version file to staging: %s", versionFile), err) } + log.Debug("version file added to staging", "file", versionFile) + } else { + log.Debug("version file not found, skipping", "file", versionFile) } + log.Debug("checking for staged changes") if !s.git.HasStagedChanges(ctx) { + log.Error("no staged changes detected after adding files") return domainErrors.ErrNoChanges } + log.Debug("staged changes detected, proceeding with commit") message := fmt.Sprintf("chore: update changelog and bump version to %s", version) if err := s.git.CreateCommit(ctx, message); err != nil { + log.Error("failed to create commit", "error", err) return domainErrors.NewAppError(domainErrors.TypeGit, "failed to commit changelog and version bump", err) } + + log.Info("changelog commit process completed successfully") return nil }