File indexing completed on 2024-03-24 17:21:43

0001 # -*- coding: UTF-8 -*-
0002 
0003 """
0004 Version control operations.
0005 
0006 Collections of PO files are frequently kept under some sort of version control.
0007 This module provides typical version control operations, abstracted across
0008 various version control systems.
0009 
0010 @author: Chusslove Illich (Часлав Илић) <caslav.ilic@gmx.net>
0011 @license: GPLv3
0012 """
0013 
0014 import os
0015 import re
0016 import shutil
0017 import tempfile
0018 
0019 from pology import PologyError, _, n_
0020 from pology.escape import escape_sh
0021 from pology.fsops import collect_system, system_wd, unicode_to_str, join_ncwd
0022 from pology.report import report, warning
0023 
0024 
0025 _vcskeys_by_pkey = {}
0026 _vcstypes_by_akey = {}
0027 
0028 def _register_vcs ():
0029 
0030     register = (
0031         # First keyword is primary.
0032         (("none", "noop", "dummy"),
0033          VcsNoop),
0034         (("svn", "subversion"),
0035          VcsSubversion),
0036         (("git",),
0037          VcsGit),
0038     )
0039     for vcskeys, vcstype in register:
0040         _vcskeys_by_pkey[vcskeys[0]] = vcskeys
0041         _vcstypes_by_akey.update([(x, vcstype) for x in vcskeys])
0042 
0043 
0044 def available_vcs (flat=False):
0045     """
0046     Get keywords of all available version control systems.
0047 
0048     Some VCS have more than one keyword identifying them.
0049     If C{flat} is C{False}, a dictionary with primary keyword per VCS
0050     as keys, and tuple of all alternatives (including the main keyword)
0051     as values, is returned.
0052     If C{flat} is C{True}, all keywords are returned in a flat tuple.
0053 
0054     @return: VCS keywords, as dictionary by primary or as a flat list of all
0055     @rtype: {(string, string)*} or [string*]
0056     """
0057 
0058     if flat:
0059         return list(_vcstypes_by_akey.keys())
0060     else:
0061         return _vcskeys_by_pkey.copy()
0062 
0063 
0064 def make_vcs (vcskey):
0065     """
0066     Factory for version control systems.
0067 
0068     Desired VCS is identified by a keyword. Currently available:
0069       - dummy noop (C{none}, C{noop}, C{dummy})
0070       - Subversion (C{svn}, C{subversion})
0071       - Git (C{git})
0072 
0073     @param vcskey: keyword identifier of the VCS
0074     @type vcskey: string
0075 
0076     @return: version control object
0077     @rtype: instance of L{VcsBase}
0078     """
0079 
0080     nkey = vcskey.strip().lower()
0081     vcstype = _vcstypes_by_akey.get(nkey)
0082     if not vcstype:
0083         raise PologyError(
0084             _("@info",
0085               "Unknown version control system requested by key '%(key)s'.",
0086               key=vcskey))
0087     return vcstype()
0088 
0089 
0090 class VcsBase (object):
0091     """
0092     Abstract base for VCS objects.
0093     """
0094 
0095     def add (self, paths, repadd=False):
0096         """
0097         Add paths to version control.
0098 
0099         It depends on the particular VCS what adding means,
0100         but in general it should be the point where the subsequent L{commit()}
0101         on the same path will record addition in the repository history.
0102 
0103         Also a single path can be given instead of sequence of paths.
0104 
0105         Actually added paths may be different from input paths,
0106         e.g. if an input path is already version controlled,
0107         or input path's parent directory was added as well.
0108         List of added paths can be requested with C{repadd} parameter,
0109         and it will become the second element of return value.
0110 
0111         @param paths: paths to add
0112         @type paths: <string*> or string
0113         @param repadd: whether to report which paths were actually added
0114         @type repadd: bool
0115 
0116         @return: C{True} if addition successful, possibly list of added paths
0117         @rtype: bool or (bool, [string*])
0118         """
0119         raise PologyError(
0120             _("@info",
0121               "Selected version control system does not define adding."))
0122 
0123 
0124     def remove (self, path):
0125         """
0126         Remove path from version control and from disk.
0127 
0128         It depends on the particular VCS what removing means,
0129         but in general it should be the point where the subsequent L{commit()}
0130         on the same path will record removal in the repository history.
0131 
0132         @param path: path to remove
0133         @type path: string
0134 
0135         @return: C{True} if removal successful
0136         @rtype: bool
0137         """
0138 
0139         raise PologyError(
0140             _("@info",
0141               "Selected version control system does not define removing."))
0142 
0143 
0144     def move (self, spath, dpath):
0145         """
0146         Move versioned file or directory within the repository.
0147 
0148         It depends on the particular VCS what moving means,
0149         but in general it should be the point where the subsequent L{commit()}
0150         on source and destination path (or their common parent directory)
0151         will record the move in the repository history.
0152 
0153         @param spath: source path
0154         @type spath: string
0155         @param dpath: destination path
0156         @type dpath: string
0157 
0158         @return: C{True} if moving successful
0159         @rtype: bool
0160         """
0161 
0162         raise PologyError(
0163             _("@info",
0164               "Selected version control system does not define moving."))
0165 
0166 
0167 
0168     def revision (self, path):
0169         """
0170         Get current revision ID of the path.
0171 
0172         @param path: path to query for revision
0173         @type path: string
0174 
0175         @return: revision ID
0176         @rtype: string
0177         """
0178 
0179         raise PologyError(
0180             _("@info",
0181               "Selected version control system does not define "
0182               "revision query."))
0183 
0184 
0185     def is_clear (self, path):
0186         """
0187         Check if the path is in clear state.
0188 
0189         Clear state means none of: not version-controlled, modified, added...
0190 
0191         @param path: path to check the state of
0192         @type path: string
0193 
0194         @return: C{True} if clear
0195         @rtype: bool
0196         """
0197 
0198         raise PologyError(
0199             _("@info",
0200               "Selected version control system does not define state query."))
0201 
0202 
0203     def is_versioned (self, path):
0204         """
0205         Check if path is under version control.
0206 
0207         @param path: path to check
0208         @type path: string
0209 
0210         @return: C{True} if versioned
0211         @rtype: bool
0212         """
0213 
0214         raise PologyError(
0215             _("@info",
0216               "Selected version control system does not define "
0217               "checking whether a path is version controlled."))
0218 
0219 
0220     def export (self, path, rev, dstpath, rewrite=None):
0221         """
0222         Export a versioned file or directory.
0223 
0224         Makes a copy of versioned file or directory pointed to by
0225         local path C{path}, in the revision C{rev}, to destination C{dstpath}.
0226         If C{rev} is C{None}, the clean version of C{path} according
0227         to current local repository state is copied to C{dstpath}.
0228 
0229         Final repository path, as determined from C{path}, can be filtered
0230         through an external function C{rewrite} before being used.
0231         The function takes as arguments the path and revision strings.
0232         This can be useful, for example, to reroute remote repository URL.
0233 
0234         @param path: path of the versioned file or directory in local repository
0235         @type path: string
0236         @param rev: revision to export
0237         @type rev: string or C{None}
0238         @param dstpath: file path to export to
0239         @type dstpath: string
0240         @param rewrite: function to filter resolved repository path
0241         @type rewrite: (string, string)->string or None
0242 
0243         @return: C{True} if fetching succeeded, C{False} otherwise
0244         @rtype: bool
0245         """
0246 
0247         raise PologyError(
0248             _("@info",
0249               "Selected version control system does not define "
0250               "fetching of a versioned path."))
0251 
0252 
0253     def commit (self, paths, message=None, msgfile=None, incparents=True):
0254         """
0255         Commit paths to the repository.
0256 
0257         Paths can include any number of files and directories.
0258         Also a single path string can be given instead of a sequence.
0259         It depends on the particular VCS what committing means,
0260         but in general it should be the earliest level at which
0261         modifications are recorded in the repository history.
0262 
0263         Commit message can be given either directly, through C{message}
0264         parameter, or read from a file with path given by C{msgfile}.
0265         If both C{message} and C{msgfile} are given,
0266         C{message} takes precedence and C{msgfile} is ignored.
0267         If the commit message is not given, VCS should ask for one as usual
0268         (pop an editor window, or whatever the user has configured).
0269 
0270         Some VCS require that the parent directory of a path to be committed
0271         has been committed itself or included in the commit list if not.
0272         If that is the case, C{incparents} parameter determines if
0273         this function should assure that non-committed parents are included
0274         into the commit list too. This may be expensive to check,
0275         so it is good to disable it if all parents are known to be
0276         committed or included in the input paths.
0277 
0278         @param paths: paths to commit
0279         @type paths: <string*> or string
0280         @param message: commit message
0281         @type message: string
0282         @param msgfile: path to file with the commit message
0283         @type msgfile: string
0284         @param incparents: whether to automatically include non-committed
0285             parents in the commit list
0286         @type incparents: bool
0287 
0288         @return: C{True} if committing succeeded, C{False} otherwise
0289         @rtype: bool
0290         """
0291 
0292         raise PologyError(
0293             _("@info",
0294               "Selected version control system does not define "
0295               "committing of paths."))
0296 
0297 
0298     def log (self, path, rev1=None, rev2=None):
0299         """
0300         Get revision log of the path.
0301 
0302         Revision log entry consists of revision ID, commiter name,
0303         date string, and commit message.
0304         Except the revision ID, any of these may be empty strings,
0305         depending on the particular VCS.
0306         The log is ordered from earliest to newest revision.
0307 
0308         A section of entries between revisions C{rev1} (inclusive)
0309         and C{rev2} (exclusive) can be returned instead of the whole log.
0310         If C{rev1} is C{None}, selected IDs start from the first in the log.
0311         If C{rev2} is C{None}, selected IDs end with the last in the log.
0312 
0313         If either C{rev1} or C{rev2} is not C{None} and does not exist in
0314         the path's log, or the path is not versioned, empty log is returned.
0315 
0316         @param path: path to query for revisions
0317         @type path: string
0318         @param rev1: entries starting from this revision (inclusive)
0319         @type rev1: string
0320         @param rev2: entries up to this revision (exclusive)
0321         @type rev2: string
0322 
0323         @return: revision ID, committer name, date string, commit message
0324         @rtype: [(string*4)*]
0325         """
0326 
0327         raise PologyError(
0328             _("@info",
0329               "Selected version control system does not define "
0330               "revision history query."))
0331 
0332 
0333     def to_commit (self, path):
0334         """
0335         Get paths which need to be committed within the given path.
0336 
0337         Input path can be either a file or directory.
0338         If it is a directory, it depends on VCS whether it will
0339         only report files within it that need to be committed,
0340         or subdirectories too (including the given directory).
0341 
0342         @param path: path to query for non-committed paths
0343         @type path: string
0344 
0345         @return: non-committed paths
0346         @rtype: [string*]
0347         """
0348 
0349         raise PologyError(
0350             _("@info",
0351               "Selected version control system does not define "
0352               "listing of non-committed paths."))
0353 
0354 
0355     def diff (self, path, rev1=None, rev2=None):
0356         """
0357         Get diff between revisions of the given path.
0358 
0359         Unified diff is computed and reported as list of 2-tuples,
0360         where the first element is a tag, and the second the payload.
0361         For tags C{" "}, C{"+"}, and C{"-"}, the payload is the line
0362         (without newline) which was equal, added or removed, respectively.
0363         Payload for tag C{":"} is the path of the diffed file,
0364         and for C{"@"} the 4-tuple of old start line, old number of lines,
0365         new start line, and new number of lines, which are represented
0366         by the following difference segment.
0367 
0368         Diffs can be requested between specific revisions.
0369         If both C{rev1} and C{rev2} are C{None},
0370         diff is taken from last known commit to working copy.
0371         If only C{rev2} is C{None} diff is taken from C{rev1} to working copy.
0372 
0373         @param path: path to query for modified lines
0374         @type path: string
0375         @param rev1: diff from this revision
0376         @type rev1: string
0377         @param rev2: diff to this revision
0378         @type rev2: string
0379 
0380         @return: tagged unified diff
0381         @rtype: [(string, string or (int, int, int, int))*]
0382         """
0383 
0384         raise PologyError(
0385             _("@info",
0386               "Selected version control system does not define diffing."))
0387 
0388 
0389     def revert (self, path):
0390         """
0391         Revert a versioned file or directory.
0392 
0393         The path is reverted to the clean version of itself according
0394         to current local repository state.
0395 
0396         @param path: path of the versioned file or directory in local repository
0397         @type path: string
0398 
0399         @return: C{True} if reverting succeeded, C{False} otherwise
0400         @rtype: bool
0401         """
0402 
0403         raise PologyError(
0404             _("@info",
0405               "Selected version control system does not define "
0406               "reverting a versioned path."))
0407 
0408 
0409 class VcsNoop (VcsBase):
0410     """
0411     VCS: Dummy VCS which perform only file system operations.
0412     """
0413 
0414     def add (self, paths, repadd=False):
0415         # Base override.
0416 
0417         return True if not repadd else [True, paths]
0418 
0419 
0420     def remove (self, path):
0421         # Base override.
0422 
0423         if os.path.isdir(path):
0424             shutil.rmtree(path)
0425         else:
0426             os.remove(path)
0427         return True
0428 
0429 
0430     def move (self, spath, dpath):
0431         # Base override.
0432 
0433         shutil.move(spath, dpath)
0434         return True
0435 
0436 
0437     def revision (self, path):
0438         # Base override.
0439 
0440         return ""
0441 
0442 
0443     def is_clear (self, path):
0444         # Base override.
0445 
0446         return True
0447 
0448 
0449     def is_versioned (self, path):
0450         # Base override.
0451 
0452         return True
0453 
0454 
0455     def export (self, path, rev, dstpath, rewrite=None):
0456         # Base override.
0457 
0458         if rev is not None:
0459             return False
0460 
0461         try:
0462             os.shutil.copyfile(path, dstpath)
0463         except:
0464             return False
0465         return True
0466 
0467 
0468     def commit (self, paths, message=None, msgfile=None, incparents=True):
0469         # Base override.
0470 
0471         return True
0472 
0473 
0474     def log (self, path, rev1=None, rev2=None):
0475         # Base override.
0476 
0477         return []
0478 
0479 
0480     def to_commit (self, path):
0481         # Base override.
0482 
0483         return []
0484 
0485 
0486     def revert (self, path):
0487         # Base override.
0488 
0489         return True
0490 
0491 
0492 class VcsSubversion (VcsBase):
0493     """
0494     VCS: Subversion.
0495     """
0496 
0497     def __init__ (self):
0498 
0499         # Environment to cancel any localization in output of operations,
0500         # for methods which need to parse the output.
0501         self._env = os.environ.copy()
0502         self._env["LC_ALL"] = "C"
0503 
0504 
0505     def add (self, paths, repadd=False):
0506         # Base override.
0507 
0508         if isinstance(paths, str):
0509             paths = [paths]
0510         if not paths:
0511             return True
0512 
0513         tmppath = _temp_paths_file(paths)
0514         res = collect_system(["svn", "add", "--force", "--parents",
0515                               "--targets", tmppath],
0516                              env=self._env)
0517         success = (res[2] == 0)
0518         os.remove(tmppath)
0519 
0520         if repadd:
0521             apaths = []
0522             for line in res[0].split("\n"):
0523                 if line.startswith("A"):
0524                     apaths.append(line[1:].strip())
0525             return success, apaths
0526         else:
0527             return success
0528 
0529 
0530     def remove (self, path):
0531         # Base override.
0532 
0533         if collect_system(["svn", "remove", path])[2] != 0:
0534             return False
0535 
0536         return True
0537 
0538 
0539     def move (self, spath, dpath):
0540         # Base override.
0541 
0542         if collect_system(["svn", "move", "--parents",
0543                            self._ep(spath), dpath])[2] != 0:
0544             return False
0545         return True
0546 
0547 
0548     def revision (self, path):
0549         # Base override.
0550 
0551         res = collect_system(["svn", "info", self._ep(path)], env=self._env)
0552         rx = re.compile(r"^Last Changed Rev: *([0-9]+)", re.I)
0553         revid = ""
0554         for line in res[0].split("\n"):
0555             m = rx.search(line)
0556             if m:
0557                 revid = m.group(1)
0558                 break
0559 
0560         return revid
0561 
0562 
0563     def is_clear (self, path):
0564         # Base override.
0565 
0566         res = collect_system(["svn", "status", path], env=self._env)
0567         clear = not re.search(r"^\S", res[0])
0568 
0569         return clear
0570 
0571 
0572     def is_versioned (self, path):
0573         # Base override.
0574 
0575         res = collect_system(["svn", "info", self._ep(path)], env=self._env)
0576         if res[-1] != 0:
0577             return False
0578 
0579         rx = re.compile(r"^Repository", re.I)
0580         for line in res[0].split("\n"):
0581             if rx.search(line):
0582                 return True
0583 
0584         return False
0585 
0586 
0587     def export (self, path, rev, dstpath, rewrite=None):
0588         # Base override.
0589 
0590         if rev is None:
0591             res = collect_system(["svn", "export", "--force", self._ep(path),
0592                                   "-r", "BASE", dstpath])
0593             if res[-1] != 0:
0594                 return False
0595             return True
0596 
0597         res = collect_system(["svn", "info", self._ep(path)], env=self._env)
0598         if res[-1] != 0:
0599             return False
0600         rx = re.compile(r"^URL:\s*(\S+)", re.I)
0601         rempath = None
0602         for line in res[0].split("\n"):
0603             m = rx.search(line)
0604             if m:
0605                 rempath = m.group(1)
0606                 break
0607         if not rempath:
0608             return False
0609 
0610         if rewrite:
0611             rempath = rewrite(rempath, rev)
0612 
0613         if collect_system(["svn", "export", "--force", self._ep(rempath),
0614                            "-r", rev, dstpath])[-1] != 0:
0615             return False
0616 
0617         return True
0618 
0619 
0620     def commit (self, paths, message=None, msgfile=None, incparents=True):
0621         # Base override.
0622 
0623         if isinstance(paths, str):
0624             paths = [paths]
0625         if not paths:
0626             return True
0627 
0628         if incparents:
0629             # Move up any path that needs its parent committed too.
0630             paths_mod = []
0631             for path in paths:
0632                 path_mod = path
0633                 while True:
0634                     path_mod_up = os.path.dirname(path_mod)
0635                     if self.revision(path_mod_up):
0636                         break
0637                     elif not path_mod_up or not self.is_versioned(path_mod_up):
0638                         # Let simply Subversion complain.
0639                         path_mod = path
0640                         break
0641                     else:
0642                         path_mod = path_mod_up
0643                 paths_mod.append(path_mod)
0644             paths = paths_mod
0645 
0646         cmdline = ["svn", "commit"]
0647         if message is not None:
0648             cmdline += ["-m", message]
0649         elif msgfile is not None:
0650             cmdline += ["-F", msgfile]
0651         tmppath = _temp_paths_file(paths)
0652         cmdline += ["--targets", tmppath]
0653         # Do not use collect_system(), user may need to input stuff.
0654         cmdstr = " ".join(map(escape_sh, cmdline))
0655         success = (os.system(unicode_to_str(cmdstr)) == 0)
0656         os.remove(tmppath)
0657 
0658         return success
0659 
0660 
0661     def log (self, path, rev1=None, rev2=None):
0662         # Base override.
0663 
0664         res = collect_system(["svn", "log", self._ep(path)], env=self._env)
0665         if res[-1] != 0:
0666             return []
0667         rev = ""
0668         next_rev, next_cmsg = list(range(2))
0669         entries = []
0670         next = -1
0671         for line in res[0].strip().split("\n"):
0672             if line.startswith("----------"):
0673                 if rev:
0674                     cmsg = "\n".join(cmsg).strip("\n")
0675                     entries.append((rev, user, dstr, cmsg))
0676                 cmsg = []
0677                 next = next_rev
0678             elif next == next_rev:
0679                 lst = line.split("|")
0680                 rev, user, dstr = [x.strip() for x in lst[:3]]
0681                 rev = rev[1:] # strip initial "r"
0682                 next = next_cmsg
0683             elif next == next_cmsg:
0684                 cmsg += [line]
0685 
0686         entries.reverse()
0687 
0688         return _crop_log(entries, rev1, rev2)
0689 
0690 
0691     def to_commit (self, path):
0692         # Base override.
0693 
0694         res = collect_system(["svn", "status", path], env=self._env)
0695         if res[-1] != 0:
0696             return []
0697 
0698         ncpaths = []
0699         for line in res[0].split("\n"):
0700             if line[:1] in ("A", "M"):
0701                 path = line[1:].strip()
0702                 ncpaths.append(path)
0703 
0704         return ncpaths
0705 
0706 
0707     def diff (self, path, rev1=None, rev2=None):
0708         # Base override.
0709 
0710         if rev1 is not None and rev2 is not None:
0711             rspec = "-r %s:%s" % (rev1, rev2)
0712         elif rev1 is not None:
0713             rspec = "-r %s" % rev1
0714         elif rev2 is not None:
0715             raise PologyError(
0716                 _("@info \"Subversion\" is a version control system",
0717                   "Subversion cannot diff from working copy "
0718                   "to a named revision."))
0719         else:
0720             rspec = ""
0721 
0722         res = collect_system(["svn", "diff", path, rspec], env=self._env)
0723         if res[-1] != 0:
0724             warning(_("@info",
0725                       "Subversion reports it cannot diff path '%(path)s':\n"
0726                        "%(msg)s",
0727                        path=path, msg=res[1]))
0728             return []
0729 
0730         udiff = []
0731         nskip = 0
0732         for line in res[0].split("\n"):
0733             if nskip > 0:
0734                 nskip -= 1
0735                 continue
0736 
0737             if line.startswith("Index:"):
0738                 udiff.append((":", line[line.find(":") + 1:].strip()))
0739                 nskip = 3
0740             elif line.startswith("@@"):
0741                 m = re.search(r"-(\d+),(\d+) *\+(\d+),(\d+)", line)
0742                 spans = tuple(map(int, m.groups())) if m else (0, 0, 0, 0)
0743                 udiff.append(("@", spans))
0744             elif line.startswith(" "):
0745                 udiff.append((" ", line[1:]))
0746             elif line.startswith("-"):
0747                 udiff.append(("-", line[1:]))
0748             elif line.startswith("+"):
0749                 udiff.append(("+", line[1:]))
0750 
0751         return udiff
0752 
0753 
0754     def revert (self, path):
0755         # Base override.
0756 
0757         res = collect_system(["svn", "revert", "-R", path], env=self._env)
0758         if res[-1] != 0:
0759             warning(_("@info",
0760                       "Subversion reports it cannot revert path '%(path)s':\n"
0761                       "%(msg)s",
0762                       path=path, msg=res[1]))
0763             return False
0764 
0765         return True
0766 
0767 
0768     def _ep (self, path):
0769         #if "@" in os.path.basename(os.path.normpath(path)):
0770         if "@" in path:
0771             path = "%s@" % path
0772         return path
0773 
0774 
0775 class VcsGit (VcsBase):
0776     """
0777     VCS: Git.
0778     """
0779 
0780     def __init__ (self):
0781 
0782         # Environment to cancel any localization in output of operations,
0783         # for methods which need to parse the output.
0784         self._env = os.environ.copy()
0785         self._env["LC_ALL"] = "C"
0786 
0787 
0788     def _gitroot (self, paths):
0789 
0790         single = False
0791         if isinstance(paths, str):
0792             paths = [paths]
0793             single = True
0794 
0795         # Take first path as referent.
0796         path = os.path.abspath(paths[0])
0797 
0798         root = None
0799         if os.path.isfile(path):
0800             pdir = os.path.dirname(path)
0801         else:
0802             pdir = path
0803         while True:
0804             gitpath = os.path.join(pdir, ".git")
0805             if os.path.isdir(gitpath):
0806                 root = pdir
0807                 break
0808             pdir_p = pdir
0809             pdir = os.path.dirname(pdir)
0810             if pdir == pdir_p:
0811                 break
0812 
0813         if root is None:
0814             raise PologyError(
0815                 _("@info \"Git\" is a version control system",
0816                   "Cannot find Git repository for '%(path)s'.",
0817                   path=path))
0818 
0819         rpaths = []
0820         for path in paths:
0821             path = os.path.abspath(path)
0822             path = path[len(root) + len(os.path.sep):]
0823             rpaths.append(path)
0824 
0825         if single:
0826             return root, rpaths[0]
0827         else:
0828             return root, rpaths
0829 
0830 
0831     def add (self, paths, repadd=False):
0832         # Base override.
0833 
0834         if isinstance(paths, str):
0835             paths = [paths]
0836         if not paths:
0837             return True
0838 
0839         root, paths = self._gitroot(paths)
0840 
0841         success = True
0842         apaths = []
0843         for path in paths:
0844             if collect_system(["git", "add", path], wdir=root)[2] != 0:
0845                 success = False
0846                 break
0847             apaths.append(path)
0848 
0849         return success if not repadd else [success, apaths]
0850 
0851 
0852     def remove (self, path):
0853         # Base override.
0854 
0855         if os.path.isdir(path):
0856             warning(_("@info",
0857                       "Git cannot remove directories (tried on '%(path)s').",
0858                       path=path))
0859             return False
0860 
0861         root, path = self._gitroot(path)
0862 
0863         if collect_system(["git", "rm", path], wdir=root)[2] != 0:
0864             return False
0865 
0866         return True
0867 
0868 
0869     def move (self, spath, dpath):
0870         # Base override.
0871 
0872         root1, spath = self._gitroot(spath)
0873         root2, dpath = self._gitroot(dpath)
0874         if root1 != root2:
0875             warning(_("@info",
0876                       "Trying to move paths between different repositories."))
0877             return False
0878 
0879         if collect_system(["git", "mv", spath, dpath], wdir=root1)[2] != 0:
0880             return False
0881 
0882         return True
0883 
0884 
0885     def revision (self, path):
0886         # Base override.
0887 
0888         root, path = self._gitroot(path)
0889 
0890         res = collect_system(["git", "log", path], wdir=root, env=self._env)
0891         rx = re.compile(r"^commit\s*([0-9abcdef]+)", re.I)
0892         revid = ""
0893         for line in res[0].split("\n"):
0894             m = rx.search(line)
0895             if m:
0896                 revid = m.group(1)
0897                 break
0898 
0899         return revid
0900 
0901 
0902     def is_clear (self, path):
0903         # Base override.
0904 
0905         root, path = self._gitroot(path)
0906 
0907         res = collect_system(["git", "status", path], wdir=root, env=self._env)
0908         rx = re.compile(r"\bmodified:\s*(\S.*)", re.I)
0909         for line in res[0].split("\n"):
0910             m = rx.search(line)
0911             if m:
0912                 mpath = m.group(1)
0913                 if os.path.isfile(path):
0914                     if mpath == path:
0915                         return False
0916                 else:
0917                     if not path or mpath[len(path):].startswith(os.path.sep):
0918                         return False
0919 
0920         return True
0921 
0922 
0923     def is_versioned (self, path):
0924         # Base override.
0925 
0926         root, path = self._gitroot(path)
0927         if not path:
0928             return True
0929 
0930         res = collect_system(["git", "status"], wdir=root, env=self._env)
0931         rx = re.compile(r"untracked.*?:", re.I)
0932         m = rx.search(res[0])
0933         if m:
0934             for line in res[0][m.end():].split("\n"):
0935                 line = line.lstrip("#").strip()
0936                 if line == path:
0937                     return False
0938 
0939         return True
0940 
0941 
0942     def export (self, path, rev, dstpath, rewrite=None):
0943         # Base override.
0944 
0945         root, path = self._gitroot(path)
0946         ret = True
0947 
0948         if rev is None:
0949             rev = "HEAD"
0950 
0951         if rewrite:
0952             path = rewrite(path, rev)
0953 
0954         # FIXME: Better temporary location."
0955         # FIXME: Use list command lines (so must replace piping too).
0956         tarpdir = "/tmp"
0957         tarbdir = "git-archive-tree%d" % os.getpid()
0958         res = collect_system("  git archive --prefix=%s/ %s %s "
0959                              "| (cd %s && tar xf -)"
0960                              % (tarbdir, rev, path, tarpdir),
0961                              wdir=root)
0962         if res[2] == 0:
0963             tardir = os.path.join(tarpdir, tarbdir)
0964             tarpath = os.path.join(tardir, path)
0965             try:
0966                 shutil.move(tarpath, dstpath)
0967             except:
0968                 ret = False
0969             if os.path.isdir(tardir):
0970                 shutil.rmtree(tardir)
0971         else:
0972             ret = False
0973 
0974         return ret
0975 
0976 
0977     def commit (self, paths, message=None, msgfile=None, incparents=True):
0978         # Base override.
0979 
0980         if isinstance(paths, str):
0981             paths = [paths]
0982         if not paths:
0983             return True
0984 
0985         opaths = paths
0986         root, paths = self._gitroot(paths)
0987 
0988         # Check if all paths are versioned.
0989         # Add to index any modified paths that have not been added.
0990         for opath in opaths:
0991             if not self.is_versioned(opath):
0992                 warning(_("@info"
0993                           "Git cannot commit non-versioned path '%(path)s'.",
0994                           path=opath))
0995                 return False
0996             if os.path.exists(opath) and not self.add(opath):
0997                 warning(_("@info"
0998                           "Git cannot add path '%(path)s' to index.",
0999                           path=opath))
1000                 return False
1001 
1002         # Reset all paths in index which have not been given to commit.
1003         ipaths = self._paths_to_commit(root)
1004         rpaths = list(set(ipaths).difference(paths))
1005         if rpaths:
1006             warning(_("@info",
1007                       "Git is resetting paths in index which are "
1008                       "not to be committed."))
1009             cmdline = "git reset %s" % " ".join(rpaths)
1010             system_wd(unicode_to_str(cmdline), root)
1011             # ...seems to return != 0 even if it did what it was told to.
1012 
1013         # Commit the index.
1014         cmdline = ["git", "commit"]
1015         if message is not None:
1016             cmdline += ["-m", message]
1017         elif msgfile is not None:
1018             cmdline += ["-F", msgfile]
1019         # Do not use collect_system(), user may need to input stuff.
1020         cmdstr = " ".join(map(escape_sh, cmdline))
1021         if system_wd(unicode_to_str(cmdstr), root) != 0:
1022             return False
1023 
1024         return True
1025 
1026 
1027     def log (self, path, rev1=None, rev2=None):
1028         # Base override.
1029 
1030         root, path = self._gitroot(path)
1031 
1032         res = collect_system(["git", "log", path], wdir=root, env=self._env)
1033         if res[-1] != 0:
1034             return []
1035         rev = ""
1036         next_auth, next_date, next_cmsg = list(range(3))
1037         next = -1
1038         entries = []
1039         lines = res[0].split("\n")
1040         for i in range(len(lines) + 1):
1041             if i < len(lines):
1042                 line = lines[i]
1043             if i == len(lines) or line.startswith("commit"):
1044                 if rev:
1045                     cmsg = "\n".join(cmsg).strip("\n")
1046                     entries.append((rev, user, dstr, cmsg))
1047                 rev = line[line.find(" ") + 1:].strip()
1048                 cmsg = []
1049                 next = next_auth
1050             elif next == next_auth:
1051                 user = line[line.find(":") + 1:].strip()
1052                 next = next_date
1053             elif next == next_date:
1054                 dstr = line[line.find(":") + 1:].strip()
1055                 next = next_cmsg
1056             elif next == next_cmsg:
1057                 cmsg += [line[4:]]
1058 
1059         entries.reverse()
1060 
1061         return _crop_log(entries, rev1, rev2)
1062 
1063 
1064     def to_commit (self, path):
1065         # Base override.
1066 
1067         root, path = self._gitroot(path)
1068 
1069         ncpaths = self._paths_to_commit(root, path or ".")
1070 
1071         ncpaths = [join_ncwd(root, p) for p in ncpaths]
1072 
1073         return ncpaths
1074 
1075 
1076     def _paths_to_commit (self, root, path=None):
1077 
1078         if path:
1079             cmdline = ["git", "status", path]
1080         else:
1081             cmdline = ["git", "status"]
1082         res = collect_system(cmdline, wdir=root, env=self._env)
1083 
1084         sect_rx = re.compile(r"^(?:# )?(\S.*):$", re.I)
1085         file_rx = re.compile(r"^#?\s+.*\w:\s*(.+?)\s*$", re.I)
1086         inlist = False
1087         ipaths = []
1088         for line in res[0].split("\n"):
1089             m = sect_rx.search(line)
1090             if m:
1091                 mstr = m.group(1)
1092                 if (   mstr.endswith("to be committed") # git 1.6.x
1093                     or mstr.endswith("but not updated") # git 1.7.x
1094                     or mstr.endswith("not staged for commit") # git 1.7.x
1095                 ):
1096                     inlist = True
1097                 else:
1098                     break
1099             if not inlist:
1100                 continue
1101             m = file_rx.search(line)
1102             if m:
1103                 ipaths.append(m.group(1))
1104 
1105         return ipaths
1106 
1107 
1108     def diff (self, path, rev1=None, rev2=None):
1109         # Base override.
1110 
1111         root, path = self._gitroot(path)
1112 
1113         if rev1 is not None and rev2 is not None:
1114             rspec = "%s..%s" % (rev1, rev2)
1115         elif rev1 is not None:
1116             rspec = "%s" % rev1
1117         elif rev2 is not None:
1118             raise PologyError(
1119                 _("@info"
1120                   "Git cannot diff from non-staged paths to a commit."))
1121         else:
1122             rspec = ""
1123 
1124         res = collect_system(["git", "diff", rspec, path],
1125                              wdir=root, env=self._env)
1126         if res[-1] != 0:
1127             warning(_("@info"
1128                       "Git reports it cannot diff path '%(path)s':\n"
1129                       "%(msg)s",
1130                       path=path, msg=res[1]))
1131             return []
1132 
1133         udiff = []
1134         nskip = 0
1135         for line in res[0].split("\n"):
1136             if nskip > 0:
1137                 nskip -= 1
1138                 continue
1139 
1140             if line.startswith("diff"):
1141                 m = re.search(r"a/(.*?) *b/", line)
1142                 udiff.append((":", m.group(1) if m else ""))
1143                 nskip = 3
1144             elif line.startswith("@@"):
1145                 m = re.search(r"-(\d+),(\d+) *\+(\d+),(\d+)", line)
1146                 spans = tuple(map(int, m.groups())) if m else (0, 0, 0, 0)
1147                 udiff.append(("@", spans))
1148             elif line.startswith(" "):
1149                 udiff.append((" ", line[1:]))
1150             elif line.startswith("-"):
1151                 udiff.append(("-", line[1:]))
1152             elif line.startswith("+"):
1153                 udiff.append(("+", line[1:]))
1154 
1155         return udiff
1156 
1157 
1158     def revert (self, path):
1159         # Base override.
1160 
1161         res = collect_system(["git", "checkout", path],
1162                              wdir=root, env=self._env)
1163         if res[-1] != 0:
1164             warning(_("@info"
1165                       "Git reports it cannot revert path '%(path)s':\n"
1166                       "%(msg)s",
1167                       path=path, msg=res[1]))
1168             return []
1169 
1170         return True
1171 
1172 
1173 def _crop_log (entries, rev1, rev2):
1174 
1175     start = 0
1176     if rev1 is not None:
1177         while start < len(entries):
1178             if entries[start][0] == rev1:
1179                 break
1180             start += 1
1181 
1182     end = len(entries)
1183     if rev2 is not None:
1184         while end > 0:
1185             end -= 1
1186             if entries[end][0] == rev2:
1187                 break
1188 
1189     return entries[start:end]
1190 
1191 
1192 def _temp_paths_file (paths):
1193 
1194     content = unicode_to_str("\n".join(paths) + "\n")
1195     tmpf, tmppath = tempfile.mkstemp()
1196     os.write(tmpf, content)
1197     os.close(tmpf)
1198     return tmppath
1199 
1200 
1201 _register_vcs()