1. #!/usr/bin/env python
  2. # encoding: utf-8
  3.  
  4. """hgsite
  5.  
  6. Create and/or upload a static copy of the repository.
  7.  
  8. The main goal is sharing Mercurial on servers with only FTP access and
  9. statically served files, while providing the same information as hg
  10. serve and full solutions like bitbucket and gitorious (naturally
  11. without the interactivity).
  12. """
  13.  
  14. __copyright__ = """Copyright 2011 Arne Babenhauserheide
  15.  
  16. This software may be used and distributed according to the terms of the
  17. GNU General Public License version 2 or any later version.
  18. """
  19.  
  20. import os
  21. import shutil
  22. import re
  23. import mercurial
  24. import ftplib
  25. import socket
  26. import datetime
  27. from mercurial import cmdutil, util, scmutil
  28. from mercurial import commands, dispatch
  29. from mercurial.i18n import _
  30. from mercurial import hg, discovery, util, extensions
  31.  
  32. _staticidentifier = ".statichgrepo"
  33.  
  34. templates = {
  35. "head": """<!DOCTYPE html>
  36. <html><head>
  37. <meta charset="utf-8" />
  38. <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <!--duplicate for older browsers-->
  39. <link rel="stylesheet" href="style.css" type="text/css" media="screen" />
  40. <link rel="stylesheet" href="print.css" type="text/css" media="print" />
  41. <title>{title}</title>
  42. </head>
  43. <body>
  44. <h1 id="maintitle">{reponame}</h1>
  45. """,
  46. "srchead": """<!DOCTYPE html>
  47. <html><head>
  48. <meta charset="utf-8" />
  49. <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <!--duplicate for older browsers-->
  50. <link rel="stylesheet" href="style.css" type="text/css" media="screen" />
  51. <link rel="stylesheet" href="print.css" type="text/css" media="print" />
  52. <title>{filetitle}</title>
  53. </head>
  54. <body>
  55. """,
  56. "forkhead": """<!DOCTYPE html>
  57. <html><head>
  58. <meta charset="utf-8" />
  59. <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <!--duplicate for older browsers-->
  60. <link rel="stylesheet" href="style.css" type="text/css" media="screen" />
  61. <link rel="stylesheet" href="print.css" type="text/css" media="print" />
  62. <title>{forkname}</title>
  63. </head>
  64. <body>
  65. <h1>{forkname} <small>(fork of <a href="../../">{reponame}</a>, found at {forkuri})</small></h1>
  66. """,
  67. "foot": "</body></html>\n",
  68. "screenstyle": """ """,
  69. "printstyle": """ """,
  70. "manifesthead": """<h2>""" + _("Commit (click to see the diff)")+""": <a href='../../commit/{hex}.html'>{hex}</a></h2>
  71. <p>{desc}</p><p>{user}</p>
  72. <h2>""" + _("Diffstat") + """</h2>
  73. <pre>{diffstat}</pre>
  74. <h2>""" + _("Files in this revision") + "</h2>",
  75. "commitlog": """\n<div style='float: right; padding-left: 0.5em'><em>({author|person})</em></div><strong> {date|shortdate}: <a href='{relativepath}src/{node}/index.html'>{desc|strip|fill68|firstline}</a></strong> <span style='font-size: xx-small'>{branches} {tags} {bookmarks}</span><p>{desc|escape}</p>\n""",
  76. }
  77.  
  78. _indexregexp = re.compile("^\\.*index.html$")
  79.  
  80.  
  81. def samefilecontent(filepath1, filepath2):
  82. """Check if the content of the two referenced files is equal."""
  83. try:
  84. with open(filepath1) as f1:
  85. with open(filepath2) as f2:
  86. return f1.read() == f2.read()
  87. except OSError: return False
  88.  
  89. def contentequals(filepath, content):
  90. """Check if the files content is content."""
  91. try:
  92. with open(filepath) as f:
  93. return f.read() == content
  94. except OSError: return not content
  95. except IOError: return False # file does not exist. Empty != not existing.
  96. # TODO: check: return True if content is None?
  97.  
  98. def parsereadme(filepath, truncated=False):
  99. """Parse the readme file"""
  100. with open(filepath) as r:
  101. readme = r.read()
  102. if truncated:
  103. return "<pre>" + "\n".join(readme.splitlines()[:5]) + "</pre>"
  104. else:
  105. return "<pre>" + readme + "</pre>"
  106.  
  107. def overviewlogstring(ui, repo, revs, template=templates["commitlog"]):
  108. """Get the string for a log of the given revisions for the overview page."""
  109. ui.pushbuffer()
  110. t = cmdutil.changeset_templater(ui, repo, patch=False, diffopts=None, mapfile=None, buffered=False)
  111. t.use_template(template.replace("{relativepath}", ""))
  112. for c in revs:
  113. ctx = repo.changectx(c)
  114. t.show(ctx)
  115. return ui.popbuffer()
  116.  
  117.  
  118. def writeoverview(ui, repo, target, name):
  119. """Create the overview page"""
  120. overview = ""
  121. # get the title
  122. overview += templates["head"].replace("{reponame}", name).replace("{title}", name)
  123. # add a short identifier from the first line of the readme, if it
  124. # exists # TODO: Parse different types of readme files
  125. readme = name
  126. for f in os.listdir(repo.root):
  127. if f.lower().startswith("readme"):
  128. readme = parsereadme(os.path.join(repo.root, f))
  129. readme_intro = parsereadme(os.path.join(repo.root, f), truncated=True)
  130. overview += "<div id='intro'>"
  131. overview += readme_intro
  132. overview += "</div>"
  133. break
  134. # now the links to the log and the files.
  135. overview += "\n<p id='nav'><a href='commits'>changelog</a> | <a href='src/" + repo["tip"].hex() + "/'>files</a>"
  136. # and the forks
  137. forks = getforkinfo(ui, target)
  138. if forks:
  139. overview += " | " + _("forks: ")
  140. for forkname, forkuri in forks.items():
  141. overview += "<a href='" + getforkdir(target, forkname) + "'>" + forkname + "</a> "
  142. incoming, fn, localother = getincoming(ui, repo, otheruri=forkuri, othername=forkname)
  143. overview += "<small>(" + str(len(incoming))
  144. outgoing, fn, localother = getoutgoing(ui, repo, otheruri=forkuri, othername=forkname)
  145. overview += "<small>↓↑</small>" + str(len(outgoing)) + ")</small> "
  146.  
  147. overview += "</p>"
  148.  
  149. # now add the 5 most recent log entries
  150. # divert all following ui output to a string, so we can just use standard functions
  151. overview += "\n<div id='shortlog'><h2>Changes (<a href='commits'>full changelog</a>)</h2>\n"
  152. ui.pushbuffer()
  153. t = cmdutil.changeset_templater(ui, repo, patch=False, diffopts=None, mapfile=None, buffered=False)
  154. t.use_template(templates["commitlog"].replace("{relativepath}", ""))
  155. for c in range(1, min(len(repo.changelog), 5)):
  156. ctx = repo.changectx(str(-c))
  157. t.show(ctx)
  158. overview += ui.popbuffer()
  159. overview += "</div>"
  160. # Add branch, bookmark and tag information, if they exist.
  161. branches = []
  162. for branch, heads in repo.branchmap().items():
  163. if branch and branch != "default": # not default
  164. branches.extend(heads)
  165.  
  166. try:
  167. tags = repo._tags
  168. except AttributeError:
  169. tags = []
  170. try:
  171. bookmarks = repo._bookmarks
  172. except AttributeError:
  173. bookmarks = []
  174. if branches: # add branches
  175. overview += "\n<div id='branches'><h2>Branches</h2>\n"
  176. overview += overviewlogstring(ui, repo, branches,
  177. template=templates["commitlog"].replace(
  178. "{branches}", "XXXXX").replace(
  179. "{date|shortdate}", "{branches}").replace(
  180. "XXXXX", "{date|shortdate}").replace(
  181. "{tags}", "XXXXX").replace(
  182. "{date|shortdate}", "{tags}").replace(
  183. "XXXXX", "{date|shortdate}"))
  184. overview += "</div>"
  185. if len(tags) > 1:
  186. overview += "\n<div id='tags'><h2>Tags</h2>\n"
  187. overview += overviewlogstring(ui, repo, [tags[t] for t in tags if t != "tip"],
  188. template=templates["commitlog"].replace(
  189. "{tags}", "XXXXX").replace(
  190. "{date|shortdate}", "{tags}").replace(
  191. "XXXXX", "{date|shortdate}"))
  192. overview += "</div>"
  193. if len(bookmarks):
  194. overview += "\n<div id='bookmarks'><h2>Bookmarks</h2>\n"
  195. overview += overviewlogstring(ui, repo, bookmarks.values(),
  196. template=templates["commitlog"].replace(
  197. "{bookmarks}", "XXXXX").replace(
  198. "{date|shortdate}", "{bookmarks}").replace(
  199. "XXXXX", "{date|shortdate}"))
  200. overview += "</div>"
  201. # add the full readme
  202. overview += "<div id='readme'><h2>"+_("Readme")+"</h2>\n"
  203. overview += readme
  204. overview += "</div>"
  205.  
  206. # finish the overview
  207. overview += templates["foot"]
  208. indexfile = os.path.join(target, "index.html")
  209. if not contentequals(indexfile, overview):
  210. with open(indexfile, "w") as f:
  211. f.write(overview)
  212.  
  213. def writelog(ui, repo, target, name):
  214. """Write the full changelog, in steps of 100."""
  215. commits = os.path.join(target, "commits")
  216.  
  217. # create the folders
  218. if not os.path.isdir(commits):
  219. os.makedirs(commits)
  220. for i in range(len(repo.changelog)/100):
  221. d = commits+"-"+str(i+1)+"00"
  222. if not os.path.isdir(d):
  223. os.makedirs(d)
  224.  
  225. # create the log files
  226. t = cmdutil.changeset_templater(ui, repo, patch=False, diffopts=None, mapfile=None, buffered=False)
  227. t.use_template(templates["commitlog"].replace("{relativepath}", "../"))
  228. logs = []
  229. for ck in range(len(repo.changelog)/100+1):
  230. ui.pushbuffer()
  231. if ck:
  232. dd = d
  233. di = str(ck)+"00"
  234. d = commits+"-"+di
  235. logs[-1][-1] += "<p><a href=\"../commits-"+di+"\">earlier</a></p>"
  236. if ck>2:
  237. # the older log gets a reference to the newer one
  238. logs[-1][-1] += "<p><a href=\"../commits-"+str(ck-2)+"00"+"\">later</a></p>"
  239. elif ck>1:
  240. logs[-1][-1] += "<p><a href=\"../commits\">later</a></p>"
  241. logs.append([os.path.join(d, "index.html"), ""])
  242. else:
  243. d = commits
  244. logs.append([os.path.join(d, "index.html"), ""])
  245.  
  246. logs[-1][-1] += templates["head"].replace("{reponame}", "<a href='../'>"+name+"</a>").replace("{title}", name)
  247. for c in range(ck*100+1, min(len(repo.changelog)+1, (ck+1)*100)):
  248. ctx = repo.changectx(str(-c))
  249. t.show(ctx)
  250. logs[-1][-1] += ui.popbuffer()
  251.  
  252. for filepath,data in logs:
  253. data += templates["foot"].replace("{reponame}", "<a href='../'>"+name+"</a>")
  254. if not contentequals(filepath,data):
  255. with open(filepath, "w") as f:
  256. f.write(data)
  257.  
  258. def getlocalother(repo, ui, otheruri, othername):
  259. """Get a local clone of the repo identified by uri and name within .hg/paths.
  260.  
  261. This creates that local clone!
  262. """
  263. # if we cannot get the changes via bundlerepo, we create a
  264. # local clone in .hg/paths/<othername>-<sha1-of-otheruri> and
  265. # check from there. in case that local clone already exists,
  266. # we tell it to pull there. The hash is necessary to prevent
  267. # collisions when the uri changes.
  268. if othername is None:
  269. othername = ""
  270. urihash = util.sha1(otheruri).hexdigest()
  271. localcopy = os.path.join(repo.root, ".hg", "paths",
  272. othername+"-"+urihash)
  273. # if getting remote changes directly fails, we take the
  274. # completely safe path: dispatch uses the only really stable
  275. # interface: the cli.
  276. if os.path.isdir(localcopy):
  277. req = dispatch.request(["-R", localcopy, "pull", otheruri])
  278. else:
  279. req = dispatch.request(["clone", otheruri, localcopy], ui=ui)
  280. dispatch.dispatch(req)
  281. other = hg.peer(repo, {}, localcopy)
  282. return other
  283.  
  284. def getincoming(ui, repo, otheruri, other=None, othername=None):
  285. """Get incoming changes."""
  286. # Note: We cannot just use getcommonincoming and I do not yet know
  287. # how to use its output to get good changes. TODO: do this nicer.
  288. def cleanupfn():
  289. """non-operation cleanup function (default)."""
  290. pass
  291. # cannot do that for ftp or freenet insertion uris (freenet
  292. # separates insertion and retrieval by private/public key)
  293. isftpuri = otheruri.startswith("ftp://")
  294. isfreenetpriv = "AQECAAE/" in otheruri
  295. if isftpuri or isfreenetpriv:
  296. chlist = []
  297. return chlist, cleanupfn, other
  298.  
  299. if not other:
  300. other = hg.peer(repo, {}, otheruri)
  301. ui.pushbuffer() # ignore ui events
  302. source, branches = hg.parseurl(otheruri, None)
  303. revs, checkout = hg.addbranchrevs(repo, other, branches, None)
  304. if revs:
  305. revs = [other.lookup(rev) for rev in revs]
  306. try: # FIXME: This breaks on http repos!
  307. other, chlist, cleanupfn = hg.bundlerepo.getremotechanges(ui, repo, other,
  308. revs, False, False)
  309. except (AttributeError, util.Abort):
  310. other = getlocalother(repo, ui, otheruri, othername)
  311. other, chlist, cleanupfn = hg.bundlerepo.getremotechanges(ui, repo, other,
  312. revs, False, False)
  313. ui.popbuffer()
  314. return chlist, cleanupfn, other
  315.  
  316. def getoutgoing(ui, repo, otheruri, other=None, othername=None):
  317. def cleanupfn():
  318. """non-operation cleanup function (default)."""
  319. pass
  320. # cannot do that for ftp or freenet insertion uris (freenet
  321. # separates insertion and retrieval by private/public key)
  322. isftpuri = otheruri.startswith("ftp://")
  323. isfreenetpriv = "AQECAAE/" in otheruri
  324. if isftpuri or isfreenetpriv:
  325. chlist = []
  326. return chlist, cleanupfn, other
  327.  
  328. if not other:
  329. other = hg.peer(repo, {}, otheruri)
  330.  
  331. def outgoingchanges(repo, other):
  332. from mercurial import discovery
  333. fco = discovery.findcommonoutgoing
  334. try:
  335. og = fco(repo, other, force=True)
  336. return og.missing
  337. except AttributeError: # old client
  338. common, outheads = og
  339. o = repo.changelog.findmissing(common=common, heads=outheads)
  340. return o
  341. other.ui.pushbuffer() # ignore ui events
  342.  
  343. try:
  344. chlist = outgoingchanges(repo, other)
  345. except (AttributeError, util.Abort):
  346. other.ui.popbuffer()
  347. other = getlocalother(repo, ui, otheruri, othername)
  348. other.ui.pushbuffer()
  349. chlist = outgoingchanges(repo, other)
  350.  
  351. other.ui.popbuffer()
  352. return chlist, cleanupfn, other
  353.  
  354.  
  355. def getforkinfo(ui, target):
  356. """Name and Uri of all forks."""
  357. forks = dict(ui.configitems("paths"))
  358. forkinfo = {}
  359. for forkname, forkuri in forks.items():
  360. # ignore the static repo
  361. if os.path.abspath(forkuri) == os.path.abspath(target):
  362. continue
  363. forkinfo[forkname] = forkuri
  364. return forkinfo
  365.  
  366. def safeuri(uri):
  367. """Shareable uris: Hide password + hide freenet insert keys."""
  368. uri = util.hidepassword(uri)
  369. freenetpriv = "AQECAAE/"
  370. if "USK@" in uri and freenetpriv in uri:
  371. uri = "freenet://USK@******" + uri[uri.index(freenetpriv)+len(freenetpriv)-1:]
  372. return uri
  373.  
  374. def getforkdata(ui, repo, target, name, forkname, forkuri):
  375. """Write the site for a single fork."""
  376. # make sure the forkdir exists.
  377. other = hg.peer(repo, {}, forkuri)
  378.  
  379. # incrementally build the html
  380. html = templates["forkhead"].replace(
  381. "{forkname}", forkname).replace(
  382. "{reponame}", name).replace(
  383. "{forkuri}", safeuri(forkuri))
  384.  
  385. # prepare the log templater
  386. t = cmdutil.changeset_templater(ui, repo, patch=False, diffopts=None, mapfile=None, buffered=False)
  387. t.use_template(templates["commitlog"].replace(
  388. "{relativepath}", "../"))
  389.  
  390. # Add incoming commits
  391. html += "<div id='incoming'><h2>Incoming commits</h2>"
  392. chlist, cleanupfn, localother = getincoming(ui, repo, otheruri=forkuri, other=other, othername=forkname)
  393. ui.pushbuffer()
  394. for ch in chlist:
  395. ctx = localother.changectx(ch)
  396. t.show(ctx)
  397. html += ui.popbuffer()
  398. cleanupfn()
  399.  
  400. # add outgoing commits
  401. html += "<div id='outgoing'><h2>Outgoing commits</h2>"
  402. chlist, cleanupfn, localother = getoutgoing(ui, repo, forkuri, other=other, othername=forkname)
  403.  
  404. ui.pushbuffer()
  405. for ch in chlist:
  406. ctx = repo.changectx(ch)
  407. t.show(ctx)
  408. html += ui.popbuffer()
  409. cleanupfn()
  410. html += "</div>"
  411. html += templates["foot"]
  412. return html
  413.  
  414. def getforkdir(target, forkname):
  415. return os.path.join("forks", forkname)
  416.  
  417. def writeforks(ui, repo, target, name):
  418. """Write an info-page for each fork, defined in hg paths.
  419.  
  420. relevant data: incoming commits, outgoing commits, branches and bookmarks not in fork or not in repo. Short: incoming (commits, branches, bookmarks), outgoing (incoming first means, we consider this repo to be the main repo).
  421. """
  422. forkinfo = getforkinfo(ui, target)
  423. for forkname, forkuri in forkinfo.items():
  424. # ignore the static repo itself
  425. if os.path.abspath(forkuri) == os.path.abspath(target):
  426. continue
  427. forkdir = getforkdir(target, forkname)
  428. if not os.path.isdir(os.path.join(target, forkdir)):
  429. os.makedirs(os.path.join(target, forkdir))
  430. with open(os.path.join(target, forkdir, "index.html"), "w") as f:
  431. f.write(
  432. getforkdata(ui, repo, target, name, forkname, forkuri))
  433.  
  434.  
  435. def writecommits(ui, repo, target, name, force=False):
  436. """Write all not yet existing commit files."""
  437. commit = os.path.join(target, "commit")
  438.  
  439. # create the folders
  440. if not os.path.isdir(commit):
  441. os.makedirs(commit)
  442.  
  443. t = cmdutil.changeset_templater(ui, repo, patch=False, diffopts=None, mapfile=None, buffered=False)
  444. t.use_template(templates["commitlog"].replace("{relativepath}", "../"))
  445. for c in range(len(repo.changelog)):
  446. ctx = repo.changectx(str(c))
  447. cpath = os.path.join(commit, ctx.hex() + ".html")
  448. if not force and os.path.isfile(cpath):
  449. continue
  450. with open(cpath, "w") as cf:
  451. cf.write(templates["head"].replace("{reponame}", "<a href='../'>"+name+"</a>").replace("{title}", name))
  452. ui.pushbuffer()
  453. t.show(ctx)
  454. cf.write(ui.popbuffer())
  455. ui.pushbuffer()
  456. commands.diff(ui, repo, change=str(c), git=True)
  457. cf.write("<pre>"+ui.popbuffer().replace("<", "<")+"</pre>")
  458. cf.write(templates["foot"].replace("{reponame}", "<a href='../'>"+name+"</a>"))
  459.  
  460.  
  461. def escapename(filename):
  462. """escape index.html as .index.html and .ind… as ..ind… and so fort."""
  463. if _indexregexp.match(filename) is not None:
  464. return "." + filename
  465. else: return filename
  466.  
  467.  
  468. def parsesrcdata(data):
  469. """Parse a src file into a html file."""
  470. return "<pre>"+data.replace("<", "<")+"</pre>"
  471.  
  472. def srcpath(target, ctx, filename):
  473. """Get the relative path to the static sourcefile for an already escaped filename."""
  474. return os.path.join(target,"src",ctx.hex(),filename+".html")
  475.  
  476. def rawpath(target, ctx, filename):
  477. """Get the relative path to the static sourcefile for an already escaped filename."""
  478. return os.path.join(target,"raw",ctx.hex(),filename)
  479.  
  480. def ctxdiffstat(ui, repo, ctx):
  481. """Get the diffstat of a change context."""
  482. command = "log -r " + ctx.hex() + " --stat --color=never"
  483. req = dispatch.request(command.split(), ui=ui, repo=repo)
  484. ui.pushbuffer()
  485. dispatch.dispatch(req)
  486. # FIXME: remove the color in an elegant way instead of fudging like this.
  487. return ui.popbuffer().replace(
  488. "[0;33m","").replace(
  489. "[0;32m","").replace(
  490. "[0m", "").replace(
  491. "[0;31m", "").replace(
  492. "","")
  493. def createindex(ui, repo, target, ctx):
  494. """Create an index page for the changecontext: the commit message + the user + all files in the changecontext."""
  495. # first the head
  496. index = templates["manifesthead"].replace(
  497. "{hex}", ctx.hex()).replace(
  498. "{desc}", ctx.description()).replace(
  499. "{user}", ctx.user()).replace(
  500. "{diffstat}", ctxdiffstat(ui, repo, ctx))
  501. # then the files
  502. index += "<ul>"
  503. for filename in ctx:
  504. filectx = ctx[filename]
  505. lasteditctx = filectx.filectx(filectx.filerev())
  506. index += "<li><a href='../../"+ os.path.join("src",lasteditctx.hex(), escapename(filename)+".html") + "'>" + filename + "</a>"# (<a href='../../" + os.path.join("raw",lasteditctx.hex(), filename) + "'>raw</a>)</li>"
  507. index += "</ul>"
  508. return index
  509.  
  510. def writesourcetree(ui, repo, target, name, force, rawfiles=False):
  511. """Write manifests for all commits and websites for all files.
  512.  
  513. * For each file, write sites for all revisions where the file was changed: under src/<hex>/path as html site (with linenumbers and maybe colored source), under raw/<hex>/<path> as plain files. If there is an index.html file, write it as .index.html. If there also is .index.html, turn it to ..index.html, …
  514. * For each commit write an index with links to the included files at their latest revisions before/at the commit.
  515. """
  516. # first write all files in all commits.
  517. for c in range(len(repo.changelog)):
  518. ctx = repo.changectx(str(c))
  519. for filename in ctx.files():
  520. try:
  521. filectx = ctx.filectx(filename)
  522. except LookupError, e:
  523. ui.warn("File not found, likely moved ", e, "\n")
  524. if rawfiles:
  525. # first write the raw data
  526. filepath = rawpath(target,ctx,filectx.path())
  527. # skip already existing files
  528. if not force and os.path.isfile(filepath):
  529. continue
  530. try:
  531. os.makedirs(os.path.dirname(filepath))
  532. except OSError: pass # exists
  533. with open(filepath, "w") as f:
  534. f.write(filectx.data())
  535. # then write it as html
  536. _filenameescaped = escapename(filectx.path())
  537. filepath = srcpath(target,ctx,_filenameescaped)
  538. if not force and os.path.isfile(filepath):
  539. continue
  540. try:
  541. os.makedirs(os.path.dirname(filepath))
  542. except OSError: pass # exists
  543. with open(filepath, "w") as f:
  544. f.write(templates["srchead"].replace("{filetitle}", name+": " + filename))
  545. f.write(parsesrcdata(filectx.data()))
  546. f.write(templates["foot"].replace("{reponame}", name))
  547. # then write manifests for all commits
  548. for c in range(len(repo.changelog)):
  549. ctx = repo.changectx(str(c))
  550. filepath = os.path.join(target,"src",ctx.hex(),"index.html")
  551. # skip already existing files
  552. if not force and os.path.isfile(filepath):
  553. continue
  554. try:
  555. os.makedirs(os.path.dirname(filepath))
  556. except OSError: pass # exists
  557. with open(filepath, "w") as f:
  558. f.write(templates["head"].replace("{reponame}", "<a href='../../'>"+name+"</a>").replace("{title}", name))
  559. f.write(createindex(ui, repo, target, ctx))
  560. f.write(templates["foot"].replace("{reponame}", "<a href='../../'>"+name+"</a>"))
  561.  
  562. def parsesite(ui, repo, target, **opts):
  563. """Create the static folder."""
  564. idfile = os.path.join(target, _staticidentifier)
  565. if not os.path.isdir(target):
  566. # make sure the target exists
  567. os.makedirs(target)
  568. else: # make sure it is a staticrepo
  569. if not os.path.isfile(idfile):
  570. if not ui.prompt("The target folder " + target + " has not yet been used as static repo. Really use it? (y/N)", default="n").lower() in ["y", "yes"]:
  571. return
  572. with open(idfile, "w") as i:
  573. i.write("")
  574.  
  575. if opts["sitename"]:
  576. name = opts["sitename"]
  577. elif target != "static": name = target
  578. else: name = os.path.basename(repo.root)
  579.  
  580. # first the stylesheets
  581. screenstyle = opts["screenstyle"]
  582. screenfile = os.path.join(target, "style.css")
  583. if screenstyle and not samefilecontent(screenstyle, screenfile):
  584. shutil.copyfile(screenstyle, screenfile)
  585. elif not contentequals(screenfile,templates["screenstyle"]):
  586. with open(screenfile, "w") as f:
  587. f.write(templates["screenstyle"])
  588. printstyle = opts["printstyle"]
  589. printfile = os.path.join(target, "print.css")
  590. if printstyle and not samefilecontent(printstyle, printfile):
  591. shutil.copyfile(printstyle, printfile)
  592. elif not contentequals(printfile, templates["printstyle"]):
  593. with open(printfile, "w") as f:
  594. f.write(templates["printstyle"])
  595.  
  596. # then the overview
  597. writeoverview(ui, repo, target, name)
  598.  
  599. # and the log
  600. writelog(ui, repo, target, name)
  601.  
  602. # and all commit files
  603. writecommits(ui, repo, target, name, force=opts["force"])
  604.  
  605. # and all file data
  606. writesourcetree(ui, repo, target, name, force=opts["force"])
  607.  
  608. # and all forks
  609. writeforks(ui, repo, target, name)
  610.  
  611.  
  612. def addrepo(ui, repo, target, bookmarks, force):
  613. """Add the repo to the target and make sure it is up to date."""
  614. try:
  615. commands.init(ui, dest=target)
  616. except mercurial.error.RepoError, e:
  617. # already exists
  618. pass
  619.  
  620. ui.pushbuffer()
  621. if bookmarks:
  622. commands.push(ui, repo, dest=target, bookmark=repo._bookmarks, force=force)
  623. else:
  624. commands.push(ui, repo, dest=target, force=force)
  625. ui.popbuffer()
  626.  
  627.  
  628. def upload(ui, repo, target, ftpstring, force):
  629. """upload the repo to the FTP server identified by the ftp string."""
  630. try:
  631. user, password = ftpstring.split("@")[0].split(":")
  632. serverandpath = "@".join(ftpstring.split("@")[1:])
  633. except ValueError:
  634. ui.warn(_("FTP-upload: No @ in FTP-Url. We try anonymous access.\n"))
  635. user, password = "anonymous", ""
  636. serverandpath = ftpstring # no @, so we just take the whole string
  637. server = serverandpath.split("/")[0]
  638. ftppath = "/".join(serverandpath.split("/")[1:])
  639. timeout = 10
  640. try:
  641. ftp = ftplib.FTP(server, user, password, "", timeout)
  642. except socket.timeout:
  643. ui.warn(_("connection to "), server, _(" timed out after "), timeout, _(" seconds.\n"))
  644. return
  645.  
  646. ui.status(ftp.getwelcome(), "\n")
  647.  
  648. # create the target dir.
  649. serverdir = os.path.dirname(ftppath)
  650. serverdirparts = ftppath.split("/")
  651. sd = serverdirparts[0]
  652. if not sd in ftp.nlst():
  653. ftp.mkd(sd)
  654. for sdp in serverdirparts[1:]:
  655. sdo = sd
  656. sd = os.path.join(sd, sdp)
  657. if not sd in ftp.nlst(sdo):
  658. ftp.mkd(sd)
  659.  
  660.  
  661. ftp.cwd(ftppath)
  662. if not ftp.pwd() == "/" + ftppath:
  663. ui.warn(_("not in the correct ftp directory. Cowardly bailing out.\n"))
  664. return
  665.  
  666. #ftp.dir()
  667. #return
  668. ftpfeatures = ftp.sendcmd("FEAT")
  669. featuremtime = " MDTM" in ftpfeatures.splitlines()
  670. _ftplistcache = set()
  671.  
  672. for d, dirnames, filenames in os.walk(target):
  673. for filename in filenames:
  674. localfile = os.path.join(d, filename)
  675. serverfile = localfile[len(target)+1:]
  676. serverdir = os.path.dirname(serverfile)
  677. serverdirparts = serverdir.split("/")
  678. # print serverdirparts, serverfile
  679. with open(localfile, "rb") as f:
  680. sd = serverdirparts[0]
  681. if sd and not sd in _ftplistcache: # should happen only once per superdir
  682. _ftplistcache.update(set(ftp.nlst()))
  683. if sd and not sd in _ftplistcache:
  684. try:
  685. ui.status(_("creating directory "), sd, "\n")
  686. ftp.mkd(sd)
  687. _ftplistcache.add(sd)
  688. except ftplib.error_perm, resp:
  689. ui.warn(_("could not create directory "), sd, ": " , resp, "\n")
  690. else: _ftplistcache.add(sd)
  691.  
  692. for sdp in serverdirparts[1:]:
  693. sdold = sd
  694. sd = os.path.join(sd, sdp)
  695. #print sd, sdp
  696. #print ftp.nlst(sdold)
  697. if sd and not sd in _ftplistcache: # should happen only once per superdir
  698. _ftplistcache.update(set(ftp.nlst(sdold)))
  699. if sd and not sd in _ftplistcache:
  700. try:
  701. ui.status(_("creating directory "), sd, "\n")
  702. ftp.mkd(sd)
  703. _ftplistcache.add(sd)
  704. except ftplib.error_perm, resp:
  705. ui.warn(_("could not create directory "),
  706. sd, ": " , resp, "\n")
  707.  
  708. if not serverfile in _ftplistcache: # should happen for existing files only once per dir.
  709. _ftplistcache.update(set(ftp.nlst(serverdir)))
  710. if not serverfile in _ftplistcache or force:
  711. if force:
  712. ui.status(_("uploading "), serverfile,
  713. _(" because I am forced to.\n"))
  714. else:
  715. ui.status(_("uploading "), serverfile,
  716. _(" because it is not yet online.\n"))
  717.  
  718. ftp.storbinary("STOR "+ serverfile, f)
  719. else:
  720. # reupload the file if the file on the server is older than the local file.
  721. if featuremtime:
  722. ftpmtime = ftp.sendcmd("MDTM " + serverfile).split()[1]
  723. localmtime = os.stat(localfile).st_mtime
  724. localmtimestr = datetime.datetime.utcfromtimestamp(localmtime).strftime("%Y%m%d%H%M%S")
  725. newer = int(localmtimestr) > int(ftpmtime)
  726. if newer:
  727. ui.status(_("uploading "), serverfile,
  728. _(" because it is newer than the file on the FTP server.\n"))
  729. ftp.storbinary("STOR "+ serverfile, f)
  730.  
  731.  
  732.  
  733. def staticsite(ui, repo, target=None, **opts):
  734. """Create a static copy of the repository and/or upload it to an FTP server."""
  735. if repo.root == target:
  736. ui.warn(_("static target repo can’t be the current repo"))
  737. return
  738. if not target: target = "static"
  739. #print repo["."].branch()
  740. # add the hg repo to the static site
  741. # currently we need to either include all bookmarks or not, because we don’t have the remote repo when parsing the site.
  742. # TODO: I don’t know if that is the correct way to go. Maybe always push all.
  743. bookmark = opts["bookmark"]
  744. addrepo(ui, repo, target, bookmark, force=opts["force"])
  745. # first: just create the site.
  746. parsesite(ui, repo, target, **opts)
  747. if opts["upload"]:
  748. # upload the repo
  749. upload(ui, repo, target, opts["upload"], opts["force"])
  750.  
  751.  
  752. cmdtable = {
  753. # "command-name": (function-call, options-list, help-string)
  754. "site": (staticsite,
  755. [
  756. #('r', 'rev', None, 'parse the given revision'),
  757. #('a', 'all', None, 'parse all revisions (requires much space)'),
  758. ('n', 'sitename', "", 'the repo name. Default: folder or last segment of the repo-path.'),
  759. ('u', 'upload', "", 'upload the repo to the given ftp host. Format: user:password@host/path/to/dir'),
  760. ('f', 'force', False, 'force recreating all commit files. Slow.'),
  761. ('s', 'screenstyle', "", 'use a custom stylesheet for display on screen'),
  762. ('p', 'printstyle', "", 'use a custom stylesheet for printing'),
  763. ('B', 'bookmark', False, 'include the bookmarks')],
  764. "[options] [folder]")
  765. }
  766.  
  767. ## add ftp as scheme to be handled by this plugin.
  768.  
  769. wrapcmds = { # cmd: generic, target, fixdoc, ppopts, opts
  770. 'push': (False, None, False, False, [
  771. ('', 'staticsite', None, 'show parent svn revision instead'),
  772. ])
  773. }
  774.  
  775. ## Explicitely wrap functions to change local commands in case the remote repo is an FTP repo. See mercurial.extensions for more information.
  776. # Get the module which holds the functions to wrap
  777. # the new function: gets the original function as first argument and the originals args and kwds.
  778. def findcommonoutgoing(orig, *args, **opts):
  779. repo = args[1]
  780. capable = getattr(repo, 'capable', lambda x: False)
  781. if capable('ftp'):
  782. class fakeoutgoing(object):
  783. def __init__(self):
  784. self.excluded = []
  785. self.missing = []
  786. self.commonheads = []
  787. return fakeoutgoing()
  788. else:
  789. return orig(*args, **opts)
  790. # really wrap the functions
  791. extensions.wrapfunction(discovery, 'findcommonoutgoing', findcommonoutgoing)
  792.  
  793. # explicitely wrap commands in case the remote repo is an FTP repo.
  794. def ftppush(orig, *args, **opts):
  795. try:
  796. ui, repo, path = args
  797. path = ui.expandpath(path)
  798. except ValueError: # no ftp string
  799. ui, repo = args
  800. path = ui.expandpath('default-push', 'default')
  801. # only act differently, if the target is an FTP repo.
  802. if not path.startswith("ftp"):
  803. return orig(*args, **opts)
  804. # first create the site at ._site
  805. target = "._site"
  806. ftpstring = path.replace("ftp://", "")
  807. # fix the options to fit those of the site command
  808. opts["name"] = opts["sitename"]
  809. opts["upload"] = ftpstring
  810. staticsite(ui, repo, target, **opts)
  811. return 0
  812. # really wrap the command
  813. siteopts = [('', 'sitename', "", 'staticsite: the title of the site. Default: folder or last segment of the repo-path.'),
  814. ('', 'screenstyle', "", 'use a custom stylesheet for display on screen'),
  815. ('', 'printstyle', "", 'use a custom stylesheet for printing')]
  816. entry = extensions.wrapcommand(commands.table, "push", ftppush)
  817. entry[1].extend(siteopts)
  818.  
  819. # Starting an FTP repo. Not yet used, except for throwing errors for missing commands and faking the lock.
  820.  
  821. # TODO: repo -> peer
  822. from mercurial import util
  823. try:
  824. from mercurial.peer import peerrepository
  825. except ImportError:
  826. from mercurial.repo import repository as peerrepository
  827. try:
  828. from mercurial.error import RepoError
  829. except ImportError:
  830. from mercurial.repo import RepoError
  831.  
  832. # TODO: repo -> peer
  833. class FTPRepository(peerrepository):
  834. def __init__(self, ui, path, create):
  835. self.create = create
  836. self.ui = ui
  837. self.path = path
  838. self.capabilities = set(["ftp"])
  839.  
  840. def lock(self):
  841. """We cannot really lock FTP repos, yet.
  842.  
  843. TODO: Implement as locking the repo in the static site folder."""
  844. class DummyLock:
  845. def release(self):
  846. pass
  847. l = DummyLock()
  848. return l
  849.  
  850. def url(self):
  851. return self.path
  852.  
  853. def lookup(self, key):
  854. return key
  855.  
  856. def cancopy(self):
  857. return False
  858.  
  859. def heads(self, *args, **opts):
  860. """
  861. Whenever this function is hit, we abort. The traceback is useful for
  862. figuring out where to intercept the functionality.
  863. """
  864. raise util.Abort('command heads unavailable for FTP repositories')
  865.  
  866. def pushkey(self, namespace, key, old, new):
  867. return False
  868.  
  869. def listkeys(self, namespace):
  870. return {}
  871.  
  872. def push(self, remote, force=False, revs=None, newbranch=None):
  873. raise util.Abort('command push unavailable for FTP repositories')
  874. def pull(self, remote, heads=[], force=False):
  875. raise util.Abort('command pull unavailable for FTP repositories')
  876. def findoutgoing(self, remote, base=None, heads=None, force=False):
  877. raise util.Abort('command findoutgoing unavailable for FTP repositories')
  878.  
  879.  
  880. class RepoContainer(object):
  881. def __init__(self):
  882. pass
  883.  
  884. def __repr__(self):
  885. return '<FTPRepository>'
  886.  
  887. def instance(self, ui, url, create):
  888. # Should this use urlmod.url(), or is manual parsing better?
  889. #context = {}
  890. return FTPRepository(ui, url, create)
  891.  
  892. hg.schemes["ftp"] = RepoContainer()
  893.  
  894. def test():
  895. import subprocess as sp
  896. def showcall(args):
  897. print args
  898. sp.call(args)
  899. os.chdir(os.path.dirname(__file__))
  900. # just check if loading the extension works
  901. showcall(["hg", "--config", "extensions.site="+__file__])
  902. # check if I can create a site
  903. showcall(["hg", "--config", "extensions.site="+__file__, "site", "-B", "-n", "mysite"])
  904. # check if uploading works: Only a valid test, if you have a
  905. # post-push hook which does the uploading
  906. showcall(["hg", "--config", "extensions.site="+__file__, "push"])
  907. # check if push directly to ftp works. Requires the path draketo
  908. # to be set up in .hg/hgrc as ftp://user:password/path
  909. showcall(["hg", "--config", "extensions.site="+__file__, "push", "draketo", "--sitename", "site extension"])
  910.  
  911. if __name__ == "__main__":
  912. test()