feat: use go-git instead of "system git"
This commit is contained in:
parent
a4f4663749
commit
d980126473
9 changed files with 275 additions and 155 deletions
|
|
@ -2,11 +2,12 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing/transport/http"
|
||||
"github.com/scornet256/go-logger"
|
||||
)
|
||||
|
||||
|
|
@ -31,7 +32,6 @@ type GitStats struct {
|
|||
|
||||
// increment counters
|
||||
func (stats *GitStats) IncrementCounter(operation string, repoPath string) {
|
||||
|
||||
stats.mu.Lock()
|
||||
defer stats.mu.Unlock()
|
||||
|
||||
|
|
@ -54,9 +54,8 @@ func (stats *GitStats) IncrementCounter(operation string, repoPath string) {
|
|||
|
||||
// concurrent git operations
|
||||
func CheckoutRepositories(repositories []Repository, stats *GitStats) {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, config.Concurrency)
|
||||
semaphore := make(chan struct{}, globalConfig.Concurrency)
|
||||
|
||||
for _, repo := range repositories {
|
||||
wg.Add(1)
|
||||
|
|
@ -79,67 +78,67 @@ func CheckoutRepositories(repositories []Repository, stats *GitStats) {
|
|||
// manage single repo
|
||||
func processRepository(repo Repository) GitOperationResult {
|
||||
repoName := string(repo.PathWithNamespace)
|
||||
repoDestination := filepath.Join(config.Destination, repoName)
|
||||
repoDestination := filepath.Join(globalConfig.Destination, repoName)
|
||||
|
||||
logger.Print("Starting on repository: "+repoName, nil)
|
||||
|
||||
// check repo status
|
||||
status, err := checkRepositoryStatus(repoDestination)
|
||||
// check if repo exists
|
||||
_, err := git.PlainOpen(repoDestination)
|
||||
if err != nil {
|
||||
if err == git.ErrRepositoryNotExists {
|
||||
// repo doesn't exist, clone it
|
||||
gitURL := buildGitURL(repoName)
|
||||
return cloneRepository(repoName, repoDestination, gitURL)
|
||||
}
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("checking repository status: %w", err),
|
||||
Error: fmt.Errorf("opening repository: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
gitURL := buildGitURL(repoName)
|
||||
|
||||
switch {
|
||||
case strings.Contains(status, "No such file or directory"):
|
||||
return cloneRepository(repoName, repoDestination, gitURL)
|
||||
case strings.Contains(status, gitURL):
|
||||
return pullRepository(repoName, repoDestination)
|
||||
default:
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("unexpected repository status: %s", status),
|
||||
}
|
||||
}
|
||||
// repo exists, pull it
|
||||
return pullRepository(repoName, repoDestination)
|
||||
}
|
||||
|
||||
// check repo status
|
||||
func checkRepositoryStatus(repoDestination string) (string, error) {
|
||||
cmd := exec.Command("git", "-C", repoDestination, "remote", "-v")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// If directory doesn't exist, that's expected for new clones
|
||||
if err != nil && strings.Contains(string(output), "No such file or directory") {
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// craft git url with auth
|
||||
// craft git url without auth (auth handled separately)
|
||||
func buildGitURL(repoName string) string {
|
||||
return fmt.Sprintf("https://%s-token:%s@%s/%s.git",
|
||||
config.GitBackend, config.GitToken, config.GitHost, repoName)
|
||||
return fmt.Sprintf("https://%s/%s.git", globalConfig.GitHost, repoName)
|
||||
}
|
||||
|
||||
// create auth method
|
||||
func getAuth() *http.BasicAuth {
|
||||
return &http.BasicAuth{
|
||||
Username: globalConfig.GitBackend + "-token",
|
||||
Password: globalConfig.GitToken,
|
||||
}
|
||||
}
|
||||
|
||||
// clone new repository
|
||||
func cloneRepository(repoName, repoDestination, gitURL string) GitOperationResult {
|
||||
logger.Print("Cloning repository: "+repoName, nil)
|
||||
|
||||
cmd := exec.Command("git", "clone", gitURL, repoDestination)
|
||||
output, err := cmd.CombinedOutput()
|
||||
// ensure parent directory exists
|
||||
parentDir := filepath.Dir(repoDestination)
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("creating parent directory: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
_, err := git.PlainClone(repoDestination, &git.CloneOptions{
|
||||
URL: gitURL,
|
||||
Auth: getAuth(),
|
||||
Progress: nil,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("cloning repository: %w, output: %s", err, string(output)),
|
||||
Error: fmt.Errorf("cloning repository: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,27 +157,77 @@ func cloneRepository(repoName, repoDestination, gitURL string) GitOperationResul
|
|||
func pullRepository(repoName, repoDestination string) GitOperationResult {
|
||||
logger.Print("Pulling repository: "+repoName, nil)
|
||||
|
||||
// Find remote
|
||||
remote, err := findRemote(repoDestination)
|
||||
// open repository
|
||||
repo, err := git.PlainOpen(repoDestination)
|
||||
if err != nil {
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("finding remote: %w", err),
|
||||
Error: fmt.Errorf("opening repository: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
// update remote URL with current token (in case token changed)
|
||||
gitURL := buildGitURL(repoName)
|
||||
if err := updateRemoteURL(repoDestination, gitURL); err != nil {
|
||||
logger.Print("WARNING: failed to update remote URL: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
// get worktree
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("getting worktree: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
// check for uncommitted/unstaged changes
|
||||
status, err := worktree.Status()
|
||||
if err != nil {
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("checking status: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
if !status.IsClean() {
|
||||
// determine error type
|
||||
errorType := "unstaged"
|
||||
for _, s := range status {
|
||||
if s.Staging != git.Unmodified {
|
||||
errorType = "uncommitted"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("repository has local changes"),
|
||||
ErrorType: errorType,
|
||||
}
|
||||
}
|
||||
|
||||
// pull changes
|
||||
cmd := exec.Command("git", "-C", repoDestination, "pull", remote)
|
||||
output, err := cmd.CombinedOutput()
|
||||
err = worktree.Pull(&git.PullOptions{
|
||||
Auth: getAuth(),
|
||||
Progress: nil,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errorType := classifyPullError(string(output))
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("pulling repository: %w, output: %s", err, string(output)),
|
||||
ErrorType: errorType,
|
||||
if err == git.NoErrAlreadyUpToDate {
|
||||
// not an error, just already up to date
|
||||
logger.Print("Repository already up to date: "+repoName, nil)
|
||||
} else {
|
||||
return GitOperationResult{
|
||||
RepoName: repoName,
|
||||
Operation: "error",
|
||||
Error: fmt.Errorf("pulling repository: %w", err),
|
||||
ErrorType: "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,73 +242,51 @@ func pullRepository(repoName, repoDestination string) GitOperationResult {
|
|||
}
|
||||
}
|
||||
|
||||
// find remote for repo
|
||||
func findRemote(repoDestination string) (string, error) {
|
||||
cmd := exec.Command("git", "-C", repoDestination, "remote", "show")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting remote: %w", err)
|
||||
}
|
||||
|
||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(remotes) == 0 {
|
||||
return "", fmt.Errorf("no remotes found")
|
||||
}
|
||||
|
||||
return remotes[0], nil
|
||||
}
|
||||
|
||||
// manage pull error
|
||||
func classifyPullError(output string) string {
|
||||
switch {
|
||||
case strings.Contains(output, "You have unstaged changes"):
|
||||
return "unstaged"
|
||||
case strings.Contains(output, "Your index contains uncommitted changes"):
|
||||
return "uncommitted"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// set git user config
|
||||
func setGitUserConfig(repoName, repoDestination string) error {
|
||||
|
||||
// git user name
|
||||
if err := setGitUserName(repoName, repoDestination); err != nil {
|
||||
return fmt.Errorf("setting username: %w", err)
|
||||
repo, err := git.PlainOpen(repoDestination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening repository: %w", err)
|
||||
}
|
||||
|
||||
// git user mail
|
||||
if err := setGitUserEmail(repoName, repoDestination); err != nil {
|
||||
return fmt.Errorf("setting email: %w", err)
|
||||
cfg, err := repo.Config()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting config: %w", err)
|
||||
}
|
||||
|
||||
cfg.User.Name = globalConfig.GitUserName
|
||||
cfg.User.Email = globalConfig.GitUserMail
|
||||
|
||||
if err := repo.SetConfig(cfg); err != nil {
|
||||
return fmt.Errorf("setting config: %w", err)
|
||||
}
|
||||
|
||||
logger.Print("Set git user config for: "+repoName, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// set git user name
|
||||
func setGitUserName(repoName, repoDestination string) error {
|
||||
|
||||
cmd := exec.Command("git", "-C", repoDestination, "config", "user.name", config.GitUserName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
// update remote URL with current token
|
||||
func updateRemoteURL(repoDestination, gitURL string) error {
|
||||
repo, err := git.PlainOpen(repoDestination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting git username: %w, output: %s", err, string(output))
|
||||
return fmt.Errorf("opening repository: %w", err)
|
||||
}
|
||||
|
||||
logger.Print("Set git username for: "+repoName, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// set git user mail
|
||||
func setGitUserEmail(repoName, repoDestination string) error {
|
||||
|
||||
cmd := exec.Command("git", "-C", repoDestination, "config", "user.email", config.GitUserMail)
|
||||
output, err := cmd.CombinedOutput()
|
||||
cfg, err := repo.Config()
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting git email: %w, output: %s", err, string(output))
|
||||
return fmt.Errorf("getting config: %w", err)
|
||||
}
|
||||
|
||||
// update first remote's URL
|
||||
for name := range cfg.Remotes {
|
||||
cfg.Remotes[name].URLs = []string{gitURL}
|
||||
break
|
||||
}
|
||||
|
||||
if err := repo.SetConfig(cfg); err != nil {
|
||||
return fmt.Errorf("setting config: %w", err)
|
||||
}
|
||||
|
||||
logger.Print("Set git email for: "+repoName, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +318,7 @@ func handleResult(result GitOperationResult, stats *GitStats) {
|
|||
}
|
||||
|
||||
// update progress bar
|
||||
if !config.Debug {
|
||||
if !globalConfig.Debug {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,11 +47,10 @@ func NewGiteaClient(baseURL, token string) *GiteaClient {
|
|||
|
||||
// fetch gitea repos
|
||||
func FetchRepositoriesGitea() ([]Repository, error) {
|
||||
client := NewGiteaClient(config.GitHost, config.GitToken)
|
||||
|
||||
client := NewGiteaClient(globalConfig.GitHost, globalConfig.GitToken)
|
||||
options := GiteaAPIOptions{
|
||||
Visibility: "all",
|
||||
IncludeArchived: config.IncludeArchived,
|
||||
IncludeArchived: globalConfig.IncludeArchived,
|
||||
Sort: "alpha",
|
||||
Limit: 100,
|
||||
Page: 1,
|
||||
|
|
|
|||
|
|
@ -63,11 +63,10 @@ func NewGitLabClient(baseURL, token string) *GitLabClient {
|
|||
|
||||
// fetch gitlab repos
|
||||
func FetchRepositoriesGitLab() ([]Repository, error) {
|
||||
client := NewGitLabClient(config.GitHost, config.GitToken)
|
||||
|
||||
client := NewGitLabClient(globalConfig.GitHost, globalConfig.GitToken)
|
||||
options := GitLabAPIOptions{
|
||||
Membership: true,
|
||||
IncludeArchived: config.IncludeArchived,
|
||||
IncludeArchived: globalConfig.IncludeArchived,
|
||||
OrderBy: "name",
|
||||
Sort: "asc",
|
||||
PerPage: 100,
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ func expandPath(path string) string {
|
|||
|
||||
// loadconfig from yaml file
|
||||
func loadConfig(configPath string) (*Config, error) {
|
||||
config := &Config{}
|
||||
config.setDefaults()
|
||||
cfg := &Config{}
|
||||
cfg.setDefaults()
|
||||
|
||||
// check if config file exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
|
|
@ -78,11 +78,11 @@ func loadConfig(configPath string) (*Config, error) {
|
|||
}
|
||||
|
||||
// parse yaml
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// validateConfig validates the configuration values
|
||||
|
|
@ -147,11 +147,6 @@ func manageArguments() *Config {
|
|||
|
||||
flag.Parse()
|
||||
|
||||
// override debug setting if flag is set
|
||||
if *debugFlag {
|
||||
config.Debug = true
|
||||
}
|
||||
|
||||
if *versionFlag {
|
||||
fmt.Println(version)
|
||||
os.Exit(0)
|
||||
|
|
@ -160,23 +155,28 @@ func manageArguments() *Config {
|
|||
configPath := *configFileFlag
|
||||
|
||||
// Load configuration from YAML file
|
||||
config, err := loadConfig(configPath)
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
flag.Usage()
|
||||
logger.Fatal("Configuration error: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
// override debug setting if flag is set
|
||||
if *debugFlag {
|
||||
cfg.Debug = true
|
||||
}
|
||||
|
||||
// Process configuration
|
||||
config.processConfig()
|
||||
cfg.processConfig()
|
||||
|
||||
// Validate configuration
|
||||
if err := config.validateConfig(); err != nil {
|
||||
if err := cfg.validateConfig(); err != nil {
|
||||
flag.Usage()
|
||||
logger.Fatal("Configuration validation error: "+err.Error(), nil)
|
||||
}
|
||||
|
||||
// Log configuration
|
||||
config.logConfig(configPath)
|
||||
cfg.logConfig(configPath)
|
||||
|
||||
return config
|
||||
return cfg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
// version
|
||||
var version string
|
||||
var config *Config
|
||||
var globalConfig *Config
|
||||
|
||||
// repository data
|
||||
type Repository struct {
|
||||
|
|
@ -19,37 +19,31 @@ type Repository struct {
|
|||
func main() {
|
||||
|
||||
// set app version
|
||||
version = "2.2.2"
|
||||
version = "3.0.0"
|
||||
|
||||
// set appname for logger
|
||||
logger.SetAppName("gogitlabber")
|
||||
|
||||
// manage all argument magic and load configuration
|
||||
config = manageArguments()
|
||||
globalConfig = manageArguments()
|
||||
|
||||
// set debugging
|
||||
logger.SetDebug(config.Debug)
|
||||
|
||||
// check for git
|
||||
err := verifyGitAvailable()
|
||||
if err != nil {
|
||||
logger.Fatal("VALIDATION: git not found in path", err)
|
||||
}
|
||||
logger.Print("VALIDATION: git found in path", nil)
|
||||
logger.SetDebug(globalConfig.Debug)
|
||||
|
||||
// validate git backend is set
|
||||
if config.GitBackend == "" {
|
||||
if globalConfig.GitBackend == "" {
|
||||
logger.Fatal("Configuration error: git_backend is required (gitlab|gitea)", nil)
|
||||
}
|
||||
|
||||
// make initial progressbar
|
||||
if !config.Debug {
|
||||
if !globalConfig.Debug {
|
||||
progressBar()
|
||||
}
|
||||
|
||||
// fetch repository information
|
||||
var repositories []Repository
|
||||
switch config.GitBackend {
|
||||
var err error
|
||||
switch globalConfig.GitBackend {
|
||||
case "gitea":
|
||||
repositories, err = FetchRepositoriesGitea()
|
||||
if err != nil {
|
||||
|
|
@ -61,7 +55,7 @@ func main() {
|
|||
logger.Fatal("Fetching repositories failed", err)
|
||||
}
|
||||
default:
|
||||
logger.Fatal(fmt.Sprintf("Unsupported git backend: %s (supported: gitlab|gitea)", config.GitBackend), nil)
|
||||
logger.Fatal(fmt.Sprintf("Unsupported git backend: %s (supported: gitlab|gitea)", globalConfig.GitBackend), nil)
|
||||
}
|
||||
|
||||
// manage found repositories
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ func progressBar() {
|
|||
|
||||
// update progressbar
|
||||
func updateProgressBar(repoCount int) error {
|
||||
if config.Debug {
|
||||
if globalConfig.Debug {
|
||||
return nil // Skip progress bar in debug mode
|
||||
}
|
||||
logger.Print("Resetting progress bar", nil)
|
||||
|
|
@ -68,7 +68,7 @@ func printPullErrorUnstaged(stats *GitStats) {
|
|||
if len(stats.pullErrorMsgUnstaged) > 0 {
|
||||
fmt.Println("Repositories with unstaged changes:")
|
||||
for _, repo := range stats.pullErrorMsgUnstaged {
|
||||
fmt.Printf("❕ %s has unstaged changes.\n", repo)
|
||||
fmt.Printf("• %s has unstaged changes.\n", repo)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ func printPullErrorUncommitted(stats *GitStats) {
|
|||
if len(stats.pullErrorMsgUncommitted) > 0 {
|
||||
fmt.Println("Repositories with uncommitted changes:")
|
||||
for _, repo := range stats.pullErrorMsgUncommitted {
|
||||
fmt.Printf("❕ %s has uncommitted changes.\n", repo)
|
||||
fmt.Printf("• %s has uncommitted changes.\n", repo)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ func printGeneralErrors(stats *GitStats) {
|
|||
if len(stats.generalErrors) > 0 {
|
||||
fmt.Println("Repositories with errors:")
|
||||
for _, repo := range stats.generalErrors {
|
||||
fmt.Printf("❌ %s failed to process.\n", repo)
|
||||
fmt.Printf("✗ %s failed to process.\n", repo)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// verify if git is available
|
||||
func verifyGitAvailable() error {
|
||||
_, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return fmt.Errorf("git is not installed or not in PATH: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue