| | | 1 | | package git |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "bytes" |
| | | 5 | | "context" |
| | | 6 | | "fmt" |
| | | 7 | | "os/exec" |
| | | 8 | | "strings" |
| | | 9 | | "time" |
| | | 10 | | |
| | | 11 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 12 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 13 | | ) |
| | | 14 | | |
| | | 15 | | // Compile-time interface compliance check. |
| | | 16 | | var _ ports.GitRepository = (*Repository)(nil) |
| | | 17 | | |
| | | 18 | | // Repository implements ports.GitRepository using the git CLI. |
| | | 19 | | type Repository struct { |
| | | 20 | | workDir string |
| | | 21 | | } |
| | | 22 | | |
| | | 23 | | // NewRepository creates a new git CLI adapter. |
| | | 24 | | func NewRepository(workDir string) *Repository { |
| | | 25 | | return &Repository{workDir: workDir} |
| | | 26 | | } |
| | | 27 | | |
| | 18 | 28 | | func (r *Repository) run(ctx context.Context, args ...string) (string, error) { |
| | 18 | 29 | | cmd := exec.CommandContext(ctx, "git", args...) |
| | 18 | 30 | | cmd.Dir = r.workDir |
| | 18 | 31 | | var stdout, stderr bytes.Buffer |
| | 18 | 32 | | cmd.Stdout = &stdout |
| | 18 | 33 | | cmd.Stderr = &stderr |
| | 4 | 34 | | if err := cmd.Run(); err != nil { |
| | 4 | 35 | | return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), stderr.String(), err) |
| | 4 | 36 | | } |
| | 14 | 37 | | return strings.TrimSpace(stdout.String()), nil |
| | | 38 | | } |
| | | 39 | | |
| | 1 | 40 | | func (r *Repository) CurrentBranch(ctx context.Context) (string, error) { |
| | 1 | 41 | | return r.run(ctx, "rev-parse", "--abbrev-ref", "HEAD") |
| | 1 | 42 | | } |
| | | 43 | | |
| | 2 | 44 | | func (r *Repository) ListTags(ctx context.Context) ([]domain.Tag, error) { |
| | 2 | 45 | | output, err := r.run(ctx, "tag", "--list", "--sort=-version:refname") |
| | 0 | 46 | | if err != nil { |
| | 0 | 47 | | return nil, err |
| | 0 | 48 | | } |
| | 1 | 49 | | if output == "" { |
| | 1 | 50 | | return nil, nil |
| | 1 | 51 | | } |
| | | 52 | | |
| | 1 | 53 | | lines := strings.Split(output, "\n") |
| | 1 | 54 | | tags := make([]domain.Tag, 0, len(lines)) |
| | 1 | 55 | | for _, line := range lines { |
| | 1 | 56 | | line = strings.TrimSpace(line) |
| | 0 | 57 | | if line == "" { |
| | 0 | 58 | | continue |
| | | 59 | | } |
| | 1 | 60 | | hash, err := r.run(ctx, "rev-list", "-1", line) |
| | 0 | 61 | | if err != nil { |
| | 0 | 62 | | return nil, fmt.Errorf("resolving tag %s: %w", line, err) |
| | 0 | 63 | | } |
| | 1 | 64 | | tags = append(tags, domain.Tag{ |
| | 1 | 65 | | Name: line, |
| | 1 | 66 | | Hash: hash, |
| | 1 | 67 | | }) |
| | | 68 | | } |
| | 1 | 69 | | return tags, nil |
| | | 70 | | } |
| | | 71 | | |
| | 2 | 72 | | func (r *Repository) CommitsSince(ctx context.Context, sinceHash string) ([]domain.Commit, error) { |
| | 2 | 73 | | args := []string{"log", "--format=%H|%an|%ae|%aI|%s|%b%x00"} |
| | 1 | 74 | | if sinceHash != "" { |
| | 1 | 75 | | args = append(args, sinceHash+"..HEAD") |
| | 1 | 76 | | } |
| | | 77 | | |
| | 2 | 78 | | output, err := r.run(ctx, args...) |
| | 0 | 79 | | if err != nil { |
| | 0 | 80 | | return nil, err |
| | 0 | 81 | | } |
| | 0 | 82 | | if output == "" { |
| | 0 | 83 | | return nil, nil |
| | 0 | 84 | | } |
| | | 85 | | |
| | 2 | 86 | | return parseCommitLog(output) |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | func parseCommitLog(output string) ([]domain.Commit, error) { |
| | | 90 | | entries := strings.Split(output, "\x00") |
| | | 91 | | commits := make([]domain.Commit, 0, len(entries)) |
| | | 92 | | |
| | | 93 | | for _, entry := range entries { |
| | | 94 | | entry = strings.TrimSpace(entry) |
| | | 95 | | if entry == "" { |
| | | 96 | | continue |
| | | 97 | | } |
| | | 98 | | |
| | | 99 | | commit, err := parseCommitEntry(entry) |
| | | 100 | | if err != nil { |
| | | 101 | | continue // skip unparseable entries |
| | | 102 | | } |
| | | 103 | | commits = append(commits, commit) |
| | | 104 | | } |
| | | 105 | | return commits, nil |
| | | 106 | | } |
| | | 107 | | |
| | | 108 | | func parseCommitEntry(entry string) (domain.Commit, error) { |
| | | 109 | | // First line: hash|author|email|date|subject |
| | | 110 | | // Remaining: body |
| | | 111 | | lines := strings.SplitN(entry, "\n", 2) |
| | | 112 | | firstLine := lines[0] |
| | | 113 | | |
| | | 114 | | parts := strings.SplitN(firstLine, "|", 6) |
| | | 115 | | if len(parts) < 5 { |
| | | 116 | | return domain.Commit{}, fmt.Errorf("unexpected commit format: %q", firstLine) |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | date, err := time.Parse(time.RFC3339, parts[3]) |
| | | 120 | | if err != nil { |
| | | 121 | | return domain.Commit{}, fmt.Errorf("parsing commit date %q: %w", parts[3], err) |
| | | 122 | | } |
| | | 123 | | |
| | | 124 | | body := "" |
| | | 125 | | if len(parts) >= 6 { |
| | | 126 | | body = parts[5] |
| | | 127 | | } |
| | | 128 | | if len(lines) > 1 { |
| | | 129 | | body = body + "\n" + lines[1] |
| | | 130 | | } |
| | | 131 | | |
| | | 132 | | return domain.Commit{ |
| | | 133 | | Hash: parts[0], |
| | | 134 | | Author: parts[1], |
| | | 135 | | AuthorEmail: parts[2], |
| | | 136 | | Date: date, |
| | | 137 | | Message: parts[4], |
| | | 138 | | Body: strings.TrimSpace(body), |
| | | 139 | | }, nil |
| | | 140 | | } |
| | | 141 | | |
| | 1 | 142 | | func (r *Repository) FilesChangedInCommit(ctx context.Context, hash string) ([]string, error) { |
| | 1 | 143 | | output, err := r.run(ctx, "diff-tree", "--no-commit-id", "--name-only", "-r", hash) |
| | 0 | 144 | | if err != nil { |
| | 0 | 145 | | return nil, err |
| | 0 | 146 | | } |
| | 0 | 147 | | if output == "" { |
| | 0 | 148 | | return nil, nil |
| | 0 | 149 | | } |
| | 1 | 150 | | lines := strings.Split(output, "\n") |
| | 1 | 151 | | result := make([]string, 0, len(lines)) |
| | 1 | 152 | | for _, l := range lines { |
| | 1 | 153 | | if l != "" { |
| | 1 | 154 | | result = append(result, l) |
| | 1 | 155 | | } |
| | | 156 | | } |
| | 1 | 157 | | return result, nil |
| | | 158 | | } |
| | | 159 | | |
| | 4 | 160 | | func (r *Repository) CreateTag(ctx context.Context, name, hash, message string) error { |
| | 4 | 161 | | var err error |
| | 1 | 162 | | if message != "" { |
| | 1 | 163 | | _, err = r.run(ctx, "tag", "-a", name, hash, "-m", message) |
| | 1 | 164 | | } else { |
| | 3 | 165 | | _, err = r.run(ctx, "tag", name, hash) |
| | 3 | 166 | | } |
| | 3 | 167 | | if err == nil { |
| | 3 | 168 | | return nil |
| | 3 | 169 | | } |
| | | 170 | | // When the tag already exists, check whether it resolves to the same commit. |
| | | 171 | | // If it does, the operation is idempotent — return ErrTagAlreadyExists so |
| | | 172 | | // the caller can handle the re-run case without treating it as a hard failure. |
| | 1 | 173 | | if strings.Contains(err.Error(), "already exists") { |
| | 1 | 174 | | existing, resolveErr := r.run(ctx, "rev-parse", name+"^{commit}") |
| | 1 | 175 | | if resolveErr == nil && existing == hash { |
| | 1 | 176 | | return domain.ErrTagAlreadyExists |
| | 1 | 177 | | } |
| | | 178 | | } |
| | 0 | 179 | | return err |
| | | 180 | | } |
| | | 181 | | |
| | 1 | 182 | | func (r *Repository) PushTag(ctx context.Context, name string) error { |
| | 1 | 183 | | _, err := r.run(ctx, "push", "origin", name) |
| | 1 | 184 | | return err |
| | 1 | 185 | | } |
| | | 186 | | |
| | 1 | 187 | | func (r *Repository) HeadHash(ctx context.Context) (string, error) { |
| | 1 | 188 | | return r.run(ctx, "rev-parse", "HEAD") |
| | 1 | 189 | | } |
| | | 190 | | |
| | 1 | 191 | | func (r *Repository) RemoteURL(ctx context.Context) (string, error) { |
| | 1 | 192 | | return r.run(ctx, "remote", "get-url", "origin") |
| | 1 | 193 | | } |
| | | 194 | | |
| | 2 | 195 | | func (r *Repository) Stage(ctx context.Context, files []string) error { |
| | 1 | 196 | | if len(files) == 0 { |
| | 1 | 197 | | return nil |
| | 1 | 198 | | } |
| | 1 | 199 | | args := append([]string{"add", "--"}, files...) |
| | 1 | 200 | | _, err := r.run(ctx, args...) |
| | 1 | 201 | | return err |
| | | 202 | | } |
| | | 203 | | |
| | 1 | 204 | | func (r *Repository) Commit(ctx context.Context, message string) error { |
| | 1 | 205 | | _, err := r.run(ctx, "commit", "-m", message) |
| | 1 | 206 | | return err |
| | 1 | 207 | | } |
| | | 208 | | |
| | 1 | 209 | | func (r *Repository) Push(ctx context.Context) error { |
| | 1 | 210 | | _, err := r.run(ctx, "push", "origin", "HEAD") |
| | 1 | 211 | | return err |
| | 1 | 212 | | } |