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/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| 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 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 + """ 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