Skip to content

Commit 86e16cc

Browse files
committed
Add repo sync command
1 parent 5984cf2 commit 86e16cc

File tree

5 files changed

+374
-9
lines changed

5 files changed

+374
-9
lines changed

git/git.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,30 @@ func getBranchShortName(output []byte) string {
368368
branch := firstLine(output)
369369
return strings.TrimPrefix(branch, "refs/heads/")
370370
}
371+
372+
func IsAncestor(ancestor, commit string) (bool, error) {
373+
cmd, err := GitCommand("merge-base", "--is-ancestor", ancestor, commit)
374+
if err != nil {
375+
return false, err
376+
}
377+
err = run.PrepareCmd(cmd).Run()
378+
return err == nil, nil
379+
}
380+
381+
func IsDirty() (bool, error) {
382+
cmd, err := GitCommand("status", "--untracked-files=no", "--porcelain")
383+
if err != nil {
384+
return false, err
385+
}
386+
387+
output, err := run.PrepareCmd(cmd).Output()
388+
if err != nil {
389+
return true, err
390+
}
391+
392+
if len(output) > 0 {
393+
return true, nil
394+
}
395+
396+
return false, nil
397+
}

pkg/cmd/repo/repo.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork"
99
gardenCmd "github.com/cli/cli/pkg/cmd/repo/garden"
1010
repoListCmd "github.com/cli/cli/pkg/cmd/repo/list"
11+
repoSyncCmd "github.com/cli/cli/pkg/cmd/repo/sync"
1112
repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view"
1213
"github.com/cli/cli/pkg/cmdutil"
1314
"github.com/spf13/cobra"
@@ -38,6 +39,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command {
3839
cmd.AddCommand(repoCloneCmd.NewCmdClone(f, nil))
3940
cmd.AddCommand(repoCreateCmd.NewCmdCreate(f, nil))
4041
cmd.AddCommand(repoListCmd.NewCmdList(f, nil))
42+
cmd.AddCommand(repoSyncCmd.NewCmdSync(f, nil))
4143
cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil))
4244
cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil))
4345

pkg/cmd/repo/sync/http.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package sync
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/cli/cli/api"
9+
"github.com/cli/cli/internal/ghrepo"
10+
)
11+
12+
type commit struct {
13+
Ref string `json:"ref"`
14+
NodeID string `json:"node_id"`
15+
URL string `json:"url"`
16+
Object struct {
17+
Type string `json:"type"`
18+
SHA string `json:"sha"`
19+
URL string `json:"url"`
20+
} `json:"object"`
21+
}
22+
23+
func latestCommit(client *api.Client, repo ghrepo.Interface, branch string) (commit, error) {
24+
var response commit
25+
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
26+
err := client.REST(repo.RepoHost(), "GET", path, nil, &response)
27+
return response, err
28+
}
29+
30+
func syncFork(client *api.Client, repo ghrepo.Interface, branch, SHA string, force bool) error {
31+
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
32+
body := map[string]interface{}{
33+
"sha": SHA,
34+
"force": force,
35+
}
36+
requestByte, err := json.Marshal(body)
37+
if err != nil {
38+
return err
39+
}
40+
requestBody := bytes.NewReader(requestByte)
41+
return client.REST(repo.RepoHost(), "PATCH", path, requestBody, nil)
42+
}

