2 # Copyright (c) 2016-2017 Bitcoin Core Developers
3 # Distributed under the MIT software license, see the accompanying
4 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
6 # This script will locally construct a merge commit for a pull request on a
7 # github repository, inspect it, sign it and optionally push it.
9 # The following temporary branches are created/overwritten and deleted:
10 # * pull/$PULL/base (the current master we're merging onto)
11 # * pull/$PULL/head (the current state of the remote pull request)
12 # * pull/$PULL/merge (github's merge)
13 # * pull/$PULL/local-merge (our merge)
15 # In case of a clean merge that is accepted by the user, the local branch with
16 # name $BRANCH is overwritten with the merged result, and optionally pushed.
17 from __future__
import division
,print_function
,unicode_literals
19 from sys
import stdin
,stdout
,stderr
25 from urllib
.request
import Request
,urlopen
27 from urllib2
import Request
,urlopen
29 # External tools (can be overridden using environment)
30 GIT
= os
.getenv('GIT','git')
31 BASH
= os
.getenv('BASH','bash')
33 # OS specific configuration for terminal attributes
36 COMMIT_FORMAT
= '%h %s (%an)%d'
37 if os
.name
== 'posix': # if posix, assume we can use basic terminal escapes
38 ATTR_RESET
= '\033[0m'
39 ATTR_PR
= '\033[1;36m'
40 COMMIT_FORMAT
= '%C(bold blue)%h%Creset %s %C(cyan)(%an)%Creset%C(green)%d%Creset'
42 def git_config_get(option
, default
=None):
44 Get named configuration option from git repository.
47 return subprocess
.check_output([GIT
,'config','--get',option
]).rstrip().decode('utf-8')
48 except subprocess
.CalledProcessError
as e
:
51 def retrieve_pr_info(repo
,pull
):
53 Retrieve pull request information from github.
54 Return None if no title can be found, or an error happens.
57 req
= Request("https://api.github.com/repos/"+repo
+"/pulls/"+pull
)
59 reader
= codecs
.getreader('utf-8')
60 obj
= json
.load(reader(result
))
62 except Exception as e
:
63 print('Warning: unable to retrieve pull information from github: %s' % e
)
67 print(text
,end
=" ",file=stderr
)
69 reply
= stdin
.readline().rstrip()
73 def get_symlink_files():
74 files
= sorted(subprocess
.check_output([GIT
, 'ls-tree', '--full-tree', '-r', 'HEAD']).splitlines())
77 if (int(f
.decode('utf-8').split(" ")[0], 8) & 0o170000) == 0o120000:
78 ret
.append(f
.decode('utf-8').split("\t")[1])
81 def tree_sha512sum(commit
='HEAD'):
82 # request metadata for entire tree, recursively
85 for line
in subprocess
.check_output([GIT
, 'ls-tree', '--full-tree', '-r', commit
]).splitlines():
86 name_sep
= line
.index(b
'\t')
87 metadata
= line
[:name_sep
].split() # perms, 'blob', blobid
88 assert(metadata
[1] == b
'blob')
89 name
= line
[name_sep
+1:]
91 blob_by_name
[name
] = metadata
[2]
94 # open connection to git-cat-file in batch mode to request data for all blobs
95 # this is much faster than launching it per file
96 p
= subprocess
.Popen([GIT
, 'cat-file', '--batch'], stdout
=subprocess
.PIPE
, stdin
=subprocess
.PIPE
)
97 overall
= hashlib
.sha512()
99 blob
= blob_by_name
[f
]
101 p
.stdin
.write(blob
+ b
'\n')
103 # read header: blob, "blob", size
104 reply
= p
.stdout
.readline().split()
105 assert(reply
[0] == blob
and reply
[1] == b
'blob')
108 intern = hashlib
.sha512()
111 bs
= min(65536, size
- ptr
)
112 piece
= p
.stdout
.read(bs
)
116 raise IOError('Premature EOF reading git cat-file output')
118 dig
= intern.hexdigest()
119 assert(p
.stdout
.read(1) == b
'\n') # ignore LF that follows blob data
120 # update overall hash with file hash
121 overall
.update(dig
.encode("utf-8"))
122 overall
.update(" ".encode("utf-8"))
124 overall
.update("\n".encode("utf-8"))
127 raise IOError('Non-zero return value executing git cat-file')
128 return overall
.hexdigest()
130 def print_merge_details(pull
, title
, branch
, base_branch
, head_branch
):
131 print('%s#%s%s %s %sinto %s%s' % (ATTR_RESET
+ATTR_PR
,pull
,ATTR_RESET
,title
,ATTR_RESET
+ATTR_PR
,branch
,ATTR_RESET
))
132 subprocess
.check_call([GIT
,'log','--graph','--topo-order','--pretty=format:'+COMMIT_FORMAT
,base_branch
+'..'+head_branch
])
134 def parse_arguments():
136 In addition, you can set the following git configuration variables:
137 githubmerge.repository (mandatory),
138 user.signingkey (mandatory),
139 githubmerge.host (default: git@github.com),
140 githubmerge.branch (no default),
141 githubmerge.testcmd (default: none).
143 parser
= argparse
.ArgumentParser(description
='Utility to merge, sign and push github pull requests',
145 parser
.add_argument('pull', metavar
='PULL', type=int, nargs
=1,
146 help='Pull request ID to merge')
147 parser
.add_argument('branch', metavar
='BRANCH', type=str, nargs
='?',
148 default
=None, help='Branch to merge against (default: githubmerge.branch setting, or base branch for pull, or \'master\')')
149 return parser
.parse_args()
152 # Extract settings from git repo
153 repo
= git_config_get('githubmerge.repository')
154 host
= git_config_get('githubmerge.host','git@github.com')
155 opt_branch
= git_config_get('githubmerge.branch',None)
156 testcmd
= git_config_get('githubmerge.testcmd')
157 signingkey
= git_config_get('user.signingkey')
159 print("ERROR: No repository configured. Use this command to set:", file=stderr
)
160 print("git config githubmerge.repository <owner>/<repo>", file=stderr
)
162 if signingkey
is None:
163 print("ERROR: No GPG signing key set. Set one using:",file=stderr
)
164 print("git config --global user.signingkey <key>",file=stderr
)
167 host_repo
= host
+":"+repo
# shortcut for push/pull target
169 # Extract settings from command line
170 args
= parse_arguments()
171 pull
= str(args
.pull
[0])
173 # Receive pull information from github
174 info
= retrieve_pr_info(repo
,pull
)
177 title
= info
['title'].strip()
178 # precedence order for destination branch argument:
179 # - command line argument
180 # - githubmerge.branch setting
181 # - base branch for pull (as retrieved from github)
183 branch
= args
.branch
or opt_branch
or info
['base']['ref'] or 'master'
185 # Initialize source branches
186 head_branch
= 'pull/'+pull
+'/head'
187 base_branch
= 'pull/'+pull
+'/base'
188 merge_branch
= 'pull/'+pull
+'/merge'
189 local_merge_branch
= 'pull/'+pull
+'/local-merge'
191 devnull
= open(os
.devnull
,'w')
193 subprocess
.check_call([GIT
,'checkout','-q',branch
])
194 except subprocess
.CalledProcessError
as e
:
195 print("ERROR: Cannot check out branch %s." % (branch
), file=stderr
)
198 subprocess
.check_call([GIT
,'fetch','-q',host_repo
,'+refs/pull/'+pull
+'/*:refs/heads/pull/'+pull
+'/*'])
199 except subprocess
.CalledProcessError
as e
:
200 print("ERROR: Cannot find pull request #%s on %s." % (pull
,host_repo
), file=stderr
)
203 subprocess
.check_call([GIT
,'log','-q','-1','refs/heads/'+head_branch
], stdout
=devnull
, stderr
=stdout
)
204 except subprocess
.CalledProcessError
as e
:
205 print("ERROR: Cannot find head of pull request #%s on %s." % (pull
,host_repo
), file=stderr
)
208 subprocess
.check_call([GIT
,'log','-q','-1','refs/heads/'+merge_branch
], stdout
=devnull
, stderr
=stdout
)
209 except subprocess
.CalledProcessError
as e
:
210 print("ERROR: Cannot find merge of pull request #%s on %s." % (pull
,host_repo
), file=stderr
)
213 subprocess
.check_call([GIT
,'fetch','-q',host_repo
,'+refs/heads/'+branch
+':refs/heads/'+base_branch
])
214 except subprocess
.CalledProcessError
as e
:
215 print("ERROR: Cannot find branch %s on %s." % (branch
,host_repo
), file=stderr
)
217 subprocess
.check_call([GIT
,'checkout','-q',base_branch
])
218 subprocess
.call([GIT
,'branch','-q','-D',local_merge_branch
], stderr
=devnull
)
219 subprocess
.check_call([GIT
,'checkout','-q','-b',local_merge_branch
])
222 # Go up to the repository's root.
223 toplevel
= subprocess
.check_output([GIT
,'rev-parse','--show-toplevel']).strip()
225 # Create unsigned merge commit.
227 firstline
= 'Merge #%s: %s' % (pull
,title
)
229 firstline
= 'Merge #%s' % (pull
,)
230 message
= firstline
+ '\n\n'
231 message
+= subprocess
.check_output([GIT
,'log','--no-merges','--topo-order','--pretty=format:%h %s (%an)',base_branch
+'..'+head_branch
]).decode('utf-8')
233 subprocess
.check_call([GIT
,'merge','-q','--commit','--no-edit','--no-ff','-m',message
.encode('utf-8'),head_branch
])
234 except subprocess
.CalledProcessError
as e
:
235 print("ERROR: Cannot be merged cleanly.",file=stderr
)
236 subprocess
.check_call([GIT
,'merge','--abort'])
238 logmsg
= subprocess
.check_output([GIT
,'log','--pretty=format:%s','-n','1']).decode('utf-8')
239 if logmsg
.rstrip() != firstline
.rstrip():
240 print("ERROR: Creating merge failed (already merged?).",file=stderr
)
243 symlink_files
= get_symlink_files()
244 for f
in symlink_files
:
245 print("ERROR: File %s was a symlink" % f
)
246 if len(symlink_files
) > 0:
249 # Put tree SHA512 into the message
251 first_sha512
= tree_sha512sum()
252 message
+= '\n\nTree-SHA512: ' + first_sha512
253 except subprocess
.CalledProcessError
as e
:
254 printf("ERROR: Unable to compute tree hash")
257 subprocess
.check_call([GIT
,'commit','--amend','-m',message
.encode('utf-8')])
258 except subprocess
.CalledProcessError
as e
:
259 printf("ERROR: Cannot update message.",file=stderr
)
262 print_merge_details(pull
, title
, branch
, base_branch
, head_branch
)
265 # Run test command if configured.
267 if subprocess
.call(testcmd
,shell
=True):
268 print("ERROR: Running %s failed." % testcmd
,file=stderr
)
271 # Show the created merge.
272 diff
= subprocess
.check_output([GIT
,'diff',merge_branch
+'..'+local_merge_branch
])
273 subprocess
.check_call([GIT
,'diff',base_branch
+'..'+local_merge_branch
])
275 print("WARNING: merge differs from github!",file=stderr
)
276 reply
= ask_prompt("Type 'ignore' to continue.")
277 if reply
.lower() == 'ignore':
278 print("Difference with github ignored.",file=stderr
)
282 # Verify the result manually.
283 print("Dropping you on a shell so you can try building/testing the merged source.",file=stderr
)
284 print("Run 'git diff HEAD~' to show the changes being merged.",file=stderr
)
285 print("Type 'exit' when done.",file=stderr
)
286 if os
.path
.isfile('/etc/debian_version'): # Show pull number on Debian default prompt
287 os
.putenv('debian_chroot',pull
)
288 subprocess
.call([BASH
,'-i'])
290 second_sha512
= tree_sha512sum()
291 if first_sha512
!= second_sha512
:
292 print("ERROR: Tree hash changed unexpectedly",file=stderr
)
295 # Sign the merge commit.
296 print_merge_details(pull
, title
, branch
, base_branch
, head_branch
)
298 reply
= ask_prompt("Type 's' to sign off on the above merge, or 'x' to reject and exit.").lower()
301 subprocess
.check_call([GIT
,'commit','-q','--gpg-sign','--amend','--no-edit'])
303 except subprocess
.CalledProcessError
as e
:
304 print("Error signing, exiting.",file=stderr
)
307 print("Not signing off on merge, exiting.",file=stderr
)
310 # Put the result in branch.
311 subprocess
.check_call([GIT
,'checkout','-q',branch
])
312 subprocess
.check_call([GIT
,'reset','-q','--hard',local_merge_branch
])
314 # Clean up temporary branches.
315 subprocess
.call([GIT
,'checkout','-q',branch
])
316 subprocess
.call([GIT
,'branch','-q','-D',head_branch
],stderr
=devnull
)
317 subprocess
.call([GIT
,'branch','-q','-D',base_branch
],stderr
=devnull
)
318 subprocess
.call([GIT
,'branch','-q','-D',merge_branch
],stderr
=devnull
)
319 subprocess
.call([GIT
,'branch','-q','-D',local_merge_branch
],stderr
=devnull
)
323 reply
= ask_prompt("Type 'push' to push the result to %s, branch %s, or 'x' to exit without pushing." % (host_repo
,branch
)).lower()
325 subprocess
.check_call([GIT
,'push',host_repo
,'refs/heads/'+branch
])
330 if __name__
== '__main__':