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