pkg/cmd/repo/sync/sync.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package sync
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"os/exec"
8+
"regexp"
9+
10+
"github.com/AlecAivazis/survey/v2"
11+
"github.com/MakeNowJust/heredoc"
12+
"github.com/cli/cli/api"
13+
"github.com/cli/cli/context"
14+
"github.com/cli/cli/git"
15+
"github.com/cli/cli/internal/ghrepo"
16+
"github.com/cli/cli/internal/run"
17+
"github.com/cli/cli/pkg/cmdutil"
18+
"github.com/cli/cli/pkg/iostreams"
19+
"github.com/cli/cli/pkg/prompt"
20+
"github.com/cli/safeexec"
21+
"github.com/spf13/cobra"
22+
)
23+
24+
type SyncOptions struct {
25+
HttpClient func() (*http.Client, error)
26+
IO *iostreams.IOStreams
27+
BaseRepo func() (ghrepo.Interface, error)
28+
Remotes func() (context.Remotes, error)
29+
CurrentBranch func() (string, error)
30+
DestArg string
31+
SrcArg string
32+
Branch string
33+
Force bool
34+
SkipConfirm bool
35+
}
36+
37+
func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Command {
38+
opts := SyncOptions{
39+
HttpClient: f.HttpClient,
40+
IO: f.IOStreams,
41+
BaseRepo: f.BaseRepo,
42+
Remotes: f.Remotes,
43+
CurrentBranch: f.Branch,
44+
}
45+
46+
cmd := &cobra.Command{
47+
Use: "sync [<destination-repository>]",
48+
Short: "Sync a repository",
49+
Long: heredoc.Doc(`
50+
Sync destination repository from source repository.
51+
52+
Without an argument, the local repository is selected as the destination repository.
53+
By default the source repository is the parent of the destination repository.
54+
The source repository can be overridden with the --source flag.
55+
`),
56+
Example: heredoc.Doc(`
57+
# Sync local repository from remote parent
58+
$ gh repo sync
59+
60+
# Sync local repository from remote parent on non-default branch
61+
$ gh repo sync --branch v1
62+
63+
# Sync remote fork from remote parent
64+
$ gh repo sync owner/cli-fork
65+
66+
# Sync remote repo from another remote repo
67+
$ gh repo sync owner/repo --source owner2/repo2
68+
`),
69+
Args: cobra.MaximumNArgs(1),
70+
RunE: func(c *cobra.Command, args []string) error {
71+
if len(args) > 0 {
72+
opts.DestArg = args[0]
73+
}
74+
if !opts.IO.CanPrompt() && !opts.SkipConfirm {
75+
return &cmdutil.FlagError{Err: errors.New("`--confirm` required when not running interactively")}
76+
}
77+
if runF != nil {
78+
return runF(&opts)
79+
}
80+
return syncRun(&opts)
81+
},
82+
}
83+
84+
cmd.Flags().StringVarP(&opts.SrcArg, "source", "s", "", "Source repository")
85+
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Branch to sync")
86+
cmd.Flags().BoolVarP(&opts.Force, "force", "", false, "Discard destination repository changes")
87+
cmd.Flags().BoolVarP(&opts.SkipConfirm, "confirm", "y", false, "Skip the confirmation prompt")
88+
return cmd
89+
}
90+
91+
func syncRun(opts *SyncOptions) error {
92+
httpClient, err := opts.HttpClient()
93+
if err != nil {
94+
return err
95+
}
96+
apiClient := api.NewClientFromHTTP(httpClient)
97+
98+
var local bool
99+
var destRepo, srcRepo ghrepo.Interface
100+
101+
if opts.DestArg == "" {
102+
local = true
103+
destRepo, err = opts.BaseRepo()
104+
if err != nil {
105+
return err
106+
}
107+
} else {
108+
destRepo, err = ghrepo.FromFullName(opts.DestArg)
109+
if err != nil {
110+
return err
111+
}
112+
}
113+
114+
if opts.SrcArg == "" {
115+
if local {
116+
srcRepo = destRepo
117+
} else {
118+
opts.IO.StartProgressIndicator()
119+
srcRepo, err = api.RepoParent(apiClient, destRepo)
120+
opts.IO.StopProgressIndicator()
121+
if err != nil {
122+
return err
123+
}
124+
if srcRepo == nil {
125+
return fmt.Errorf("can't determine source repo for %s because repo is not fork", ghrepo.FullName(destRepo))
126+
}
127+
}
128+
} else {
129+
srcRepo, err = ghrepo.FromFullName(opts.SrcArg)
130+
if err != nil {
131+
return err
132+
}
133+
}
134+
135+
if !local && destRepo.RepoHost() != srcRepo.RepoHost() {
136+
return fmt.Errorf("can't sync repos from different hosts")
137+
}
138+
139+
if opts.Branch == "" {
140+
opts.IO.StartProgressIndicator()
141+
opts.Branch, err = api.RepoDefaultBranch(apiClient, srcRepo)
142+
opts.IO.StopProgressIndicator()
143+
if err != nil {
144+
return err
145+
}
146+
}
147+
148+
srcStr := fmt.Sprintf("%s:%s", ghrepo.FullName(srcRepo), opts.Branch)
149+
destStr := fmt.Sprintf("%s:%s", ghrepo.FullName(destRepo), opts.Branch)
150+
if local {
151+
destStr = fmt.Sprintf(".:%s", opts.Branch)
152+
}
153+
cs := opts.IO.ColorScheme()
154+
if !opts.SkipConfirm && opts.IO.CanPrompt() {
155+
if opts.Force {
156+
fmt.Fprintf(opts.IO.ErrOut, "%s Using --force will cause diverging commits on %s to be discarded\n", cs.WarningIcon(), destStr)
157+
}
158+
var confirmed bool
159+
confirmQuestion := &survey.Confirm{
160+
Message: fmt.Sprintf("Sync %s from %s?", destStr, srcStr),
161+
Default: false,
162+
}
163+
err := prompt.SurveyAskOne(confirmQuestion, &confirmed)
164+
if err != nil {
165+
return err
166+
}
167+
168+
if !confirmed {
169+
return cmdutil.CancelError
170+
}
171+
}
172+
173+
opts.IO.StartProgressIndicator()
174+
if local {
175+
err = syncLocalRepo(srcRepo, opts)
176+
} else {
177+
err = syncRemoteRepo(apiClient, destRepo, srcRepo, opts)
178+
}
179+
opts.IO.StopProgressIndicator()
180+
181+
if err != nil {
182+
return err
183+
}
184+
185+
if opts.IO.IsStdoutTTY() {
186+
success := cs.Bold(fmt.Sprintf("Synced %s from %s\n", destStr, srcStr))
187+
fmt.Fprintf(opts.IO.Out, "%s %s", cs.SuccessIconWithColor(cs.GreenBold), success)
188+
}
189+
190+
return nil
191+
}
192+
193+
func syncLocalRepo(srcRepo ghrepo.Interface, opts *SyncOptions) error {
194+
// Remotes precedence by name
195+
// 1. upstream
196+
// 2. github
197+
// 3. origin
198+
// 4. other
199+
remotes, err := opts.Remotes()
200+
if err != nil {
201+
return err
202+
}
203+
remote := remotes[0]
204+
branch := opts.Branch
205+
206+
_ = executeCmds([][]string{{"git", "fetch", remote.Name, fmt.Sprintf("+refs/heads/%s", branch)}})
207+
208+
hasLocalBranch := git.HasLocalBranch(branch)
209+
if hasLocalBranch {
210+
fastForward, err := git.IsAncestor(branch, fmt.Sprintf("%s/%s", remote.Name, branch))
211+
if err != nil {
212+
return err
213+
}
214+
215+
if !fastForward && !opts.Force {
216+
return fmt.Errorf("can't sync .:%s because there are diverging commits, try using `--force`", branch)
217+
}
218+
}
219+
220+
startBranch, err := opts.CurrentBranch()
221+
if err != nil {
222+
return err
223+
}
224+
225+
dirtyRepo, err := git.IsDirty()
226+
if err != nil {
227+
return err
228+
}
229+
230+
var cmds [][]string
231+
if dirtyRepo {
232+
cmds = append(cmds, []string{"git", "stash", "push"})
233+
}
234+
if startBranch != branch {
235+
cmds = append(cmds, []string{"git", "checkout", branch})
236+
}
237+
if hasLocalBranch {
238+
if opts.Force {
239+
cmds = append(cmds, []string{"git", "reset", "--hard", fmt.Sprintf("refs/remotes/%s/%s", remote, branch)})
240+
} else {
241+
cmds = append(cmds, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s/%s", remote, branch)})
242+
}
243+
}
244+
if startBranch != branch {
245+
cmds = append(cmds, []string{"git", "checkout", startBranch})
246+
}
247+
if dirtyRepo {
248+
cmds = append(cmds, []string{"git", "stash", "pop"})
249+
}
250+
251+
return executeCmds(cmds)
252+
}
253+
254+
func syncRemoteRepo(client *api.Client, destRepo, srcRepo ghrepo.Interface, opts *SyncOptions) error {
255+
commit, err := latestCommit(client, srcRepo, opts.Branch)
256+
if err != nil {
257+
return err
258+
}
259+
260+
// This is not a great way to detect the error returned by the API
261+
// Unfortunately API returns 422 for multiple reasons
262+
notFastForwardErrorMessage := regexp.MustCompile(`^Update is not a fast forward$`)
263+
err = syncFork(client, destRepo, opts.Branch, commit.Object.SHA, opts.Force)
264+
var httpErr api.HTTPError
265+
if err != nil && errors.As(err, &httpErr) && notFastForwardErrorMessage.MatchString(httpErr.Message) {
266+
return fmt.Errorf("can't sync %s:%s because there are diverging commits, try using `--force`",
267+
ghrepo.FullName(destRepo),
268+
opts.Branch)
269+
}
270+
271+
return err
272+
}
273+
274+
func executeCmds(cmdQueue [][]string) error {
275+
exe, err := safeexec.LookPath("git")
276+
if err != nil {
277+
return err
278+
}
279+
for _, args := range cmdQueue {
280+
cmd := exec.Command(exe, args[1:]...)
281+
if err := run.PrepareCmd(cmd).Run(); err != nil {
282+
return err
283+
}
284+
}
285+
return nil
286+
}

0 commit comments

Comments
 (0)