diff --git a/.changesets/major-1788790888874863721.md b/.changesets/major-1788790888874863721.md new file mode 100644 index 0000000..2e1fb70 --- /dev/null +++ b/.changesets/major-1788790888874863721.md @@ -0,0 +1 @@ +Introduced `changedFilePattern` to allow filtering of changed files against user-defined patterns, ensuring version bumps are only triggered by relevant file modifications. diff --git a/.changesets/minor-1788790839776692553.md b/.changesets/minor-1788790839776692553.md new file mode 100644 index 0000000..ae8c188 --- /dev/null +++ b/.changesets/minor-1788790839776692553.md @@ -0,0 +1 @@ +Migrated internal Git operations from `os/exec` shell commands to the `go-git` library to make the CLI fully self-contained and cross-platform compatible. diff --git a/.changesets/patch-1788520819013459586.md b/.changesets/patch-1788520819013459586.md index 4828654..480ada4 100644 --- a/.changesets/patch-1788520819013459586.md +++ b/.changesets/patch-1788520819013459586.md @@ -1 +1 @@ -Migrate description input to chzyer/readline to fix multi-print pasting bugs, add interrupt handling for clean exits, and resolve filepath variable shadowing. +Migrate description input to `chzyer/readline` to fix multi-print pasting bugs, add interrupt handling for clean exits, and resolve filepath variable shadowing. diff --git a/changeset/changelog.go b/changeset/changelog.go index 74ecc87..848262f 100644 --- a/changeset/changelog.go +++ b/changeset/changelog.go @@ -24,8 +24,14 @@ func ApplyChangesets(cfg config.Config) (string, error) { bumpType := determineBumpType(majors, minors, patches) - current, _ := GetLatestVersion() - newVersion, _ := BumpVersion(current, bumpType) + current, err := GetLatestVersion() + if err != nil { + return "", err + } + newVersion, err := BumpVersion(current, bumpType) + if err != nil { + return "", err + } if cfg.Changelog.Enabled { if err := updateChangelog(newVersion, majors, minors, patches, cfg); err != nil { diff --git a/changeset/changeset.go b/changeset/changeset.go index 94a1e4c..40299a1 100644 --- a/changeset/changeset.go +++ b/changeset/changeset.go @@ -11,6 +11,7 @@ import ( "github.com/ChanduBobbili/changesetgoo/config" "github.com/ChanduBobbili/changesetgoo/enums" + "github.com/ChanduBobbili/changesetgoo/utils/git" "github.com/chzyer/readline" "github.com/manifoldco/promptui" ) @@ -49,7 +50,15 @@ func AddChangeset(releaseType enums.ReleaseType, message string, cfg config.Conf } // InteractiveAdd allows user to input bump type and description -func InteractiveAdd(cfg config.Config) error { +func InteractiveAdd(gitRepo *git.GitRepository, cfg config.Config) error { + hasChanges, err := HasChangesForChangeset(gitRepo, cfg) + if err != nil { + return err + } + if !hasChanges { + return fmt.Errorf("no changes detected; nothing to add a changeset for") + } + bump, err := PromptReleaseType() if err != nil { return err diff --git a/changeset/status.go b/changeset/status.go new file mode 100644 index 0000000..b47cc90 --- /dev/null +++ b/changeset/status.go @@ -0,0 +1,155 @@ +package changeset + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/ChanduBobbili/changesetgoo/config" + "github.com/ChanduBobbili/changesetgoo/utils/git" +) + +// GetChangedFiles returns the files changed between a ref and HEAD. +func GetChangedFiles(gitRepo *git.GitRepository, ref string) ([]string, error) { + return gitRepo.DiffTreesFromRefs(ref, "HEAD") +} + +func ResolveChangeRef(gitRepo *git.GitRepository, cfg config.Config) (string, error) { + branch, err := gitRepo.GetCurrentBranch() + if err != nil { + return "", err + } + + if branch != cfg.BaseBranch { + return cfg.BaseBranch, nil + } + + version, err := GetLatestVersion() + if err != nil { + return "", err + } + tag := cfg.TagPrefix + version + + exists, err := gitRepo.CheckTagExists(tag) + if err != nil { + return "", fmt.Errorf("failed to check tag %s: %w", tag, err) + } + if exists { + return tag, nil + } + + rootCommit, err := gitRepo.GetRootCommit() + if err != nil { + return "", fmt.Errorf("no tag %s found and failed to resolve root commit: %w", tag, err) + } + return rootCommit, nil +} + +func HasChangesForChangeset(gitRepo *git.GitRepository, cfg config.Config) (bool, error) { + ref, err := ResolveChangeRef(gitRepo, cfg) + if err != nil { + return false, err + } + return gitRepo.HasChangesSinceRef(ref) +} + +// GetRelevantChangedFiles returns changed files matching ChangedFilePatterns. +func GetRelevantChangedFiles(gitRepo *git.GitRepository, cfg config.Config) ([]string, error) { + ref, err := ResolveChangeRef(gitRepo, cfg) + if err != nil { + return nil, err + } + + files, err := GetChangedFiles(gitRepo, ref) + if err != nil { + return nil, err + } + + relevant := make([]string, 0) + for _, file := range files { + if MatchesAnyPattern(file, cfg.ChangedFilePatterns) { + relevant = append(relevant, file) + } + } + + return relevant, nil +} + +// MatchesAnyPattern reports whether a file matches at least one pattern. +func MatchesAnyPattern(file string, patterns []string) bool { + for _, pattern := range patterns { + if globToRegexp(pattern).MatchString(file) { + return true + } + } + return false +} + +func globToRegexp(pattern string) *regexp.Regexp { + var b strings.Builder + b.WriteString("^") + + for i := 0; i < len(pattern); { + switch { + case strings.HasPrefix(pattern[i:], "**"): + b.WriteString(".*") + i += 2 + case pattern[i] == '*': + b.WriteString("[^/]*") + i++ + case pattern[i] == '?': + b.WriteString(".") + i++ + default: + b.WriteString(regexp.QuoteMeta(string(pattern[i]))) + i++ + } + } + + b.WriteString("$") + return regexp.MustCompile(b.String()) +} + +// HasPendingChangesets reports whether there are any pending .md changesets. +func HasPendingChangesets(changesDir string) (bool, error) { + entries, err := os.ReadDir(changesDir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") { + return true, nil + } + } + + return false, nil +} + +// CheckChangesetRequirement enforces changedFilePatterns semantics for CI-style +// checks. It returns whether the check passes and the relevant matched files. +func CheckChangesetRequirement(gitRepo *git.GitRepository, cfg config.Config) (bool, []string, error) { + relevantFiles, err := GetRelevantChangedFiles(gitRepo, cfg) + if err != nil { + return false, nil, err + } + + if len(relevantFiles) == 0 { + return true, relevantFiles, nil + } + + hasPending, err := HasPendingChangesets(cfg.ChangesDir) + if err != nil { + return false, nil, err + } + + if hasPending { + return true, relevantFiles, nil + } + + return false, relevantFiles, nil +} diff --git a/cmd/changesetgoo/main.go b/cmd/changesetgoo/main.go index 8525e93..0e6c663 100644 --- a/cmd/changesetgoo/main.go +++ b/cmd/changesetgoo/main.go @@ -1,9 +1,9 @@ package main import ( + "flag" "fmt" "os" - "os/exec" "regexp" "strings" @@ -11,200 +11,229 @@ import ( "github.com/ChanduBobbili/changesetgoo/config" "github.com/ChanduBobbili/changesetgoo/constants" "github.com/ChanduBobbili/changesetgoo/enums" -) - -var ( - flagYes bool - flagPush bool + "github.com/ChanduBobbili/changesetgoo/utils/exits" + "github.com/ChanduBobbili/changesetgoo/utils/git" + "github.com/manifoldco/promptui" ) func main() { - // Default values for flags - flagYes = false - flagPush = false + // Define command-line flags + repoPath := flag.String("repo", ".", "Path to the target git repository") + flagYes := flag.Bool("yes", false, "Auto-confirm publish without prompting") + flagPush := flag.Bool("push", false, "Push changes after publishing") + flagCheck := flag.Bool("check", false, "Check changeset requirements") + + // Custom usage function to display help + flag.Usage = func() { + exits.WithInfo(getUsage()) + } + // Parse command-line flags + flag.Parse() args := os.Args[1:] if len(args) < 1 { - printUsage() - os.Exit(1) + exits.WithInfo(getUsage()) } - // First arg is the subcommand - cmd := args[0] - - // Parse flags that appear after the subcommand - for _, arg := range args[1:] { - switch arg { - case "--push": - flagPush = true - case "--yes": - flagYes = true - default: - // If it's unknown, ignore or handle positional args - } + // Open the git repository + gitRepo, err := git.OpenGitRepo(repoPath) + if err != nil { + exits.WithGitError("%v", err) } + // First arg is the command + cmd := args[0] + cfg, err := config.LoadConfig() if err != nil { fmt.Fprintf(os.Stderr, "⚠️ Failed to load config: %v. Using defaults.\n", err) cfg = config.Defaults() } - // Handle subcommands + // Handle commands switch cmd { case "add": - runAdd(cfg) + runAdd(cfg, gitRepo) case "version": - runVersion(cfg) + runVersion(cfg, gitRepo) case "tag": - runTag(cfg) + runTag(cfg, gitRepo, *flagPush) + case "status": + runStatus(cfg, gitRepo) case "publish": - runPublish(cfg) + runPublish(cfg, gitRepo, *flagYes, *flagPush, *flagCheck) case "--version", "-v": printCLIVersion() - case "help", "--help", "-h": - printUsage() - os.Exit(0) default: fmt.Printf("Unknown command: %s\n", cmd) - printUsage() - os.Exit(2) + exits.WithUsageError(getUsage()) } + + exits.WithSuccess("") } -func runAdd(cfg config.Config) { - if err := changeset.InteractiveAdd(cfg); err != nil { - fmt.Println("⚠️ Failed to add changeset:", err) - os.Exit(1) +func runAdd(cfg config.Config, gitRepo *git.GitRepository) { + if err := changeset.InteractiveAdd(gitRepo, cfg); err != nil { + exits.WithError("⚠️ Failed to add changeset: %v", err) } - fmt.Println("✅ Changeset added") - os.Exit(0) + + exits.WithSuccess("✅ Changeset added") } -func runVersion(cfg config.Config) { - newVer, err := changeset.ApplyChangesets(cfg) - if err != nil { - fmt.Println("⚠️", err) - os.Exit(1) +func runVersion(cfg config.Config, gitRepo *git.GitRepository) { + tagName := bumpVersion(cfg) + + if cfg.Commit.Enabled { + commitChanges(gitRepo, tagName, cfg) } - fmt.Printf("✅ Version bumped to %s%s\n", cfg.TagPrefix, newVer) - os.Exit(0) } -func runTag(cfg config.Config) { +func runTag(cfg config.Config, gitRepo *git.GitRepository, flagPush bool) { version, err := changeset.GetLatestVersion() if err != nil { - fmt.Println("⚠️ Failed to get latest version:", err) - os.Exit(1) + exits.WithError("⚠️ Failed to get latest version: %v", err) } tagName := cfg.TagPrefix + version - checkCmd := exec.Command("git", "tag", "--list", tagName) - out, _ := checkCmd.Output() - if string(out) != "" { - fmt.Printf("⚠️ Tag %s already exists, skipping.\n", tagName) - os.Exit(0) - } - createTag(tagName, cfg.TagPrefix) + if tagExists, err := gitRepo.CheckTagExists(tagName); err != nil { + exits.WithError("⚠️ Failed to check tag existence: %v", err) + } else if tagExists { + exits.WithInfo("⚠️ Tag %s already exists, skipping.", tagName) + } else { + createTag(tagName, cfg.TagPrefix, gitRepo) + } if flagPush { - pushTags() + pushTags(gitRepo) } - os.Exit(0) + + exits.WithSuccess("✅ Tag created: %s", tagName) } -func runPublish(cfg config.Config) { +func runPublish(cfg config.Config, gitRepo *git.GitRepository, flagYes bool, flagPush bool, flagCheck bool) { + if flagCheck { + passes, relevantFiles, err := changeset.CheckChangesetRequirement(gitRepo, cfg) + if err != nil { + exits.WithError("⚠️ Failed to validate changeset requirement: %v", err) + } + if !passes { + for _, file := range relevantFiles { + fmt.Println(" -", file) + } + exits.WithInfo("⚠️ Relevant changes detected but no pending changeset was found\n Run: changesetgoo add") + } + } + nextVer, bumpType, err := changeset.CalculateNextVersion(cfg) if err != nil { - fmt.Println("⚠️", err) - os.Exit(1) + exits.WithError("⚠️ Failed to calculate next version: %v", err) } - previewRelease(nextVer, bumpType, cfg.TagPrefix) - - if !flagYes { - confirmRelease() - } + // Preview and confirm the release interactively + previewAndConfirmReleaseInteractive(nextVer, bumpType, cfg.TagPrefix, flagYes) tagName := bumpVersion(cfg) if cfg.Commit.Enabled { - commitChanges(tagName, cfg) + commitChanges(gitRepo, tagName, cfg) } - createTag(tagName, cfg.TagPrefix) + if tagExists, err := gitRepo.CheckTagExists(tagName); err != nil { + exits.WithError("⚠️ Failed to check tag existence: %v", err) + } else if tagExists { + exits.WithInfo("⚠️ Tag %s already exists, skipping.", tagName) + } else { + createTag(tagName, cfg.TagPrefix, gitRepo) + } if flagPush { - pushTags() + pushTags(gitRepo) + } + + exits.WithSuccess("🎉 Published: %s\n", tagName) +} + +func runStatus(cfg config.Config, gitRepo *git.GitRepository) { + passes, relevantFiles, err := changeset.CheckChangesetRequirement(gitRepo, cfg) + if err != nil { + exits.WithError("⚠️ Failed to validate changeset requirement: %v", err) + } + + if passes && len(relevantFiles) == 0 { + exits.WithSuccess("✅ No changes matched changedFilePatterns against %s\n", cfg.BaseBranch) + } + if passes { + exits.WithSuccess("✅ Relevant changes detected and pending changesets are present") } - fmt.Printf("🎉 Published: %s\n", tagName) - os.Exit(0) + for _, file := range relevantFiles { + fmt.Println(" -", file) + } + exits.WithInfo("⚠️ Relevant changes detected but no pending changeset was found\n Run: changesetgoo add") } -func previewRelease(nextVer string, bumpType enums.ReleaseType, tagPrefix string) { +func previewAndConfirmReleaseInteractive(nextVer string, bumpType enums.ReleaseType, tagPrefix string, flagYes bool) { fmt.Println("📦 Release preview") fmt.Println("------------------") fmt.Printf(" Pending bump : %s\n", bumpType) fmt.Printf(" Next version : %s%s\n\n", tagPrefix, nextVer) -} -func confirmRelease() { - fmt.Print("Do you want to continue with this release? (y/n): ") - var confirm string - fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { - fmt.Println("❌ Publish cancelled.") - os.Exit(2) + if flagYes { + return + } + + prompt := promptui.Select{ + Label: "Do you want to continue with this release?", + Items: []string{"Yes", "No"}, + } + + _, result, err := prompt.Run() + if err != nil || result == "No" { + exits.WithError("❌ Publish cancelled.") } } func bumpVersion(cfg config.Config) string { newVer, err := changeset.ApplyChangesets(cfg) if err != nil { - fmt.Println("⚠️", err) - os.Exit(1) + exits.WithError("⚠️ %v", err) } + tagName := cfg.TagPrefix + newVer fmt.Printf("✅ Version bumped: %s\n", tagName) + return tagName } -func commitChanges(tagName string, cfg config.Config) { +func commitChanges(gitRepo *git.GitRepository, tagName string, cfg config.Config) { version := strings.TrimPrefix(tagName, cfg.TagPrefix) commitMessage := config.Render(cfg.Commit.Message, map[string]string{"tag": tagName, "version": version}) - if err := runCmd("git", "add", "-A"); err != nil { - fmt.Println("⚠️ No changes to commit.") - } else if err := runCmd("git", "commit", "-m", commitMessage); err != nil { - fmt.Println("⚠️ No changes to commit.") + if err := gitRepo.AddFiles(nil); err != nil { + exits.WithGitError("%v", err) + } else if err := gitRepo.CommitChanges(commitMessage); err != nil { + exits.WithGitError("%v", err) } else { - fmt.Printf("✅ Committed release changes: %s\n", commitMessage) + exits.WithSuccess("✅ Committed release changes: %s\n", commitMessage) } } -func createTag(tagName string, tagPrefix string) { +func createTag(tagName string, tagPrefix string, gitRepo *git.GitRepository) { message := getChangelogForTag(tagName, tagPrefix) - if err := runCmd("git", "tag", "-a", tagName, "-m", message); err != nil { - fmt.Println("⚠️ Failed to create tag:", err) - os.Exit(3) + + if err := gitRepo.CreateTag(tagName, &message); err != nil { + exits.WithError("⚠️ Failed to create tag: %v", err) } - fmt.Printf("✅ Git tag %s created\n", tagName) + + exits.WithSuccess("✅ Git tag %s created with message:\n%s", tagName, message) } -func pushTags() { - if err := runCmd("git", "push", "--follow-tags"); err != nil { - fmt.Println("⚠️ Failed to push changes:", err) - os.Exit(3) +func pushTags(gitRepo *git.GitRepository) { + if err := gitRepo.PushCommitsAndTags(); err != nil { + exits.WithGitError("⚠️ Failed to push changes: %v", err) } - fmt.Println("✅ Changes pushed with tags") -} -func runCmd(name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + fmt.Println("✅ Changes pushed with tags") } func getChangelogForTag(tagName string, tagPrefix string) string { @@ -228,18 +257,23 @@ func getChangelogForTag(tagName string, tagPrefix string) string { return baseMessage // Return base message if no specific changelog is found } -func printUsage() { - fmt.Println("Usage: changesetgoo [flags]") - fmt.Println("\nCommands:") - fmt.Println(" add Add a new changeset interactively") - fmt.Println(" version Apply pending changesets and bump version") - fmt.Println(" tag Create a git tag for the latest version") - fmt.Println(" publish Bump version, commit, and create a tag") - fmt.Println(" help Show this help message") - fmt.Println(" --version, -v Show changesetgoo CLI version") - fmt.Println("\nFlags:") - fmt.Println(" --yes Auto-confirm publish without prompting") - fmt.Println(" --push Auto-push commits and tags after publish") +func getUsage() string { + return `Usage: changesetgoo [flags] + +Commands: + add Add a new changeset interactively + version Apply pending changesets and bump version + tag Create a git tag for the latest version + status Check changed files against changedFilePatterns + publish Bump version, commit, and create a tag + help Show this help message + --version, -v Show changesetgoo CLI version + +Flags: + --yes Auto-confirm publish without prompting + --push Auto-push commits and tags after publish + --check Enforce changedFilePatterns changeset requirement +` } func printCLIVersion() { diff --git a/config/config.go b/config/config.go index e903fe6..8a3b358 100644 --- a/config/config.go +++ b/config/config.go @@ -69,7 +69,7 @@ func Defaults() Config { Template: "## {{version}}", }, Commit: CommitConfig{ - Enabled: false, + Enabled: true, Message: "chore 🚀: release {{tag}}", }, BaseBranch: "main", diff --git a/go.mod b/go.mod index 3612458..ea30976 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,34 @@ module github.com/ChanduBobbili/changesetgoo -go 1.25.1 +go 1.26.0 require github.com/manifoldco/promptui v0.9.0 require ( github.com/chzyer/readline v1.5.1 + github.com/go-git/go-git/v5 v5.19.2 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum index 480d0cf..9ffbb80 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,14 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -7,12 +18,100 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/utils/exits/exits.go b/utils/exits/exits.go new file mode 100644 index 0000000..7b83d45 --- /dev/null +++ b/utils/exits/exits.go @@ -0,0 +1,43 @@ +package exits + +import ( + "fmt" + "os" +) + +// WithSuccess prints a message to stdout and exits with code 0 (Success) +func WithSuccess(message string, args ...interface{}) { + fmt.Fprintf(os.Stdout, message+"\n", args...) + os.Exit(0) +} + +// WithInfo prints a message to stdout and exits with code 0 (Success) +func WithInfo(message string, args ...interface{}) { + fmt.Fprintf(os.Stdout, message+"\n", args...) + os.Exit(0) +} + +// WithWarning prints a warning to stderr and exits with code 0 +// Warnings typically indicate potential issues but not a complete failure. +func WithWarning(message string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Warning: "+message+"\n", args...) + os.Exit(0) +} + +// WithError prints a message to stderr and exits with code 1 (General error) +func WithError(message string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Error: "+message+"\n", args...) + os.Exit(1) +} + +// WithUsageError prints a message to stderr and exits with code 2 (Invalid usage) +func WithUsageError(message string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Usage Error: "+message+"\n", args...) + os.Exit(2) +} + +// WithGitError prints a message to stderr and exits with code 3 (Git-related error) +func WithGitError(message string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Git Error: "+message+"\n", args...) + os.Exit(3) +} diff --git a/utils/git/go-git.go b/utils/git/go-git.go new file mode 100644 index 0000000..6e0dd20 --- /dev/null +++ b/utils/git/go-git.go @@ -0,0 +1,292 @@ +package git + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/ChanduBobbili/changesetgoo/utils" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type GitRepository struct { + repo *git.Repository +} + +func OpenGitRepo(repoPath *string) (*GitRepository, error) { + repo, err := git.PlainOpenWithOptions(*repoPath, &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return nil, fmt.Errorf("failed to open the git repository: %v", err) + } + return &GitRepository{repo: repo}, nil +} + +func (g *GitRepository) GetRepo() *git.Repository { + return g.repo +} + +func (g *GitRepository) GetGitUser() (userName string, userEmail string, err error) { + config, err := g.repo.Config() + if err != nil { + return "", "", fmt.Errorf("failed to get git config: %w", err) + } + return config.User.Name, config.User.Email, nil +} + +func (g *GitRepository) GetCurrentBranch() (string, error) { + head, err := g.repo.Head() + if err != nil { + return "", fmt.Errorf("Error getting HEAD: %v", err) + } + + branchName := head.Name().Short() + return branchName, nil +} + +func (g *GitRepository) GetRepoHead() (*plumbing.Reference, error) { + return g.repo.Head() +} + +func (g *GitRepository) GetRootCommit() (string, error) { + isShallow, err := utils.ExecuteCommandOutput("git", "rev-parse", "--is-shallow-repository") + if err == nil && strings.TrimSpace(isShallow) == "true" { + return "", fmt.Errorf("repository is shallow; fetch full history before resolving root commit") + } + + out, err := utils.ExecuteCommandOutput("git", "rev-list", "--max-parents=0", "HEAD") + if err != nil { + return "", fmt.Errorf("failed to resolve root commit: %w", err) + } + + trimmed := strings.TrimSpace(out) + if trimmed == "" { + return "", fmt.Errorf("no commits found in repository") + } + + parts := strings.Fields(trimmed) + if len(parts) == 0 { + return "", fmt.Errorf("no commits found in repository") + } + return parts[0], nil +} + +func (g *GitRepository) DiffTreesFromRefs(fromRef string, toRef string) ([]string, error) { + fromHash, err := g.repo.ResolveRevision(plumbing.Revision(fromRef)) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", fromRef, err) + } + toHash, err := g.repo.ResolveRevision(plumbing.Revision(toRef)) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", toRef, err) + } + + fromCommit, err := g.repo.CommitObject(*fromHash) + if err != nil { + return nil, fmt.Errorf("failed to load source commit: %w", err) + } + toCommit, err := g.repo.CommitObject(*toHash) + if err != nil { + return nil, fmt.Errorf("failed to load target commit: %w", err) + } + + fromTree, err := fromCommit.Tree() + if err != nil { + return nil, fmt.Errorf("failed to load source tree: %w", err) + } + toTree, err := toCommit.Tree() + if err != nil { + return nil, fmt.Errorf("failed to load target tree: %w", err) + } + + changes, err := fromTree.Diff(toTree) + if err != nil { + return nil, fmt.Errorf("failed to diff trees: %w", err) + } + + files := make([]string, 0, len(changes)) + seen := make(map[string]struct{}, len(changes)) + for _, change := range changes { + name := "" + if change.To.Name != "" { + name = change.To.Name + } else if change.From.Name != "" { + name = change.From.Name + } + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + files = append(files, name) + } + + return files, nil +} + +// AddFiles adds the specified files to the staging area (index) of the git repository. +// filePaths is a pointer to a slice of strings representing the file paths to be added. +// If filePaths is nil or empty, it will add all changes in the working directory. +func (g *GitRepository) AddFiles(filePaths *[]string) error { + wt, err := g.repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + if filePaths == nil || len(*filePaths) == 0 { + // Add all changes in the working directory + return wt.AddWithOptions(&git.AddOptions{All: true}) + } + + // Add specified files + for _, path := range *filePaths { + if _, err := wt.Add(path); err != nil { + return fmt.Errorf("failed to add file %s: %w", path, err) + } + } + return nil +} + +func (g *GitRepository) CommitChanges(message string) error { + wt, err := g.repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + userName, userEmail, err := g.GetGitUser() + if err != nil { + return fmt.Errorf("failed to get git user: %w", err) + } + if userName == "" { + userName = "changesetgoo" + } + if userEmail == "" { + userEmail = "changesetgoo@cli" + } + + _, err = wt.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: userName, + Email: userEmail, + When: time.Now(), + }, + }) + if err != nil { + return fmt.Errorf("failed to commit changes: %w", err) + } + + return nil +} + +func (g *GitRepository) CheckTagExists(tagName string) (bool, error) { + _, err := g.repo.Tag(tagName) + if errors.Is(err, git.ErrTagNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to check tag existence: %w", err) + } + return true, nil +} + +func (g *GitRepository) CreateTag(tagName string, message *string) error { + userName, userEmail, err := g.GetGitUser() + if err != nil { + return fmt.Errorf("failed to get git user: %w", err) + } + // Fallback values in case the git config isn't globally or locally set + if userName == "" { + userName = "changesetgoo" + } + if userEmail == "" { + userEmail = "changesetgoo@cli" + } + + headRef, err := g.repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + + tagOptions := &git.CreateTagOptions{ + Tagger: &object.Signature{ + Name: userName, + Email: userEmail, + When: time.Now(), + }} + + if message != nil { + tagOptions.Message = *message + } + + _, err = g.repo.CreateTag(tagName, headRef.Hash(), tagOptions) + if err != nil { + return fmt.Errorf("failed to create tag: %w", err) + } + + return nil +} + +func (g *GitRepository) PushCommitsAndTags() error { + if err := utils.ExecuteCommand("git", "push", "--follow-tags"); err != nil { + return fmt.Errorf("failed to push commits and tags: %w", err) + } + return nil +} + +func (g *GitRepository) HasChangesSinceRef(ref string) (bool, error) { + // Resolve the target ref (e.g., "main", "HEAD~1", or a commit hash) + targetHash, err := g.repo.ResolveRevision(plumbing.Revision(ref)) + if err != nil { + return false, fmt.Errorf("failed to resolve ref %s: %w", ref, err) + } + + targetCommit, err := g.repo.CommitObject(*targetHash) + if err != nil { + return false, fmt.Errorf("failed to get target commit: %w", err) + } + targetTree, err := targetCommit.Tree() + if err != nil { + return false, err + } + + // Resolve the current HEAD + headRef, err := g.repo.Head() + if err != nil { + return false, fmt.Errorf("failed to get HEAD: %w", err) + } + headCommit, err := g.repo.CommitObject(headRef.Hash()) + if err != nil { + return false, err + } + headTree, err := headCommit.Tree() + if err != nil { + return false, err + } + + changes, err := targetTree.Diff(headTree) + if err != nil { + return false, fmt.Errorf("failed to diff trees: %w", err) + } + if len(changes) > 0 { + return true, nil // There are committed differences + } + + // Check the working directory for uncommitted modifications or untracked files + // This replaces the 'git ls-files --others' and the uncommitted portion of 'git diff' + wt, err := g.repo.Worktree() + if err != nil { + return false, fmt.Errorf("failed to get worktree: %w", err) + } + + status, err := wt.Status() + if err != nil { + return false, fmt.Errorf("failed to get worktree status: %w", err) + } + + // IsClean() returns true only if the working tree exactly matches HEAD (no untracked, no modified) + return !status.IsClean(), nil +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..493829f --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,23 @@ +package utils + +import ( + "os" + "os/exec" +) + +func ExecuteCommand(command string, args ...string) error { + cmd := exec.Command(command, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +func ExecuteCommandOutput(command string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +}