From cbb78dbbdd44adc3c36ef7b76f3ef0fb4194535b Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Thu, 17 Jun 2021 17:48:56 +0100 Subject: [PATCH] Add initial implementation of git-sync --- LICENSE | 1 + git/git.go | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++ git/range.go | 16 ++++ main.go | 99 ++++++++++++++++++--- utils.go | 34 -------- 5 files changed, 343 insertions(+), 46 deletions(-) create mode 100644 git/git.go create mode 100644 git/range.go delete mode 100644 utils.go diff --git a/LICENSE b/LICENSE index 49433c3..59839bd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2021 Jacob Gillespie +Copyright (c) 2009 Chris Wanstrath Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/git/git.go b/git/git.go new file mode 100644 index 0000000..166ccaf --- /dev/null +++ b/git/git.go @@ -0,0 +1,239 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var ( + originNamesInLookupOrder = []string{"upstream", "github", "origin"} + remotesRegexp = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) +) + +func BranchShortName(ref string) string { + reg := regexp.MustCompile("^refs/(remotes/)?.+?/") + return reg.ReplaceAllString(ref, "") +} + +func ConfigAll(name string) ([]string, error) { + mode := "--get-all" + if strings.Contains(name, "*") { + mode = "--get-regexp" + } + + output, err := execGit("config", mode, name) + if err != nil { + return nil, fmt.Errorf("unknown config %s", name) + } + return splitLines(output), nil +} + +func CurrentBranch() (string, error) { + head, err := Head() + if err != nil { + return "", fmt.Errorf("aborted: not currently on any branch") + } + return head, nil +} + +var cachedDir string + +func Dir() (string, error) { + if cachedDir != "" { + return cachedDir, nil + } + + output, err := execGitQuiet("rev-parse", "-q", "--git-dir") + if err != nil { + return "", fmt.Errorf("not a git repository (or any of the parent directories): .git") + } + + var chdir string + // for i, flag := range GlobalFlags { + // if flag == "-C" { + // dir := GlobalFlags[i+1] + // if filepath.IsAbs(dir) { + // chdir = dir + // } else { + // chdir = filepath.Join(chdir, dir) + // } + // } + // } + + gitDir := firstLine(output) + + if !filepath.IsAbs(gitDir) { + if chdir != "" { + gitDir = filepath.Join(chdir, gitDir) + } + + gitDir, err = filepath.Abs(gitDir) + if err != nil { + return "", err + } + + gitDir = filepath.Clean(gitDir) + } + + cachedDir = gitDir + return gitDir, nil +} + +func DefaultBranch(remote string) string { + if name, err := SymbolicRef(fmt.Sprintf("refs/remotes/%s/HEAD", remote)); err != nil { + return name + } + return "refs/heads/main" +} + +func HasFile(segments ...string) bool { + // For Git >= 2.5.0 + if output, err := execGitQuiet("rev-parse", "-q", "--git-path", filepath.Join(segments...)); err == nil { + if lines := splitLines(output); len(lines) == 1 { + if _, err := os.Stat(lines[0]); err == nil { + return true + } + } + } + + return false +} + +func Head() (string, error) { + return SymbolicRef("HEAD") +} + +func LocalBranches() ([]string, error) { + output, err := execGit("branch", "--list") + if err != nil { + return nil, err + } + branches := []string{} + for _, branch := range splitLines(output) { + branches = append(branches, branch[2:]) + } + return branches, nil +} + +func MainRemote() (string, error) { + remotes, err := Remotes() + if err != nil || len(remotes) == 0 { + return "", fmt.Errorf("aborted: no git remotes found") + } + return remotes[0], nil +} + +func NewRange(a, b string) (*Range, error) { + output, err := execGitQuiet("rev-parse", "-q", a, b) + if err != nil { + return nil, err + } + lines := splitLines(output) + if len(lines) != 2 { + return nil, fmt.Errorf("can't parse range %s..%s", a, b) + } + return &Range{lines[0], lines[1]}, nil +} + +func Remotes() ([]string, error) { + output, err := execGit("remote", "-v") + if err != nil { + return nil, fmt.Errorf("aborted: can't load git remotes") + } + + remoteLines := splitLines(output) + + remotesMap := make(map[string]map[string]string) + for _, r := range remoteLines { + if remotesRegexp.MatchString(r) { + match := remotesRegexp.FindStringSubmatch(r) + name := strings.TrimSpace(match[1]) + url := strings.TrimSpace(match[2]) + urlType := strings.TrimSpace(match[3]) + utm, ok := remotesMap[name] + if !ok { + utm = make(map[string]string) + remotesMap[name] = utm + } + utm[urlType] = url + } + } + + remotes := []string{} + + for _, name := range originNamesInLookupOrder { + if _, ok := remotesMap[name]; ok { + remotes = append(remotes, name) + delete(remotesMap, name) + } + } + + for name := range remotesMap { + remotes = append(remotes, name) + } + + return remotes, nil +} + +func Spawn(args ...string) error { + cmd := exec.Command("git", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func Quiet(args ...string) bool { + fmt.Printf("%v\n", args) + cmd := exec.Command("git", args...) + cmd.Stderr = os.Stderr + return cmd.Run() == nil +} + +func SymbolicFullName(name string) (string, error) { + output, err := execGitQuiet("rev-parse", "--symbolic-full-name", name) + if err != nil { + return "", fmt.Errorf("unknown revision or path not in the working tree: %s", name) + } + return firstLine(output), nil +} + +func SymbolicRef(ref string) (string, error) { + output, err := execGit("symbolic-ref", ref) + if err != nil { + return "", err + } + return firstLine(output), err +} + +func execGit(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Stderr = os.Stderr + output, err := cmd.Output() + return string(output), err +} + +func execGitQuiet(args ...string) (string, error) { + cmd := exec.Command("git", args...) + output, err := cmd.Output() + return string(output), err +} + +func splitLines(output string) []string { + output = strings.TrimSuffix(output, "\n") + if output == "" { + return []string{} + } + return strings.Split(output, "\n") +} + +func firstLine(output string) string { + if i := strings.Index(output, "\n"); i >= 0 { + return output[0:i] + } + return output +} diff --git a/git/range.go b/git/range.go new file mode 100644 index 0000000..ba9ba4a --- /dev/null +++ b/git/range.go @@ -0,0 +1,16 @@ +package git + +import "strings" + +type Range struct { + A string + B string +} + +func (r *Range) IsIdentical() bool { + return strings.EqualFold(r.A, r.B) +} + +func (r *Range) IsAncestor() bool { + return Quiet("merge-base", "--is-ancestor", r.A, r.B) +} diff --git a/main.go b/main.go index 948d0ce..5b25b06 100644 --- a/main.go +++ b/main.go @@ -3,26 +3,101 @@ package main import ( "fmt" "os" + "regexp" + "strings" - "github.com/go-git/go-git/v5" + "github.com/jacobwgillespie/git-sync/git" +) + +var ( + green = "\033[32m" + lightGreen = "\033[32;1m" + red = "\033[31m" + lightRed = "\033[31;1m" + resetColor = "\033[0m" ) func main() { - path, err := os.Getwd() - if err != nil { - panic(err) + remote, err := git.MainRemote() + check(err) + + defaultBranch := git.BranchShortName(git.DefaultBranch(remote)) + fullDefaultBranch := fmt.Sprintf("refs/remotes/%s/%s", remote, defaultBranch) + currentBranch := "" + if current, err := git.CurrentBranch(); err == nil { + currentBranch = git.BranchShortName(current) } - repo, err := git.PlainOpen(path) - if err != nil { - panic(err) + err = git.Spawn("fetch", "--prune", "--quiet", "--progress", remote) + check(err) + + branchToRemote := map[string]string{} + if lines, err := git.ConfigAll("branch.*.remote"); err == nil { + configRe := regexp.MustCompile(`^branch\.(.+?)\.remote (.+)`) + + for _, line := range lines { + if matches := configRe.FindStringSubmatch(line); len(matches) > 0 { + branchToRemote[matches[1]] = matches[2] + } + } } - branches, err := localBranches() - if err != nil { - panic(err) + branches, err := git.LocalBranches() + check(err) + + for _, branch := range branches { + fullBranch := fmt.Sprintf("refs/heads/%s", branch) + remoteBranch := fmt.Sprintf("refs/remotes/%s/%s", remote, branch) + gone := false + + if branchToRemote[branch] == remote { + if upstream, err := git.SymbolicFullName(fmt.Sprintf("%s@{upstream}", branch)); err == nil { + remoteBranch = upstream + } else { + remoteBranch = "" + gone = true + } + } else if !git.HasFile(strings.Split(remoteBranch, "/")...) { + remoteBranch = "" + } + + if remoteBranch != "" { + diff, err := git.NewRange(fullBranch, remoteBranch) + check(err) + + if diff.IsIdentical() { + continue + } else if diff.IsAncestor() { + if branch == currentBranch { + git.Quiet("merge", "--ff-only", "--quiet", remoteBranch) + } else { + git.Quiet("update-ref", fullBranch, remoteBranch) + } + fmt.Printf("%sUpdated branch %s%s%s (was %s).\n", green, lightGreen, branch, resetColor, diff.A[0:7]) + } else { + fmt.Fprintf(os.Stderr, "warning: '%s' seems to contain unpushed commits\n", branch) + } + } else if gone { + diff, err := git.NewRange(fullBranch, fullDefaultBranch) + check(err) + + if diff.IsAncestor() { + if branch == currentBranch { + git.Quiet("checkout", "--quiet", defaultBranch) + currentBranch = defaultBranch + } + git.Quiet("branch", "-D", branch) + fmt.Printf("%sDeleted branch %s%s%s (was %s).\n", red, lightRed, branch, resetColor, diff.A[0:7]) + } else { + fmt.Fprintf(os.Stderr, "warning: '%s' was deleted on %s, but appears not merged into '%s'\n", branch, remote, defaultBranch) + } + } } +} - fmt.Printf("%v\n", repo) - fmt.Printf("%v\n", branches) +func check(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } } diff --git a/utils.go b/utils.go deleted file mode 100644 index 254de3d..0000000 --- a/utils.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "strings" -) - -func localBranches() ([]string, error) { - output, err := execGit("branch", "--list") - if err != nil { - return nil, err - } - branches := []string{} - for _, branch := range splitLines(output) { - branches = append(branches, branch[2:]) - } - return branches, nil -} - -func execGit(args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Stderr = os.Stderr - output, err := cmd.Output() - return string(output), err -} - -func splitLines(output string) []string { - output = strings.TrimSuffix(output, "\n") - if output == "" { - return []string{} - } - return strings.Split(output, "\n") -}