# HG changeset patch # User Yuya Nishihara # Date 2018-01-07 02:53:07 # Node ID aa32940279364faf4b5f1088ea78512d3262e30d # Parent 638c012a87efc583225052c8e8cd5b31b9f141a1 cmdutil: expand filename format string by templater (BC) This is BC because '{}' could be a valid filename before, but I believe good programmers wouldn't use such catastrophic output filenames. On the other hand, '\' has to be escaped since it is a directory separator on Windows. Thanks to Matt Harbison for spotting this weird issue. This patch also adds cmdutil.rendertemplate(ctx, tmpl, props) as a simpler way of expanding template against single changeset. .. bc:: '{' in output filename passed to archive/cat/export is taken as a start of a template expression. diff --git a/mercurial/cmdutil.py b/mercurial/cmdutil.py --- a/mercurial/cmdutil.py +++ b/mercurial/cmdutil.py @@ -42,6 +42,7 @@ from . import ( scmutil, smartset, subrepoutil, + templatekw, templater, util, vfs as vfsmod, @@ -891,46 +892,98 @@ def getcommiteditor(edit=False, finishde else: return commiteditor -def makefilename(ctx, pat, - total=None, seqno=None, revwidth=None, pathname=None): +def rendertemplate(ctx, tmpl, props=None): + """Expand a literal template 'tmpl' byte-string against one changeset + + Each props item must be a stringify-able value or a callable returning + such value, i.e. no bare list nor dict should be passed. + """ + repo = ctx.repo() + tres = formatter.templateresources(repo.ui, repo) + t = formatter.maketemplater(repo.ui, tmpl, defaults=templatekw.keywords, + resources=tres) + mapping = {'ctx': ctx, 'revcache': {}} + if props: + mapping.update(props) + return t.render(mapping) + +def _buildfntemplate(pat, total=None, seqno=None, revwidth=None, pathname=None): + r"""Convert old-style filename format string to template string + + >>> _buildfntemplate(b'foo-%b-%n.patch', seqno=0) + 'foo-{reporoot|basename}-{seqno}.patch' + >>> _buildfntemplate(b'%R{tags % "{tag}"}%H') + '{rev}{tags % "{tag}"}{node}' + + '\' in outermost strings has to be escaped because it is a directory + separator on Windows: + + >>> _buildfntemplate(b'c:\\tmp\\%R\\%n.patch', seqno=0) + 'c:\\\\tmp\\\\{rev}\\\\{seqno}.patch' + >>> _buildfntemplate(b'\\\\foo\\bar.patch') + '\\\\\\\\foo\\\\bar.patch' + >>> _buildfntemplate(b'\\{tags % "{tag}"}') + '\\\\{tags % "{tag}"}' + + but inner strings follow the template rules (i.e. '\' is taken as an + escape character): + + >>> _buildfntemplate(br'{"c:\tmp"}', seqno=0) + '{"c:\\tmp"}' + """ expander = { - 'H': lambda: ctx.hex(), - 'R': lambda: '%d' % ctx.rev(), - 'h': lambda: short(ctx.node()), - 'm': lambda: re.sub('[^\w]', '_', - ctx.description().strip().splitlines()[0]), - 'r': lambda: ('%d' % ctx.rev()).zfill(revwidth or 0), - '%': lambda: '%', - 'b': lambda: os.path.basename(ctx.repo().root), - } + b'H': b'{node}', + b'R': b'{rev}', + b'h': b'{node|short}', + b'm': br'{sub(r"[^\w]", "_", desc|firstline)}', + b'r': b'{if(revwidth, pad(rev, revwidth, "0", left=True), rev)}', + b'%': b'%', + b'b': b'{reporoot|basename}', + } if total is not None: - expander['N'] = lambda: '%d' % total + expander[b'N'] = b'{total}' if seqno is not None: - expander['n'] = lambda: '%d' % seqno + expander[b'n'] = b'{seqno}' if total is not None and seqno is not None: - expander['n'] = (lambda: ('%d' % seqno).zfill(len('%d' % total))) + expander[b'n'] = b'{pad(seqno, total|stringify|count, "0", left=True)}' if pathname is not None: - expander['s'] = lambda: os.path.basename(pathname) - expander['d'] = lambda: os.path.dirname(pathname) or '.' - expander['p'] = lambda: pathname + expander[b's'] = b'{pathname|basename}' + expander[b'd'] = b'{if(pathname|dirname, pathname|dirname, ".")}' + expander[b'p'] = b'{pathname}' newname = [] - patlen = len(pat) - i = 0 - while i < patlen: - c = pat[i:i + 1] - if c == '%': - i += 1 - c = pat[i:i + 1] + for typ, start, end in templater.scantemplate(pat, raw=True): + if typ != b'string': + newname.append(pat[start:end]) + continue + i = start + while i < end: + n = pat.find(b'%', i, end) + if n < 0: + newname.append(util.escapestr(pat[i:end])) + break + newname.append(util.escapestr(pat[i:n])) + if n + 2 > end: + raise error.Abort(_("incomplete format spec in output " + "filename")) + c = pat[n + 1:n + 2] + i = n + 2 try: - c = expander[c]() + newname.append(expander[c]) except KeyError: raise error.Abort(_("invalid format spec '%%%s' in output " "filename") % c) - newname.append(c) - i += 1 return ''.join(newname) +def makefilename(ctx, pat, **props): + if not pat: + return pat + tmpl = _buildfntemplate(pat, **props) + # BUG: alias expansion shouldn't be made against template fragments + # rewritten from %-format strings, but we have no easy way to partially + # disable the expansion. + return rendertemplate(ctx, tmpl, pycompat.byteskwargs(props)) + def isstdiofilename(pat): """True if the given pat looks like a filename denoting stdin/stdout""" return not pat or pat == '-' diff --git a/mercurial/commands.py b/mercurial/commands.py --- a/mercurial/commands.py +++ b/mercurial/commands.py @@ -1285,7 +1285,9 @@ def cat(ui, repo, file1, *pats, **opts): no revision is given, the parent of the working directory is used. Output may be to a file, in which case the name of the file is - given using a format string. The formatting rules as follows: + given using a template string. See :hg:`help templates`. In addition + to the common template keywords, the following formatting rules are + supported: :``%%``: literal "%" character :``%s``: basename of file being printed @@ -1296,6 +1298,7 @@ def cat(ui, repo, file1, *pats, **opts): :``%h``: short-form changeset hash (12 hexadecimal digits) :``%r``: zero-padded changeset revision number :``%b``: basename of the exporting repository + :``\\``: literal "\\" character Returns 0 on success. """ @@ -1901,7 +1904,9 @@ def export(ui, repo, *changesets, **opts first parent only. Output may be to a file, in which case the name of the file is - given using a format string. The formatting rules are as follows: + given using a template string. See :hg:`help templates`. In addition + to the common template keywords, the following formatting rules are + supported: :``%%``: literal "%" character :``%H``: changeset hash (40 hexadecimal digits) @@ -1912,6 +1917,7 @@ def export(ui, repo, *changesets, **opts :``%m``: first line of the commit message (only alphanumeric characters) :``%n``: zero-padded sequence number, starting at 1 :``%r``: zero-padded changeset revision number + :``\\``: literal "\\" character Without the -a/--text option, export will avoid generating diffs of files it detects as binary. With -a, export will generate a diff --git a/tests/test-doctest.py b/tests/test-doctest.py --- a/tests/test-doctest.py +++ b/tests/test-doctest.py @@ -42,6 +42,7 @@ def testmod(name, optionflags=0, testtar testmod('mercurial.changegroup') testmod('mercurial.changelog') +testmod('mercurial.cmdutil') testmod('mercurial.color') testmod('mercurial.config') testmod('mercurial.context') diff --git a/tests/test-export.t b/tests/test-export.t --- a/tests/test-export.t +++ b/tests/test-export.t @@ -186,11 +186,45 @@ Checking if only alphanumeric characters exporting patch: ___________0123456789_______ABCDEFGHIJKLMNOPQRSTUVWXYZ______abcdefghijklmnopqrstuvwxyz____.patch +Template fragments in file name: + + $ hg export -v -o '{node|shortest}.patch' tip + exporting patch: + 197e.patch + +Backslash should be preserved because it is a directory separator on Windows: + + $ mkdir out + $ hg export -v -o 'out\{node|shortest}.patch' tip + exporting patch: + out\197e.patch + +Still backslash is taken as an escape character in inner template strings: + + $ hg export -v -o '{"out\{foo}.patch"}' tip + exporting patch: + out{foo}.patch + Invalid pattern in file name: $ hg export -o '%x.patch' tip abort: invalid format spec '%x' in output filename [255] + $ hg export -o '%' tip + abort: incomplete format spec in output filename + [255] + $ hg export -o '%{"foo"}' tip + abort: incomplete format spec in output filename + [255] + $ hg export -o '%m{' tip + hg: parse error at 3: unterminated template expansion + [255] + $ hg export -o '%\' tip + abort: invalid format spec '%\' in output filename + [255] + $ hg export -o '\%' tip + abort: incomplete format spec in output filename + [255] Catch exporting unknown revisions (especially empty revsets, see issue3353)