From ce24b1cf7e964d7f5571c5e4baa2414d3f62a61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 12 Sep 2016 06:29:53 +0200 Subject: [PATCH 1/4] New `hub sync` command to update local branches Ported from my bash version over at https://github.com/mislav/dotfiles/blob/370154a5adc482afd89606c126bcd18df2560013/bin/git-sync --- Makefile | 1 + commands/runner.go | 5 +- commands/sync.go | 132 ++++++++++++++++++++++++++++++++++++++ git/git.go | 85 ++++++++++++++++++------ share/man/man1/hub.1.ronn | 3 + 5 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 commands/sync.go diff --git a/Makefile b/Makefile index 4784326cc..cb6ac5a95 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ HELP_CMD = \ share/man/man1/hub-pull-request.1 \ share/man/man1/hub-release.1 \ share/man/man1/hub-issue.1 \ + share/man/man1/hub-sync.1 \ HELP_EXT = \ share/man/man1/hub-am.1 \ diff --git a/commands/runner.go b/commands/runner.go index 11155401e..3a9e7f357 100644 --- a/commands/runner.go +++ b/commands/runner.go @@ -101,7 +101,10 @@ func (r *Runner) Execute() ExecError { return execErr } - err = git.Run(args.Command, args.Params...) + gitArgs := []string{args.Command} + gitArgs = append(gitArgs, args.Params...) + + err = git.Run(gitArgs...) return newExecError(err) } diff --git a/commands/sync.go b/commands/sync.go new file mode 100644 index 000000000..4752b4a84 --- /dev/null +++ b/commands/sync.go @@ -0,0 +1,132 @@ +package commands + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/github/hub/git" + "github.com/github/hub/github" + "github.com/github/hub/ui" + "github.com/github/hub/utils" +) + +var cmdSync = &Command{ + Run: sync, + Usage: "sync", + Long: `Fetch git objects from upstream and update branches. + +- If the local branch is outdated, fast-forward it; +- If the local branch contains unpushed work, warn about it; +- If the branch seems merged and its upstream branch was deleted, delete it. + +If a local branch doesn't have any upstream configuration, but has a +same-named branch on the remote, treat that as its upstream branch. + +## See also: + +hub(1), git-fetch(1) +`, +} + +func init() { + CmdRunner.Use(cmdSync) +} + +func sync(cmd *Command, args *Args) { + localRepo, err := github.LocalRepo() + utils.Check(err) + + remote, err := localRepo.MainRemote() + utils.Check(err) + + defaultBranch := localRepo.MasterBranch().ShortName() + fullDefaultBranch := fmt.Sprintf("refs/remotes/%s/%s", remote.Name, defaultBranch) + currentBranch := "" + if curBranch, err := localRepo.CurrentBranch(); err == nil { + currentBranch = curBranch.ShortName() + } + + err = git.Spawn("fetch", "--prune", "--quiet", "--progress", remote.Name) + utils.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 := git.LocalBranches() + utils.Check(err) + + var green, + lightGreen, + red, + lightRed, + resetColor string + + if ui.IsTerminal(os.Stdout) { + green = "\033[32m" + lightGreen = "\033[32;1m" + red = "\033[31m" + lightRed = "\033[31;1m" + resetColor = "\033[0m" + } + + for _, branch := range branches { + fullBranch := fmt.Sprintf("refs/heads/%s", branch) + remoteBranch := fmt.Sprintf("refs/remotes/%s/%s", remote.Name, branch) + gone := false + + if branchToRemote[branch] == remote.Name { + 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) + utils.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) + } + ui.Printf("%sUpdated branch %s%s%s (was %s).\n", green, lightGreen, branch, resetColor, diff.A[0:7]) + } else { + ui.Errorf("warning: `%s' seems to contain unpushed commits\n", branch) + } + } else if gone { + diff, err := git.NewRange(fullBranch, fullDefaultBranch) + utils.Check(err) + + if diff.IsAncestor() { + if branch == currentBranch { + git.Quiet("checkout", "--quiet", defaultBranch) + currentBranch = defaultBranch + } + git.Quiet("branch", "-D", branch) + ui.Printf("%sDeleted branch %s%s%s (was %s).\n", red, lightRed, branch, resetColor, diff.A[0:7]) + } else { + ui.Errorf("warning: `%s' was deleted on %s, but appears not merged into %s\n", branch, remote.Name, defaultBranch) + } + } + } + + args.NoForward() +} diff --git a/git/git.go b/git/git.go index 4a16c37c8..1631e0305 100644 --- a/git/git.go +++ b/git/git.go @@ -165,6 +165,29 @@ func RefList(a, b string) ([]string, error) { return output, nil } +func NewRange(a, b string) (*Range, error) { + output, err := gitOutput("rev-parse", "-q", a, b) + if err != nil { + return nil, err + } + + return &Range{output[0], output[1]}, nil +} + +type Range struct { + A string + B string +} + +func (r *Range) IsIdentical() bool { + return strings.EqualFold(r.A, r.B) +} + +func (r *Range) IsAncestor() bool { + cmd := gitCmd("merge-base", "--is-ancestor", r.A, r.B) + return cmd.Success() +} + func CommentChar() string { char, err := Config("core.commentchar") if err != nil { @@ -209,7 +232,12 @@ func Config(name string) (string, error) { } func ConfigAll(name string) ([]string, error) { - lines, err := gitOutput(gitConfigCommand([]string{"--get-all", name})...) + mode := "--get-all" + if strings.Contains(name, "*") { + mode = "--get-regexp" + } + + lines, err := gitOutput(gitConfigCommand([]string{mode, name})...) if err != nil { err = fmt.Errorf("Unknown config %s", name) } @@ -251,20 +279,19 @@ func Alias(name string) (string, error) { return Config(fmt.Sprintf("alias.%s", name)) } -func Run(command string, args ...string) error { - cmd := cmd.New("git") - - for _, v := range GlobalFlags { - cmd.WithArg(v) - } - - cmd.WithArg(command) +func Run(args ...string) error { + cmd := gitCmd(args...) + return cmd.Run() +} - for _, a := range args { - cmd.WithArg(a) - } +func Spawn(args ...string) error { + cmd := gitCmd(args...) + return cmd.Spawn() +} - return cmd.Run() +func Quiet(args ...string) bool { + cmd := gitCmd(args...) + return cmd.Success() } func IsGitDir(dir string) bool { @@ -273,16 +300,18 @@ func IsGitDir(dir string) bool { return cmd.Success() } -func gitOutput(input ...string) (outputs []string, err error) { - cmd := cmd.New("git") - - for _, v := range GlobalFlags { - cmd.WithArg(v) +func LocalBranches() ([]string, error) { + lines, err := gitOutput("branch", "--list") + if err == nil { + for i, line := range lines { + lines[i] = strings.TrimPrefix(line, "* ") + } } + return lines, err +} - for _, i := range input { - cmd.WithArg(i) - } +func gitOutput(input ...string) (outputs []string, err error) { + cmd := gitCmd(input...) out, err := cmd.CombinedOutput() for _, line := range strings.Split(out, "\n") { @@ -294,3 +323,17 @@ func gitOutput(input ...string) (outputs []string, err error) { return outputs, err } + +func gitCmd(args ...string) *cmd.Cmd { + cmd := cmd.New("git") + + for _, v := range GlobalFlags { + cmd.WithArg(v) + } + + for _, a := range args { + cmd.WithArg(a) + } + + return cmd +} diff --git a/share/man/man1/hub.1.ronn b/share/man/man1/hub.1.ronn index ea7f7088b..37b028c84 100644 --- a/share/man/man1/hub.1.ronn +++ b/share/man/man1/hub.1.ronn @@ -79,6 +79,9 @@ git but that are extended through hub, and custom ones that hub provides. * hub-release(1): List and create GitHub releases. + * hub-sync(1): + Fetch from upstream and update local branches. + ## CONFIGURATION ### GitHub OAuth authentication From 1b0631c009d86e3e850b2d7bc940c236db8907a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 12 Sep 2016 10:00:14 +0200 Subject: [PATCH 2/4] Cleanup in git branch & upstream handling in cukes --- features/browse.feature | 2 +- features/pull_request.feature | 3 ++- features/steps.rb | 19 +++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/features/browse.feature b/features/browse.feature index ce06adc3d..f86f306df 100644 --- a/features/browse.feature +++ b/features/browse.feature @@ -95,7 +95,7 @@ Feature: hub browse Given I am in "git://github.com/jashkenas/coffee-script.git" git repo And the "mislav" remote has url "git@github.com:mislav/coffee-script.git" And the default branch for "origin" is "master" - And I am on the "master" branch pushed to "mislav/master" + And the "master" branch is pushed to "mislav/master" When I successfully run `hub browse` Then "open https://github.com/jashkenas/coffee-script" should be run diff --git a/features/pull_request.feature b/features/pull_request.feature index 3bb290a58..dfef00f67 100644 --- a/features/pull_request.feature +++ b/features/pull_request.feature @@ -433,7 +433,7 @@ BODY Scenario: Implicit base by detecting main branch Given the default branch for "origin" is "develop" - And I am on the "master" branch + And I make a commit Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { @@ -677,6 +677,7 @@ BODY Scenario: Current branch is tracking local branch Given git "push.default" is set to "upstream" + And I make a commit And I am on the "feature" branch with upstream "refs/heads/master" Given the GitHub API server: """ diff --git a/features/steps.rb b/features/steps.rb index 8a108fd65..92b51680e 100644 --- a/features/steps.rb +++ b/features/steps.rb @@ -105,20 +105,19 @@ end Given(/^I am on the "([^"]+)" branch(?: (pushed to|with upstream) "([^"]+)")?$/) do |name, type, upstream| + run_silent %(git checkout --quiet -b #{shell_escape name}) empty_commit + if upstream - if upstream =~ /^refs\// - full_upstream = ".git/#{upstream}" - else - full_upstream = ".git/refs/remotes/#{upstream}" + unless upstream == 'refs/heads/master' + full_upstream = upstream.start_with?('refs/') ? upstream : "refs/remotes/#{upstream}" + run_silent %(git update-ref #{shell_escape full_upstream} HEAD) + end + + if type == 'with upstream' + run_silent %(git branch --set-upstream-to #{shell_escape upstream}) end - in_current_dir do - FileUtils.mkdir_p File.dirname(full_upstream) - FileUtils.cp '.git/refs/heads/master', full_upstream - end unless upstream == 'refs/heads/master' end - track = type == 'pushed to' ? '--no-track' : '--track' - run_silent %(git checkout --quiet -B #{shell_escape name} #{track} #{shell_escape upstream}) end Given(/^the default branch for "([^"]+)" is "([^"]+)"$/) do |remote, branch| From 28be560825c55ba3ae6de265c737d8f3487c6d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 12 Sep 2016 10:00:49 +0200 Subject: [PATCH 3/4] Ensure that the SHA for each test commit is unique Since the test commits are empty and had identical commit messages, two commits with the same parent could end up having identical SHAs. To avoid that, generate a unique message for each empty commit. --- features/support/env.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/features/support/env.rb b/features/support/env.rb index bdf3f61bd..8a8ee506a 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -169,7 +169,10 @@ def run_silent cmd end def empty_commit(message = nil) - message ||= 'empty' + unless message + @empty_commit_count = defined?(@empty_commit_count) ? @empty_commit_count + 1 : 1 + message = "empty #{@empty_commit_count}" + end run_silent "git commit --quiet -m '#{message}' --allow-empty" end From 9fab0e345fbb0eb073449ae89275c5c225995fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 12 Sep 2016 10:02:20 +0200 Subject: [PATCH 4/4] Add cukes for `hub sync` --- features/sync.feature | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 features/sync.feature diff --git a/features/sync.feature b/features/sync.feature new file mode 100644 index 000000000..e3733d481 --- /dev/null +++ b/features/sync.feature @@ -0,0 +1,56 @@ +Feature: hub sync + Background: + Given I am in "dotfiles" git repo + And I make a commit + And the "origin" remote has url "git://github.com/lostisland/faraday.git" + + Scenario: Prunes remote branches + When I successfully run `hub sync` + Then the output should contain exactly "" + And "git fetch --prune --quiet --progress origin" should be run + + Scenario: Fast-forwards currently checked out local branch + Given I am on the "feature" branch pushed to "origin/feature" + And I successfully run `git reset -q --hard HEAD^` + When I successfully run `hub sync` + Then the output should contain "Updated branch feature" + And "git merge --ff-only --quiet refs/remotes/origin/feature" should be run + + Scenario: Fast-forwards other local branches in the background + Given I am on the "feature" branch pushed to "origin/feature" + And I successfully run `git reset -q --hard HEAD^` + And I am on the "bugfix" branch pushed to "origin/bugfix" + And I successfully run `git reset -q --hard HEAD^` + And I successfully run `git checkout -q master` + When I successfully run `hub sync` + Then the output should contain "Updated branch feature" + And the output should contain "Updated branch bugfix" + + Scenario: Refuses to update local branch which has diverged from upstream + Given I am on the "feature" branch pushed to "origin/feature" + And I make a commit with message "diverge" + When I successfully run `hub sync` + Then the stderr should contain exactly: + """ + warning: `feature' seems to contain unpushed commits\n + """ + + Scenario: Deletes local branch that had its upstream deleted + Given I am on the "feature" branch with upstream "origin/feature" + And I successfully run `git checkout -q master` + And I successfully run `git merge --no-ff --no-edit feature` + And I successfully run `git update-ref refs/remotes/origin/master HEAD` + And I successfully run `rm .git/refs/remotes/origin/feature` + And I successfully run `git checkout -q feature` + When I successfully run `hub sync` + Then the output should contain "Deleted branch feature" + + Scenario: Refuses to delete local branch whose upstream was deleted but not merged to master + Given I am on the "feature" branch with upstream "origin/feature" + And I successfully run `rm .git/refs/remotes/origin/feature` + And I successfully run `git update-ref refs/remotes/origin/master master` + When I successfully run `hub sync` + Then the stderr should contain exactly: + """ + warning: `feature' was deleted on origin, but appears not merged into master\n + """