| 1 | """Python GIT interface |
| 2 | """ |
| 3 | |
| 4 | __copyright__ = """ |
| 5 | Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com> |
| 6 | |
| 7 | This program is free software; you can redistribute it and/or modify |
| 8 | it under the terms of the GNU General Public License version 2 as |
| 9 | published by the Free Software Foundation. |
| 10 | |
| 11 | This program is distributed in the hope that it will be useful, |
| 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | GNU General Public License for more details. |
| 15 | |
| 16 | You should have received a copy of the GNU General Public License |
| 17 | along with this program; if not, write to the Free Software |
| 18 | Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
| 19 | """ |
| 20 | |
| 21 | import sys, os, re |
| 22 | from shutil import copyfile |
| 23 | |
| 24 | from stgit.exception import * |
| 25 | from stgit import basedir |
| 26 | from stgit.utils import * |
| 27 | from stgit.out import * |
| 28 | from stgit.run import * |
| 29 | from stgit.config import config |
| 30 | |
| 31 | # git exception class |
| 32 | class GitException(StgException): |
| 33 | pass |
| 34 | |
| 35 | # When a subprocess has a problem, we want the exception to be a |
| 36 | # subclass of GitException. |
| 37 | class GitRunException(GitException): |
| 38 | pass |
| 39 | class GRun(Run): |
| 40 | exc = GitRunException |
| 41 | def __init__(self, *cmd): |
| 42 | """Initialise the Run object and insert the 'git' command name. |
| 43 | """ |
| 44 | Run.__init__(self, 'git', *cmd) |
| 45 | |
| 46 | # |
| 47 | # Classes |
| 48 | # |
| 49 | |
| 50 | class Person: |
| 51 | """An author, committer, etc.""" |
| 52 | def __init__(self, name = None, email = None, date = '', |
| 53 | desc = None): |
| 54 | self.name = self.email = self.date = None |
| 55 | if name or email or date: |
| 56 | assert not desc |
| 57 | self.name = name |
| 58 | self.email = email |
| 59 | self.date = date |
| 60 | elif desc: |
| 61 | assert not (name or email or date) |
| 62 | def parse_desc(s): |
| 63 | m = re.match(r'^(.+)<(.+)>(.*)$', s) |
| 64 | assert m |
| 65 | return [x.strip() or None for x in m.groups()] |
| 66 | self.name, self.email, self.date = parse_desc(desc) |
| 67 | def set_name(self, val): |
| 68 | if val: |
| 69 | self.name = val |
| 70 | def set_email(self, val): |
| 71 | if val: |
| 72 | self.email = val |
| 73 | def set_date(self, val): |
| 74 | if val: |
| 75 | self.date = val |
| 76 | def __str__(self): |
| 77 | if self.name and self.email: |
| 78 | return '%s <%s>' % (self.name, self.email) |
| 79 | else: |
| 80 | raise GitException, 'not enough identity data' |
| 81 | |
| 82 | class Commit: |
| 83 | """Handle the commit objects |
| 84 | """ |
| 85 | def __init__(self, id_hash): |
| 86 | self.__id_hash = id_hash |
| 87 | |
| 88 | lines = GRun('cat-file', 'commit', id_hash).output_lines() |
| 89 | for i in range(len(lines)): |
| 90 | line = lines[i] |
| 91 | if not line: |
| 92 | break # we've seen all the header fields |
| 93 | key, val = line.split(' ', 1) |
| 94 | if key == 'tree': |
| 95 | self.__tree = val |
| 96 | elif key == 'author': |
| 97 | self.__author = val |
| 98 | elif key == 'committer': |
| 99 | self.__committer = val |
| 100 | else: |
| 101 | pass # ignore other headers |
| 102 | self.__log = '\n'.join(lines[i+1:]) |
| 103 | |
| 104 | def get_id_hash(self): |
| 105 | return self.__id_hash |
| 106 | |
| 107 | def get_tree(self): |
| 108 | return self.__tree |
| 109 | |
| 110 | def get_parent(self): |
| 111 | parents = self.get_parents() |
| 112 | if parents: |
| 113 | return parents[0] |
| 114 | else: |
| 115 | return None |
| 116 | |
| 117 | def get_parents(self): |
| 118 | return GRun('rev-list', '--parents', '--max-count=1', self.__id_hash |
| 119 | ).output_one_line().split()[1:] |
| 120 | |
| 121 | def get_author(self): |
| 122 | return self.__author |
| 123 | |
| 124 | def get_committer(self): |
| 125 | return self.__committer |
| 126 | |
| 127 | def get_log(self): |
| 128 | return self.__log |
| 129 | |
| 130 | def __str__(self): |
| 131 | return self.get_id_hash() |
| 132 | |
| 133 | # dictionary of Commit objects, used to avoid multiple calls to git |
| 134 | __commits = dict() |
| 135 | |
| 136 | # |
| 137 | # Functions |
| 138 | # |
| 139 | |
| 140 | def get_commit(id_hash): |
| 141 | """Commit objects factory. Save/look-up them in the __commits |
| 142 | dictionary |
| 143 | """ |
| 144 | global __commits |
| 145 | |
| 146 | if id_hash in __commits: |
| 147 | return __commits[id_hash] |
| 148 | else: |
| 149 | commit = Commit(id_hash) |
| 150 | __commits[id_hash] = commit |
| 151 | return commit |
| 152 | |
| 153 | def get_conflicts(): |
| 154 | """Return the list of file conflicts |
| 155 | """ |
| 156 | names = set() |
| 157 | for line in GRun('ls-files', '-z', '--unmerged' |
| 158 | ).raw_output().split('\0')[:-1]: |
| 159 | stat, path = line.split('\t', 1) |
| 160 | names.add(path) |
| 161 | return list(names) |
| 162 | |
| 163 | def exclude_files(): |
| 164 | files = [os.path.join(basedir.get(), 'info', 'exclude')] |
| 165 | user_exclude = config.get('core.excludesfile') |
| 166 | if user_exclude: |
| 167 | files.append(user_exclude) |
| 168 | return files |
| 169 | |
| 170 | def ls_files(files, tree = 'HEAD', full_name = True): |
| 171 | """Return the files known to GIT or raise an error otherwise. It also |
| 172 | converts the file to the full path relative the the .git directory. |
| 173 | """ |
| 174 | if not files: |
| 175 | return [] |
| 176 | |
| 177 | args = [] |
| 178 | if tree: |
| 179 | args.append('--with-tree=%s' % tree) |
| 180 | if full_name: |
| 181 | args.append('--full-name') |
| 182 | args.append('--') |
| 183 | args.extend(files) |
| 184 | try: |
| 185 | # use a set to avoid file names duplication due to different stages |
| 186 | fileset = set(GRun('ls-files', '--error-unmatch', *args).output_lines()) |
| 187 | except GitRunException: |
| 188 | # just hide the details of the 'git ls-files' command we use |
| 189 | raise GitException, \ |
| 190 | 'Some of the given paths are either missing or not known to GIT' |
| 191 | return list(fileset) |
| 192 | |
| 193 | def parse_git_ls(output): |
| 194 | """Parse the output of git diff-index, diff-files, etc. Doesn't handle |
| 195 | rename/copy output, so don't feed it output generated with the -M |
| 196 | or -C flags.""" |
| 197 | t = None |
| 198 | for line in output.split('\0'): |
| 199 | if not line: |
| 200 | # There's a zero byte at the end of the output, which |
| 201 | # gives us an empty string as the last "line". |
| 202 | continue |
| 203 | if t == None: |
| 204 | mode_a, mode_b, sha1_a, sha1_b, t = line.split(' ') |
| 205 | else: |
| 206 | yield (t, line) |
| 207 | t = None |
| 208 | |
| 209 | def tree_status(files = None, tree_id = 'HEAD', unknown = False, |
| 210 | noexclude = True, verbose = False): |
| 211 | """Get the status of all changed files, or of a selected set of |
| 212 | files. Returns a list of pairs - (status, filename). |
| 213 | |
| 214 | If 'not files', it will check all files, and optionally all |
| 215 | unknown files. If 'files' is a list, it will only check the files |
| 216 | in the list. |
| 217 | """ |
| 218 | assert not files or not unknown |
| 219 | |
| 220 | if verbose: |
| 221 | out.start('Checking for changes in the working directory') |
| 222 | |
| 223 | refresh_index() |
| 224 | |
| 225 | if files is None: |
| 226 | files = [] |
| 227 | cache_files = [] |
| 228 | |
| 229 | # unknown files |
| 230 | if unknown: |
| 231 | cmd = ['ls-files', '-z', '--others', '--directory', |
| 232 | '--no-empty-directory'] |
| 233 | if not noexclude: |
| 234 | cmd += ['--exclude=%s' % s for s in |
| 235 | ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']] |
| 236 | cmd += ['--exclude-per-directory=.gitignore'] |
| 237 | cmd += ['--exclude-from=%s' % fn |
| 238 | for fn in exclude_files() |
| 239 | if os.path.exists(fn)] |
| 240 | |
| 241 | lines = GRun(*cmd).raw_output().split('\0') |
| 242 | cache_files += [('?', line) for line in lines if line] |
| 243 | |
| 244 | # conflicted files |
| 245 | conflicts = get_conflicts() |
| 246 | cache_files += [('C', filename) for filename in conflicts |
| 247 | if not files or filename in files] |
| 248 | reported_files = set(conflicts) |
| 249 | files_left = [f for f in files if f not in reported_files] |
| 250 | |
| 251 | # files in the index. Only execute this code if no files were |
| 252 | # specified when calling the function (i.e. report all files) or |
| 253 | # files were specified but already found in the previous step |
| 254 | if not files or files_left: |
| 255 | args = [tree_id] |
| 256 | if files_left: |
| 257 | args += ['--'] + files_left |
| 258 | for t, fn in parse_git_ls(GRun('diff-index', '-z', *args).raw_output()): |
| 259 | # the condition is needed in case files is emtpy and |
| 260 | # diff-index lists those already reported |
| 261 | if not fn in reported_files: |
| 262 | cache_files.append((t, fn)) |
| 263 | reported_files.add(fn) |
| 264 | files_left = [f for f in files if f not in reported_files] |
| 265 | |
| 266 | # files in the index but changed on (or removed from) disk. Only |
| 267 | # execute this code if no files were specified when calling the |
| 268 | # function (i.e. report all files) or files were specified but |
| 269 | # already found in the previous step |
| 270 | if not files or files_left: |
| 271 | args = [] |
| 272 | if files_left: |
| 273 | args += ['--'] + files_left |
| 274 | for t, fn in parse_git_ls(GRun('diff-files', '-z', *args).raw_output()): |
| 275 | # the condition is needed in case files is empty and |
| 276 | # diff-files lists those already reported |
| 277 | if not fn in reported_files: |
| 278 | cache_files.append((t, fn)) |
| 279 | reported_files.add(fn) |
| 280 | |
| 281 | if verbose: |
| 282 | out.done() |
| 283 | |
| 284 | return cache_files |
| 285 | |
| 286 | def local_changes(verbose = True): |
| 287 | """Return true if there are local changes in the tree |
| 288 | """ |
| 289 | return len(tree_status(verbose = verbose)) != 0 |
| 290 | |
| 291 | def get_heads(): |
| 292 | heads = [] |
| 293 | hr = re.compile(r'^[0-9a-f]{40} refs/heads/(.+)$') |
| 294 | for line in GRun('show-ref', '--heads').output_lines(): |
| 295 | m = hr.match(line) |
| 296 | heads.append(m.group(1)) |
| 297 | return heads |
| 298 | |
| 299 | # HEAD value cached |
| 300 | __head = None |
| 301 | |
| 302 | def get_head(): |
| 303 | """Verifies the HEAD and returns the SHA1 id that represents it |
| 304 | """ |
| 305 | global __head |
| 306 | |
| 307 | if not __head: |
| 308 | __head = rev_parse('HEAD') |
| 309 | return __head |
| 310 | |
| 311 | class DetachedHeadException(GitException): |
| 312 | def __init__(self): |
| 313 | GitException.__init__(self, 'Not on any branch') |
| 314 | |
| 315 | def get_head_file(): |
| 316 | """Return the name of the file pointed to by the HEAD symref. |
| 317 | Throw an exception if HEAD is detached.""" |
| 318 | try: |
| 319 | return strip_prefix( |
| 320 | 'refs/heads/', GRun('symbolic-ref', '-q', 'HEAD' |
| 321 | ).output_one_line()) |
| 322 | except GitRunException: |
| 323 | raise DetachedHeadException() |
| 324 | |
| 325 | def set_head_file(ref): |
| 326 | """Resets HEAD to point to a new ref |
| 327 | """ |
| 328 | # head cache flushing is needed since we might have a different value |
| 329 | # in the new head |
| 330 | __clear_head_cache() |
| 331 | try: |
| 332 | GRun('symbolic-ref', 'HEAD', 'refs/heads/%s' % ref).run() |
| 333 | except GitRunException: |
| 334 | raise GitException, 'Could not set head to "%s"' % ref |
| 335 | |
| 336 | def set_ref(ref, val): |
| 337 | """Point ref at a new commit object.""" |
| 338 | try: |
| 339 | GRun('update-ref', ref, val).run() |
| 340 | except GitRunException: |
| 341 | raise GitException, 'Could not update %s to "%s".' % (ref, val) |
| 342 | |
| 343 | def set_branch(branch, val): |
| 344 | set_ref('refs/heads/%s' % branch, val) |
| 345 | |
| 346 | def __set_head(val): |
| 347 | """Sets the HEAD value |
| 348 | """ |
| 349 | global __head |
| 350 | |
| 351 | if not __head or __head != val: |
| 352 | set_ref('HEAD', val) |
| 353 | __head = val |
| 354 | |
| 355 | # only allow SHA1 hashes |
| 356 | assert(len(__head) == 40) |
| 357 | |
| 358 | def __clear_head_cache(): |
| 359 | """Sets the __head to None so that a re-read is forced |
| 360 | """ |
| 361 | global __head |
| 362 | |
| 363 | __head = None |
| 364 | |
| 365 | def refresh_index(): |
| 366 | """Refresh index with stat() information from the working directory. |
| 367 | """ |
| 368 | GRun('update-index', '-q', '--unmerged', '--refresh').run() |
| 369 | |
| 370 | def rev_parse(git_id): |
| 371 | """Parse the string and return a verified SHA1 id |
| 372 | """ |
| 373 | try: |
| 374 | return GRun('rev-parse', '--verify', git_id |
| 375 | ).discard_stderr().output_one_line() |
| 376 | except GitRunException: |
| 377 | raise GitException, 'Unknown revision: %s' % git_id |
| 378 | |
| 379 | def ref_exists(ref): |
| 380 | try: |
| 381 | rev_parse(ref) |
| 382 | return True |
| 383 | except GitException: |
| 384 | return False |
| 385 | |
| 386 | def branch_exists(branch): |
| 387 | return ref_exists('refs/heads/%s' % branch) |
| 388 | |
| 389 | def create_branch(new_branch, tree_id = None): |
| 390 | """Create a new branch in the git repository |
| 391 | """ |
| 392 | if branch_exists(new_branch): |
| 393 | raise GitException, 'Branch "%s" already exists' % new_branch |
| 394 | |
| 395 | current_head_file = get_head_file() |
| 396 | current_head = get_head() |
| 397 | set_head_file(new_branch) |
| 398 | __set_head(current_head) |
| 399 | |
| 400 | # a checkout isn't needed if new branch points to the current head |
| 401 | if tree_id: |
| 402 | try: |
| 403 | switch(tree_id) |
| 404 | except GitException: |
| 405 | # Tree switching failed. Revert the head file |
| 406 | set_head_file(current_head_file) |
| 407 | delete_branch(new_branch) |
| 408 | raise |
| 409 | |
| 410 | if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')): |
| 411 | os.remove(os.path.join(basedir.get(), 'MERGE_HEAD')) |
| 412 | |
| 413 | def switch_branch(new_branch): |
| 414 | """Switch to a git branch |
| 415 | """ |
| 416 | global __head |
| 417 | |
| 418 | if not branch_exists(new_branch): |
| 419 | raise GitException, 'Branch "%s" does not exist' % new_branch |
| 420 | |
| 421 | tree_id = rev_parse('refs/heads/%s^{commit}' % new_branch) |
| 422 | if tree_id != get_head(): |
| 423 | refresh_index() |
| 424 | try: |
| 425 | GRun('read-tree', '-u', '-m', get_head(), tree_id).run() |
| 426 | except GitRunException: |
| 427 | raise GitException, 'read-tree failed (local changes maybe?)' |
| 428 | __head = tree_id |
| 429 | set_head_file(new_branch) |
| 430 | |
| 431 | if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')): |
| 432 | os.remove(os.path.join(basedir.get(), 'MERGE_HEAD')) |
| 433 | |
| 434 | def delete_ref(ref): |
| 435 | if not ref_exists(ref): |
| 436 | raise GitException, '%s does not exist' % ref |
| 437 | sha1 = GRun('show-ref', '-s', ref).output_one_line() |
| 438 | try: |
| 439 | GRun('update-ref', '-d', ref, sha1).run() |
| 440 | except GitRunException: |
| 441 | raise GitException, 'Failed to delete ref %s' % ref |
| 442 | |
| 443 | def delete_branch(name): |
| 444 | delete_ref('refs/heads/%s' % name) |
| 445 | |
| 446 | def rename_ref(from_ref, to_ref): |
| 447 | if not ref_exists(from_ref): |
| 448 | raise GitException, '"%s" does not exist' % from_ref |
| 449 | if ref_exists(to_ref): |
| 450 | raise GitException, '"%s" already exists' % to_ref |
| 451 | |
| 452 | sha1 = GRun('show-ref', '-s', from_ref).output_one_line() |
| 453 | try: |
| 454 | GRun('update-ref', to_ref, sha1, '0'*40).run() |
| 455 | except GitRunException: |
| 456 | raise GitException, 'Failed to create new ref %s' % to_ref |
| 457 | try: |
| 458 | GRun('update-ref', '-d', from_ref, sha1).run() |
| 459 | except GitRunException: |
| 460 | raise GitException, 'Failed to delete ref %s' % from_ref |
| 461 | |
| 462 | def rename_branch(from_name, to_name): |
| 463 | """Rename a git branch.""" |
| 464 | rename_ref('refs/heads/%s' % from_name, 'refs/heads/%s' % to_name) |
| 465 | try: |
| 466 | if get_head_file() == from_name: |
| 467 | set_head_file(to_name) |
| 468 | except DetachedHeadException: |
| 469 | pass # detached HEAD, so the renamee can't be the current branch |
| 470 | reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads') |
| 471 | if os.path.exists(reflog_dir) \ |
| 472 | and os.path.exists(os.path.join(reflog_dir, from_name)): |
| 473 | rename(reflog_dir, from_name, to_name) |
| 474 | |
| 475 | # Persons caching |
| 476 | __user = None |
| 477 | __author = None |
| 478 | __committer = None |
| 479 | |
| 480 | def user(): |
| 481 | """Return the user information. |
| 482 | """ |
| 483 | global __user |
| 484 | if not __user: |
| 485 | name=config.get('user.name') |
| 486 | email=config.get('user.email') |
| 487 | __user = Person(name, email) |
| 488 | return __user; |
| 489 | |
| 490 | def author(): |
| 491 | """Return the author information. |
| 492 | """ |
| 493 | global __author |
| 494 | if not __author: |
| 495 | try: |
| 496 | # the environment variables take priority over config |
| 497 | try: |
| 498 | date = os.environ['GIT_AUTHOR_DATE'] |
| 499 | except KeyError: |
| 500 | date = '' |
| 501 | __author = Person(os.environ['GIT_AUTHOR_NAME'], |
| 502 | os.environ['GIT_AUTHOR_EMAIL'], |
| 503 | date) |
| 504 | except KeyError: |
| 505 | __author = user() |
| 506 | return __author |
| 507 | |
| 508 | def committer(): |
| 509 | """Return the author information. |
| 510 | """ |
| 511 | global __committer |
| 512 | if not __committer: |
| 513 | try: |
| 514 | # the environment variables take priority over config |
| 515 | try: |
| 516 | date = os.environ['GIT_COMMITTER_DATE'] |
| 517 | except KeyError: |
| 518 | date = '' |
| 519 | __committer = Person(os.environ['GIT_COMMITTER_NAME'], |
| 520 | os.environ['GIT_COMMITTER_EMAIL'], |
| 521 | date) |
| 522 | except KeyError: |
| 523 | __committer = user() |
| 524 | return __committer |
| 525 | |
| 526 | def update_cache(files = None, force = False): |
| 527 | """Update the cache information for the given files |
| 528 | """ |
| 529 | cache_files = tree_status(files, verbose = False) |
| 530 | |
| 531 | # everything is up-to-date |
| 532 | if len(cache_files) == 0: |
| 533 | return False |
| 534 | |
| 535 | # check for unresolved conflicts |
| 536 | if not force and [x for x in cache_files |
| 537 | if x[0] not in ['M', 'N', 'A', 'D']]: |
| 538 | raise GitException, 'Updating cache failed: unresolved conflicts' |
| 539 | |
| 540 | # update the cache |
| 541 | add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']] |
| 542 | rm_files = [x[1] for x in cache_files if x[0] in ['D']] |
| 543 | m_files = [x[1] for x in cache_files if x[0] in ['M']] |
| 544 | |
| 545 | GRun('update-index', '--add', '--').xargs(add_files) |
| 546 | GRun('update-index', '--force-remove', '--').xargs(rm_files) |
| 547 | GRun('update-index', '--').xargs(m_files) |
| 548 | |
| 549 | return True |
| 550 | |
| 551 | def commit(message, files = None, parents = None, allowempty = False, |
| 552 | cache_update = True, tree_id = None, set_head = False, |
| 553 | author_name = None, author_email = None, author_date = None, |
| 554 | committer_name = None, committer_email = None): |
| 555 | """Commit the current tree to repository |
| 556 | """ |
| 557 | if not parents: |
| 558 | parents = [] |
| 559 | |
| 560 | # Get the tree status |
| 561 | if cache_update and parents != []: |
| 562 | changes = update_cache(files) |
| 563 | if not changes and not allowempty: |
| 564 | raise GitException, 'No changes to commit' |
| 565 | |
| 566 | # get the commit message |
| 567 | if not message: |
| 568 | message = '\n' |
| 569 | elif message[-1:] != '\n': |
| 570 | message += '\n' |
| 571 | |
| 572 | # write the index to repository |
| 573 | if tree_id == None: |
| 574 | tree_id = GRun('write-tree').output_one_line() |
| 575 | set_head = True |
| 576 | |
| 577 | # the commit |
| 578 | env = {} |
| 579 | if author_name: |
| 580 | env['GIT_AUTHOR_NAME'] = author_name |
| 581 | if author_email: |
| 582 | env['GIT_AUTHOR_EMAIL'] = author_email |
| 583 | if author_date: |
| 584 | env['GIT_AUTHOR_DATE'] = author_date |
| 585 | if committer_name: |
| 586 | env['GIT_COMMITTER_NAME'] = committer_name |
| 587 | if committer_email: |
| 588 | env['GIT_COMMITTER_EMAIL'] = committer_email |
| 589 | commit_id = GRun('commit-tree', tree_id, |
| 590 | *sum([['-p', p] for p in parents], []) |
| 591 | ).env(env).raw_input(message).output_one_line() |
| 592 | if set_head: |
| 593 | __set_head(commit_id) |
| 594 | |
| 595 | return commit_id |
| 596 | |
| 597 | def apply_diff(rev1, rev2, check_index = True, files = None): |
| 598 | """Apply the diff between rev1 and rev2 onto the current |
| 599 | index. This function doesn't need to raise an exception since it |
| 600 | is only used for fast-pushing a patch. If this operation fails, |
| 601 | the pushing would fall back to the three-way merge. |
| 602 | """ |
| 603 | if check_index: |
| 604 | index_opt = ['--index'] |
| 605 | else: |
| 606 | index_opt = [] |
| 607 | |
| 608 | if not files: |
| 609 | files = [] |
| 610 | |
| 611 | diff_str = diff(files, rev1, rev2) |
| 612 | if diff_str: |
| 613 | try: |
| 614 | GRun('apply', *index_opt).raw_input( |
| 615 | diff_str).discard_stderr().no_output() |
| 616 | except GitRunException: |
| 617 | return False |
| 618 | |
| 619 | return True |
| 620 | |
| 621 | stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S) |
| 622 | |
| 623 | def merge_recursive(base, head1, head2): |
| 624 | """Perform a 3-way merge between base, head1 and head2 into the |
| 625 | local tree |
| 626 | """ |
| 627 | refresh_index() |
| 628 | p = GRun('merge-recursive', base, '--', head1, head2).env( |
| 629 | { 'GITHEAD_%s' % base: 'ancestor', |
| 630 | 'GITHEAD_%s' % head1: 'current', |
| 631 | 'GITHEAD_%s' % head2: 'patched'}).returns([0, 1]) |
| 632 | output = p.output_lines() |
| 633 | if p.exitcode: |
| 634 | # There were conflicts |
| 635 | if config.get('stgit.autoimerge') == 'yes': |
| 636 | mergetool() |
| 637 | else: |
| 638 | conflicts = [l for l in output if l.startswith('CONFLICT')] |
| 639 | out.info(*conflicts) |
| 640 | raise GitException, "%d conflict(s)" % len(conflicts) |
| 641 | |
| 642 | def mergetool(files = ()): |
| 643 | """Invoke 'git mergetool' to resolve any outstanding conflicts. If 'not |
| 644 | files', all the files in an unmerged state will be processed.""" |
| 645 | GRun('mergetool', *list(files)).returns([0, 1]).run() |
| 646 | # check for unmerged entries (prepend 'CONFLICT ' for consistency with |
| 647 | # merge_recursive()) |
| 648 | conflicts = ['CONFLICT ' + f for f in get_conflicts()] |
| 649 | if conflicts: |
| 650 | out.info(*conflicts) |
| 651 | raise GitException, "%d conflict(s)" % len(conflicts) |
| 652 | |
| 653 | def diff(files = None, rev1 = 'HEAD', rev2 = None, diff_flags = [], |
| 654 | binary = True): |
| 655 | """Show the diff between rev1 and rev2 |
| 656 | """ |
| 657 | if not files: |
| 658 | files = [] |
| 659 | if binary and '--binary' not in diff_flags: |
| 660 | diff_flags = diff_flags + ['--binary'] |
| 661 | |
| 662 | if rev1 and rev2: |
| 663 | return GRun('diff-tree', '-p', |
| 664 | *(diff_flags + [rev1, rev2, '--'] + files)).raw_output() |
| 665 | elif rev1 or rev2: |
| 666 | refresh_index() |
| 667 | if rev2: |
| 668 | return GRun('diff-index', '-p', '-R', |
| 669 | *(diff_flags + [rev2, '--'] + files)).raw_output() |
| 670 | else: |
| 671 | return GRun('diff-index', '-p', |
| 672 | *(diff_flags + [rev1, '--'] + files)).raw_output() |
| 673 | else: |
| 674 | return '' |
| 675 | |
| 676 | def files(rev1, rev2, diff_flags = []): |
| 677 | """Return the files modified between rev1 and rev2 |
| 678 | """ |
| 679 | |
| 680 | result = [] |
| 681 | for line in GRun('diff-tree', *(diff_flags + ['-r', rev1, rev2]) |
| 682 | ).output_lines(): |
| 683 | result.append('%s %s' % tuple(line.split(' ', 4)[-1].split('\t', 1))) |
| 684 | |
| 685 | return '\n'.join(result) |
| 686 | |
| 687 | def barefiles(rev1, rev2): |
| 688 | """Return the files modified between rev1 and rev2, without status info |
| 689 | """ |
| 690 | |
| 691 | result = [] |
| 692 | for line in GRun('diff-tree', '-r', rev1, rev2).output_lines(): |
| 693 | result.append(line.split(' ', 4)[-1].split('\t', 1)[-1]) |
| 694 | |
| 695 | return '\n'.join(result) |
| 696 | |
| 697 | def pretty_commit(commit_id = 'HEAD', flags = []): |
| 698 | """Return a given commit (log + diff) |
| 699 | """ |
| 700 | return GRun('show', *(flags + [commit_id])).raw_output() |
| 701 | |
| 702 | def checkout(files = None, tree_id = None, force = False): |
| 703 | """Check out the given or all files |
| 704 | """ |
| 705 | if tree_id: |
| 706 | try: |
| 707 | GRun('read-tree', '--reset', tree_id).run() |
| 708 | except GitRunException: |
| 709 | raise GitException, 'Failed "git read-tree" --reset %s' % tree_id |
| 710 | |
| 711 | cmd = ['checkout-index', '-q', '-u'] |
| 712 | if force: |
| 713 | cmd.append('-f') |
| 714 | if files: |
| 715 | GRun(*(cmd + ['--'])).xargs(files) |
| 716 | else: |
| 717 | GRun(*(cmd + ['-a'])).run() |
| 718 | |
| 719 | def switch(tree_id, keep = False): |
| 720 | """Switch the tree to the given id |
| 721 | """ |
| 722 | if keep: |
| 723 | # only update the index while keeping the local changes |
| 724 | GRun('read-tree', tree_id).run() |
| 725 | else: |
| 726 | refresh_index() |
| 727 | try: |
| 728 | GRun('read-tree', '-u', '-m', get_head(), tree_id).run() |
| 729 | except GitRunException: |
| 730 | raise GitException, 'read-tree failed (local changes maybe?)' |
| 731 | |
| 732 | __set_head(tree_id) |
| 733 | |
| 734 | def reset(files = None, tree_id = None, check_out = True): |
| 735 | """Revert the tree changes relative to the given tree_id. It removes |
| 736 | any local changes |
| 737 | """ |
| 738 | if not tree_id: |
| 739 | tree_id = get_head() |
| 740 | |
| 741 | if check_out: |
| 742 | cache_files = tree_status(files, tree_id) |
| 743 | # files which were added but need to be removed |
| 744 | rm_files = [x[1] for x in cache_files if x[0] in ['A']] |
| 745 | |
| 746 | checkout(files, tree_id, True) |
| 747 | # checkout doesn't remove files |
| 748 | map(os.remove, rm_files) |
| 749 | |
| 750 | # if the reset refers to the whole tree, switch the HEAD as well |
| 751 | if not files: |
| 752 | __set_head(tree_id) |
| 753 | |
| 754 | def resolved(filenames, reset = None): |
| 755 | if reset: |
| 756 | stage = {'ancestor': 1, 'current': 2, 'patched': 3}[reset] |
| 757 | GRun('checkout-index', '--no-create', '--stage=%d' % stage, |
| 758 | '--stdin', '-z').input_nulterm(filenames).no_output() |
| 759 | GRun('update-index', '--add', '--').xargs(filenames) |
| 760 | for filename in filenames: |
| 761 | # update the access and modificatied times |
| 762 | os.utime(filename, None) |
| 763 | |
| 764 | def fetch(repository = 'origin', refspec = None): |
| 765 | """Fetches changes from the remote repository, using 'git fetch' |
| 766 | by default. |
| 767 | """ |
| 768 | # we update the HEAD |
| 769 | __clear_head_cache() |
| 770 | |
| 771 | args = [repository] |
| 772 | if refspec: |
| 773 | args.append(refspec) |
| 774 | |
| 775 | command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \ |
| 776 | config.get('stgit.fetchcmd') |
| 777 | Run(*(command.split() + args)).run() |
| 778 | |
| 779 | def pull(repository = 'origin', refspec = None): |
| 780 | """Fetches changes from the remote repository, using 'git pull' |
| 781 | by default. |
| 782 | """ |
| 783 | # we update the HEAD |
| 784 | __clear_head_cache() |
| 785 | |
| 786 | args = [repository] |
| 787 | if refspec: |
| 788 | args.append(refspec) |
| 789 | |
| 790 | command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \ |
| 791 | config.get('stgit.pullcmd') |
| 792 | Run(*(command.split() + args)).run() |
| 793 | |
| 794 | def rebase(tree_id = None): |
| 795 | """Rebase the current tree to the give tree_id. The tree_id |
| 796 | argument may be something other than a GIT id if an external |
| 797 | command is invoked. |
| 798 | """ |
| 799 | command = config.get('branch.%s.stgit.rebasecmd' % get_head_file()) \ |
| 800 | or config.get('stgit.rebasecmd') |
| 801 | if tree_id: |
| 802 | args = [tree_id] |
| 803 | elif command: |
| 804 | args = [] |
| 805 | else: |
| 806 | raise GitException, 'Default rebasing requires a commit id' |
| 807 | if command: |
| 808 | # clear the HEAD cache as the custom rebase command will update it |
| 809 | __clear_head_cache() |
| 810 | Run(*(command.split() + args)).run() |
| 811 | else: |
| 812 | # default rebasing |
| 813 | reset(tree_id = tree_id) |
| 814 | |
| 815 | def repack(): |
| 816 | """Repack all objects into a single pack |
| 817 | """ |
| 818 | GRun('repack', '-a', '-d', '-f').run() |
| 819 | |
| 820 | def apply_patch(filename = None, diff = None, base = None, |
| 821 | fail_dump = True, reject = False, strip = None): |
| 822 | """Apply a patch onto the current or given index. There must not |
| 823 | be any local changes in the tree, otherwise the command fails |
| 824 | """ |
| 825 | if diff is None: |
| 826 | if filename: |
| 827 | f = file(filename) |
| 828 | else: |
| 829 | f = sys.stdin |
| 830 | diff = f.read() |
| 831 | if filename: |
| 832 | f.close() |
| 833 | |
| 834 | if base: |
| 835 | orig_head = get_head() |
| 836 | switch(base) |
| 837 | else: |
| 838 | refresh_index() |
| 839 | |
| 840 | cmd = ['apply', '--index'] |
| 841 | if reject: |
| 842 | cmd += ['--reject'] |
| 843 | if strip != None: |
| 844 | cmd += ['-p', str(strip)] |
| 845 | try: |
| 846 | GRun(*cmd).raw_input(diff).no_output() |
| 847 | except GitRunException: |
| 848 | if base: |
| 849 | switch(orig_head) |
| 850 | if fail_dump: |
| 851 | # write the failed diff to a file |
| 852 | f = file('.stgit-failed.patch', 'w+') |
| 853 | f.write(diff) |
| 854 | f.close() |
| 855 | out.warn('Diff written to the .stgit-failed.patch file') |
| 856 | |
| 857 | raise |
| 858 | |
| 859 | if base: |
| 860 | top = commit(message = 'temporary commit used for applying a patch', |
| 861 | parents = [base]) |
| 862 | switch(orig_head) |
| 863 | merge_recursive(base, orig_head, top) |
| 864 | |
| 865 | def clone(repository, local_dir): |
| 866 | """Clone a remote repository. At the moment, just use the |
| 867 | 'git clone' script |
| 868 | """ |
| 869 | GRun('clone', repository, local_dir).run() |
| 870 | |
| 871 | def modifying_revs(files, base_rev, head_rev): |
| 872 | """Return the revisions from the list modifying the given files.""" |
| 873 | return GRun('rev-list', '%s..%s' % (base_rev, head_rev), '--', *files |
| 874 | ).output_lines() |
| 875 | |
| 876 | def refspec_localpart(refspec): |
| 877 | m = re.match('^[^:]*:([^:]*)$', refspec) |
| 878 | if m: |
| 879 | return m.group(1) |
| 880 | else: |
| 881 | raise GitException, 'Cannot parse refspec "%s"' % line |
| 882 | |
| 883 | def refspec_remotepart(refspec): |
| 884 | m = re.match('^([^:]*):[^:]*$', refspec) |
| 885 | if m: |
| 886 | return m.group(1) |
| 887 | else: |
| 888 | raise GitException, 'Cannot parse refspec "%s"' % line |
| 889 | |
| 890 | def __remotes_from_config(): |
| 891 | return config.sections_matching(r'remote\.(.*)\.url') |
| 892 | |
| 893 | def __remotes_from_dir(dir): |
| 894 | d = os.path.join(basedir.get(), dir) |
| 895 | if os.path.exists(d): |
| 896 | return os.listdir(d) |
| 897 | else: |
| 898 | return [] |
| 899 | |
| 900 | def remotes_list(): |
| 901 | """Return the list of remotes in the repository |
| 902 | """ |
| 903 | return (set(__remotes_from_config()) |
| 904 | | set(__remotes_from_dir('remotes')) |
| 905 | | set(__remotes_from_dir('branches'))) |
| 906 | |
| 907 | def remotes_local_branches(remote): |
| 908 | """Returns the list of local branches fetched from given remote |
| 909 | """ |
| 910 | |
| 911 | branches = [] |
| 912 | if remote in __remotes_from_config(): |
| 913 | for line in config.getall('remote.%s.fetch' % remote): |
| 914 | branches.append(refspec_localpart(line)) |
| 915 | elif remote in __remotes_from_dir('remotes'): |
| 916 | stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r') |
| 917 | for line in stream: |
| 918 | # Only consider Pull lines |
| 919 | m = re.match('^Pull: (.*)\n$', line) |
| 920 | if m: |
| 921 | branches.append(refspec_localpart(m.group(1))) |
| 922 | stream.close() |
| 923 | elif remote in __remotes_from_dir('branches'): |
| 924 | # old-style branches only declare one branch |
| 925 | branches.append('refs/heads/'+remote); |
| 926 | else: |
| 927 | raise GitException, 'Unknown remote "%s"' % remote |
| 928 | |
| 929 | return branches |
| 930 | |
| 931 | def identify_remote(branchname): |
| 932 | """Return the name for the remote to pull the given branchname |
| 933 | from, or None if we believe it is a local branch. |
| 934 | """ |
| 935 | |
| 936 | for remote in remotes_list(): |
| 937 | if branchname in remotes_local_branches(remote): |
| 938 | return remote |
| 939 | |
| 940 | # if we get here we've found nothing, the branch is a local one |
| 941 | return None |
| 942 | |
| 943 | def fetch_head(): |
| 944 | """Return the git id for the tip of the parent branch as left by |
| 945 | 'git fetch'. |
| 946 | """ |
| 947 | |
| 948 | fetch_head=None |
| 949 | stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r") |
| 950 | for line in stream: |
| 951 | # Only consider lines not tagged not-for-merge |
| 952 | m = re.match('^([^\t]*)\t\t', line) |
| 953 | if m: |
| 954 | if fetch_head: |
| 955 | raise GitException, 'StGit does not support multiple FETCH_HEAD' |
| 956 | else: |
| 957 | fetch_head=m.group(1) |
| 958 | stream.close() |
| 959 | |
| 960 | if not fetch_head: |
| 961 | out.warn('No for-merge remote head found in FETCH_HEAD') |
| 962 | |
| 963 | # here we are sure to have a single fetch_head |
| 964 | return fetch_head |
| 965 | |
| 966 | def all_refs(): |
| 967 | """Return a list of all refs in the current repository. |
| 968 | """ |
| 969 | |
| 970 | return [line.split()[1] for line in GRun('show-ref').output_lines()] |