git @ Cat's Eye Technologies klaus / 1592fc3
Merge branch 'flask' Conflicts: tools/quickstart.py Jonas Haag 12 years ago
42 changed file(s) with 1728 addition(s) and 2025 deletion(s). Raw diff Collapse all Expand all
00 *.pyc
1 /bin/
+0
-3
.gitmodules less more
0 [submodule "nano"]
1 path = nano
2 url = https://github.com/jonashaag/Nano
0 https://github.com/jonashaag/klaus
1 Copyright (c) 2011-2012 Jonas Haag <jonas@lophus.org> and contributors (see Git logs).
02
1 Copyright (c) 2011-2012 Jonas Haag <jonas@lophus.org> and contributors.
2 All rights reserved.
3 License: 2-clause-BSD (Berkley Software Distribution) license
3 Permission to use, copy, modify, and/or distribute this software for any
4 purpose with or without fee is hereby granted, provided that the above
5 copyright notice and this permission notice appear in all copies.
46
5 http://github.com/jonashaag/klaus
6
7 For a full list of contributors have a look at the Git logs.
8
9 The full text of the 2-clause BSD license follows.
10
11 The 2-clause Berkley Software Distribution license
12 ==================================================
13 Redistribution and use in source and binary forms, with or without
14 modification, are permitted provided that the following conditions are met:
15
16 * Redistributions of source code must retain the above copyright
17 notice, this list of conditions and the following disclaimer.
18 * Redistributions in binary form must reproduce the above copyright
19 notice, this list of conditions and the following disclaimer in the
20 documentation and/or other materials provided with the distribution.
21
22 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
23 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
24 IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
25 ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
26 LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
27 CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
28 SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
29 INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
30 CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
31 ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32 POSSIBILITY OF SUCH DAMAGE.
7 THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12 ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13 OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1414 .. _img3: https://github.com/jonashaag/klaus/raw/master/assets/blob-view.gif
1515
1616
17 Requirements
18 ------------
19 * Python 2.7
20 * Jinja2_
21 * Pygments_
22 * dulwich_ (>= 0.7.1)
23 * argparse (only for Python 2.6)
24 * Nano_ (shipped as submodule, do a ``git submodule update --init`` to fetch)
25
26 .. _Jinja2: http://jinja.pocoo.org/
27 .. _Pygments: http://pygments.org/
28 .. _dulwich: http://www.samba.org/~jelmer/dulwich/
29 .. _Nano: https://github.com/jonashaag/nano
30
31
3217 Installation
3318 ------------
34 *The same procedure as every year, James.* ::
19 ::
3520
36 virtualenv your-env
37 source your-env/bin/activate
38
39 git clone https://github.com/jonashaag/klaus
40 cd klaus
41 git submodule update --init
42 pip install -r requirements.txt
21 pip install klaus
4322
4423
4524 Usage
4625 -----
47 Using the ``quickstart.py`` script
48 ..................................
49 ::
5026
51 tools/quickstart --help
52 tools/quickstart.py <host> <port> /path/to/repo1 [../path/to/repo2 [...]]
27 Using the ``klaus`` script
28 ^^^^^^^^^^^^^^^^^^^^^^^^^^
29 To run klaus using the default options::
5330
54 Example::
31 klaus [repo1 [repo2 ...]]
5532
56 tools/quickstart.py 127.0.0.1 8080 ../klaus ../nano ../bjoern
33 For more options, see::
5734
58 This will make klaus serve the *klaus*, *nano* and *bjoern* repos at
59 ``127.0.0.1:8080`` using Python's built-in wsgiref_ server (or, if installed,
60 the bjoern_ server).
35 klaus --help
6136
62 .. _wsgiref: http://docs.python.org/library/wsgiref.html
63 .. _bjoern: https://github.com/jonashaag/bjoern
6437
6538 Using a real server
66 ...................
67 The ``klaus.py`` module contains a WSGI ``application`` object. The repo list
68 is read from the ``KLAUS_REPOS`` environment variable (space-separated paths).
39 ^^^^^^^^^^^^^^^^^^^
40 The ``klaus`` module contains a ``make_app`` function which returns a WSGI app.
6941
70 UWSGI example::
42 An example WSGI helper script is provided with klaus (see ``klaus/wsgi.py``),
43 configuration being read from environment variables. Use it like this (uWSGI example)::
7144
72 uwsgi ... -m klaus --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." ...
45 uwsgi -w klaus.wsgi \
46 --env KLAUS_SITE_TITLE="Klaus Demo" \
47 --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \
48 ...
0 refactor
1 --------
2 * serve under sub-uri - posativ
3
4
05 definitely
16 ----------
7 * who is using klaus, related projects
8 * commit: summary before diffs
9 * tests
210 * tag selector
311
412 maybe
614 * file blame
715 * search function
816 * (p)ajax
17 * pygments css cmd flag
18 * font
0 #!/usr/bin/env python2
1 # coding: utf-8
2
3 import os
4 import argparse
5 from dulwich.errors import NotGitRepository
6 from dulwich.repo import Repo
7 import klaus
8
9
10 def git_repository(path):
11 if not os.path.exists(path):
12 raise argparse.ArgumentTypeError('%r: No such directory' % path)
13 try:
14 Repo(path)
15 except NotGitRepository:
16 raise argparse.ArgumentTypeError('%r: Not a Git repository' % path)
17 return path
18
19
20 def make_parser():
21 parser = argparse.ArgumentParser(epilog="Gemüse kaufen!")
22 parser.add_argument('--host', help="default: 127.0.0.1", default='127.0.0.1')
23 parser.add_argument('--port', help="default: 8080", default=8080, type=int)
24 parser.add_argument('--sitename', help="site name showed in header. default: your hostname")
25
26 parser.add_argument('repos', help='repositories to serve',
27 metavar='DIR', nargs='*', type=git_repository)
28
29 grp = parser.add_argument_group("Git Smart HTTP")
30 grp.add_argument('--smarthttp', help="enable Git Smart HTTP serving",
31 action='store_true')
32 grp.add_argument('--htdigest', help="use credentials from FILE",
33 metavar="FILE", type=argparse.FileType('r'))
34
35 grp = parser.add_argument_group("Development flags", "DO NOT USE IN PRODUCTION!")
36 grp.add_argument('--debug', help="Enable Werkzeug debugger and reloader", action='store_true')
37
38 return parser
39
40
41 def main():
42 args = make_parser().parse_args()
43
44 if not args.sitename:
45 args.sitename = '%s:%d' % (args.host, args.port)
46
47 app = klaus.make_app(args.repos,
48 args.sitename or args.host,
49 args.smarthttp,
50 args.htdigest)
51
52 app.run(args.host, args.port, args.debug)
53
54
55 if __name__ == '__main__':
56 main()
+0
-170
diff.py less more
0 # -*- coding: utf-8 -*-
1 """
2 lodgeit.lib.diff
3 ~~~~~~~~~~~~~~~~
4
5 Render a nice diff between two things.
6
7 :copyright: 2007 by Armin Ronacher.
8 :license: BSD
9 """
10 import re
11 from cgi import escape
12
13
14 def prepare_udiff(udiff, **kwargs):
15 """Prepare an udiff for a template."""
16 return DiffRenderer(udiff).prepare(**kwargs)
17
18
19 class DiffRenderer(object):
20 """Give it a unified diff and it renders you a beautiful
21 html diff :-)
22 """
23 _chunk_re = re.compile(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@')
24
25 def __init__(self, udiff):
26 """:param udiff: a text in udiff format"""
27 self.lines = [escape(line) for line in udiff.splitlines()]
28
29 def _extract_rev(self, line1, line2):
30 def _extract(line):
31 parts = line.split(None, 1)
32 if parts[0].startswith(('a/', 'b/')):
33 parts[0] = parts[0][2:]
34 return parts[0], (len(parts) == 2 and parts[1] or None)
35 try:
36 if line1.startswith('--- ') and line2.startswith('+++ '):
37 return _extract(line1[4:]), _extract(line2[4:])
38 except (ValueError, IndexError):
39 pass
40 return (None, None), (None, None)
41
42 def _highlight_line(self, line, next):
43 """Highlight inline changes in both lines."""
44 start = 0
45 limit = min(len(line['line']), len(next['line']))
46 while start < limit and line['line'][start] == next['line'][start]:
47 start += 1
48 end = -1
49 limit -= start
50 while -end <= limit and line['line'][end] == next['line'][end]:
51 end -= 1
52 end += 1
53 if start or end:
54 def do(l):
55 last = end + len(l['line'])
56 if l['action'] == 'add':
57 tag = 'ins'
58 else:
59 tag = 'del'
60 l['line'] = u'%s<%s>%s</%s>%s' % (
61 l['line'][:start],
62 tag,
63 l['line'][start:last],
64 tag,
65 l['line'][last:]
66 )
67 do(line)
68 do(next)
69
70 def prepare(self, want_header=True):
71 """Parse the diff an return data for the template."""
72 in_header = True
73 header = []
74 lineiter = iter(self.lines)
75 files = []
76 try:
77 line = lineiter.next()
78 while 1:
79 # continue until we found the old file
80 if not line.startswith('--- '):
81 if in_header:
82 header.append(line)
83 line = lineiter.next()
84 continue
85
86 if header and all(x.strip() for x in header):
87 if want_header:
88 files.append({'is_header': True, 'lines': header})
89 header = []
90
91 in_header = False
92 chunks = []
93 old, new = self._extract_rev(line, lineiter.next())
94 files.append({
95 'is_header': False,
96 'old_filename': old[0],
97 'old_revision': old[1],
98 'new_filename': new[0],
99 'new_revision': new[1],
100 'chunks': chunks
101 })
102
103 line = lineiter.next()
104 while line:
105 match = self._chunk_re.match(line)
106 if not match:
107 in_header = True
108 break
109
110 lines = []
111 chunks.append(lines)
112
113 old_line, old_end, new_line, new_end = \
114 [int(x or 1) for x in match.groups()]
115 old_line -= 1
116 new_line -= 1
117 old_end += old_line
118 new_end += new_line
119 line = lineiter.next()
120
121 while old_line < old_end or new_line < new_end:
122 if line:
123 command, line = line[0], line[1:]
124 else:
125 command = ' '
126 affects_old = affects_new = False
127
128 if command == '+':
129 affects_new = True
130 action = 'add'
131 elif command == '-':
132 affects_old = True
133 action = 'del'
134 else:
135 affects_old = affects_new = True
136 action = 'unmod'
137
138 old_line += affects_old
139 new_line += affects_new
140 lines.append({
141 'old_lineno': affects_old and old_line or u'',
142 'new_lineno': affects_new and new_line or u'',
143 'action': action,
144 'line': line
145 })
146 line = lineiter.next()
147
148 except StopIteration:
149 pass
150
151 # highlight inline changes
152 for file in files:
153 if file['is_header']:
154 continue
155 for chunk in file['chunks']:
156 lineiter = iter(chunk)
157 try:
158 while True:
159 line = lineiter.next()
160 if line['action'] != 'unmod':
161 nextline = lineiter.next()
162 if nextline['action'] == 'unmod' or \
163 nextline['action'] == line['action']:
164 continue
165 self._highlight_line(line, nextline)
166 except StopIteration:
167 pass
168
169 return files
0 import os
1 import subprocess
2 import jinja2
3 import flask
4 import httpauth
5 import dulwich.web
6 from klaus import views, utils
7 from klaus.repo import FancyRepo
8
9
10 KLAUS_ROOT = os.path.dirname(__file__)
11
12 try:
13 KLAUS_VERSION = utils.check_output(['git', 'log', '--format=%h', '-n', '1'])
14 except subprocess.CalledProcessError:
15 KLAUS_VERSION = '0.2'
16
17
18 class Klaus(flask.Flask):
19 jinja_options = {
20 'extensions': ['jinja2.ext.autoescape'],
21 'undefined': jinja2.StrictUndefined
22 }
23
24 def __init__(self, repo_paths, sitename, use_smarthttp):
25 self.repos = map(FancyRepo, repo_paths)
26 self.repo_map = dict((repo.name, repo) for repo in self.repos)
27 self.sitename = sitename
28 self.use_smarthttp = use_smarthttp
29
30 flask.Flask.__init__(self, __name__)
31
32 self.setup_routes()
33
34 def create_jinja_environment(self):
35 """ Called by Flask.__init__ """
36 env = super(Klaus, self).create_jinja_environment()
37 for func in [
38 'force_unicode',
39 'timesince',
40 'shorten_sha1',
41 'shorten_message',
42 'extract_author_name',
43 ]:
44 env.filters[func] = getattr(utils, func)
45
46 env.globals['KLAUS_VERSION'] = KLAUS_VERSION
47 env.globals['USE_SMARTHTTP'] = self.use_smarthttp
48 env.globals['SITENAME'] = self.sitename
49
50 return env
51
52 def setup_routes(self):
53 for endpoint, rule in [
54 ('repo_list', '/'),
55 ('blob', '/<repo>/blob/<commit_id>/'),
56 ('blob', '/<repo>/blob/<commit_id>/<path:path>'),
57 ('raw', '/<repo>/raw/<commit_id>/'),
58 ('raw', '/<repo>/raw/<commit_id>/<path:path>'),
59 ('commit', '/<repo>/commit/<commit_id>/'),
60 ('history', '/<repo>/'),
61 ('history', '/<repo>/tree/<commit_id>/'),
62 ('history', '/<repo>/tree/<commit_id>/<path:path>'),
63 ]:
64 self.add_url_rule(rule, view_func=getattr(views, endpoint))
65
66
67 def make_app(repos, sitename, use_smarthttp=False, htdigest_file=None):
68 """
69 Returns a WSGI with all the features (smarthttp, authentication) already
70 patched in.
71 """
72 app = Klaus(
73 repos,
74 sitename,
75 use_smarthttp,
76 )
77 app.wsgi_app = utils.SubUri(app.wsgi_app)
78
79 if use_smarthttp:
80 # `path -> Repo` mapping for Dulwich's web support
81 dulwich_backend = dulwich.server.DictBackend(
82 dict(('/'+repo.name, repo) for repo in app.repos)
83 )
84 # Dulwich takes care of all Git related requests/URLs
85 # and passes through everything else to klaus
86 dulwich_wrapped_app = dulwich.web.make_wsgi_chain(
87 backend=dulwich_backend,
88 fallback_app=app.wsgi_app,
89 )
90
91 # `receive-pack` is requested by the "client" on a push
92 # (the "server" is asked to *receive* packs), i.e. we need to secure
93 # it using authentication or deny access completely to make the repo
94 # read-only.
95 #
96 # Git first sends requests to /<repo-name>/info/refs?service=git-receive-pack.
97 # If this request is responded to using HTTP 401 Unauthorized, the user
98 # is prompted for username and password. If we keep responding 401, Git
99 # interprets this as an authentication failure. (We can't respond 403
100 # because this results in horrible, unhelpful Git error messages.)
101 #
102 # Git will never call /<repo-name>/git-receive-pack if authentication
103 # failed for /info/refs, but since it's used to upload stuff to the server
104 # we must secure it anyway for security reasons.
105 PATTERN = r'^/[^/]+/(info/refs\?service=git-receive-pack|git-receive-pack)$'
106 if htdigest_file:
107 # .htdigest file given. Use it to read the push-er credentials from.
108 app.wsgi_app = httpauth.DigestFileHttpAuthMiddleware(
109 htdigest_file,
110 wsgi_app=dulwich_wrapped_app,
111 routes=[PATTERN],
112 )
113 else:
114 # no .htdigest file given. Disable push-ing. Semantically we should
115 # use HTTP 403 here but since that results in freaky error messages
116 # (see above) we keep asking for authentication (401) instead.
117 # Git will print a nice error message after a few tries.
118 app.wsgi_app = httpauth.AlwaysFailingAuthMiddleware(
119 wsgi_app=dulwich_wrapped_app,
120 routes=[PATTERN],
121 )
122
123 return app
0 # -*- coding: utf-8 -*-
1 """
2 lodgeit.lib.diff
3 ~~~~~~~~~~~~~~~~
4
5 Render a nice diff between two things.
6
7 :copyright: 2007 by Armin Ronacher.
8 :license: BSD
9 """
10 import re
11 from cgi import escape
12
13
14 def prepare_udiff(udiff, **kwargs):
15 """Prepare an udiff for a template."""
16 return DiffRenderer(udiff).prepare(**kwargs)
17
18
19 class DiffRenderer(object):
20 """Give it a unified diff and it renders you a beautiful
21 html diff :-)
22 """
23 _chunk_re = re.compile(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@')
24
25 def __init__(self, udiff):
26 """:param udiff: a text in udiff format"""
27 self.lines = [escape(line) for line in udiff.splitlines()]
28
29 def _extract_rev(self, line1, line2):
30 def _extract(line):
31 parts = line.split(None, 1)
32 if parts[0].startswith(('a/', 'b/')):
33 parts[0] = parts[0][2:]
34 return parts[0], (len(parts) == 2 and parts[1] or None)
35 try:
36 if line1.startswith('--- ') and line2.startswith('+++ '):
37 return _extract(line1[4:]), _extract(line2[4:])
38 except (ValueError, IndexError):
39 pass
40 return (None, None), (None, None)
41
42 def _highlight_line(self, line, next):
43 """Highlight inline changes in both lines."""
44 start = 0
45 limit = min(len(line['line']), len(next['line']))
46 while start < limit and line['line'][start] == next['line'][start]:
47 start += 1
48 end = -1
49 limit -= start
50 while -end <= limit and line['line'][end] == next['line'][end]:
51 end -= 1
52 end += 1
53 if start or end:
54 def do(l):
55 last = end + len(l['line'])
56 if l['action'] == 'add':
57 tag = 'ins'
58 else:
59 tag = 'del'
60 l['line'] = u'%s<%s>%s</%s>%s' % (
61 l['line'][:start],
62 tag,
63 l['line'][start:last],
64 tag,
65 l['line'][last:]
66 )
67 do(line)
68 do(next)
69
70 def prepare(self, want_header=True):
71 """Parse the diff an return data for the template."""
72 in_header = True
73 header = []
74 lineiter = iter(self.lines)
75 files = []
76 try:
77 line = lineiter.next()
78 while 1:
79 # continue until we found the old file
80 if not line.startswith('--- '):
81 if in_header:
82 header.append(line)
83 line = lineiter.next()
84 continue
85
86 if header and all(x.strip() for x in header):
87 if want_header:
88 files.append({'is_header': True, 'lines': header})
89 header = []
90
91 in_header = False
92 chunks = []
93 old, new = self._extract_rev(line, lineiter.next())
94 files.append({
95 'is_header': False,
96 'old_filename': old[0],
97 'old_revision': old[1],
98 'new_filename': new[0],
99 'new_revision': new[1],
100 'chunks': chunks
101 })
102
103 line = lineiter.next()
104 while line:
105 match = self._chunk_re.match(line)
106 if not match:
107 in_header = True
108 break
109
110 lines = []
111 chunks.append(lines)
112
113 old_line, old_end, new_line, new_end = \
114 [int(x or 1) for x in match.groups()]
115 old_line -= 1
116 new_line -= 1
117 old_end += old_line
118 new_end += new_line
119 line = lineiter.next()
120
121 while old_line < old_end or new_line < new_end:
122 if line:
123 command, line = line[0], line[1:]
124 else:
125 command = ' '
126 affects_old = affects_new = False
127
128 if command == '+':
129 affects_new = True
130 action = 'add'
131 elif command == '-':
132 affects_old = True
133 action = 'del'
134 else:
135 affects_old = affects_new = True
136 action = 'unmod'
137
138 old_line += affects_old
139 new_line += affects_new
140 lines.append({
141 'old_lineno': affects_old and old_line or u'',
142 'new_lineno': affects_new and new_line or u'',
143 'action': action,
144 'line': line
145 })
146 line = lineiter.next()
147
148 except StopIteration:
149 pass
150
151 # highlight inline changes
152 for file in files:
153 if file['is_header']:
154 continue
155 for chunk in file['chunks']:
156 lineiter = iter(chunk)
157 try:
158 while True:
159 line = lineiter.next()
160 if line['action'] != 'unmod':
161 nextline = lineiter.next()
162 if nextline['action'] == 'unmod' or \
163 nextline['action'] == line['action']:
164 continue
165 self._highlight_line(line, nextline)
166 except StopIteration:
167 pass
168
169 return files
0 import os
1
2 LANGUAGES = []
3
4 try:
5 import markdown
6 LANGUAGES.append((['.md', '.mkdn'], markdown.markdown))
7 except ImportError:
8 pass
9
10 try:
11 from docutils.core import publish_parts
12 from docutils.writers.html4css1 import Writer
13
14 def render_rest(content):
15
16 # start by h2 and ignore invalid directives and so on (most likely from Sphinx)
17 settings = {'initial_header_level': '2', 'report_level':'quiet'}
18 return publish_parts(content,
19 writer=Writer(),
20 settings_overrides=settings).get('html_body')
21
22 LANGUAGES.append((['.rst', '.rest'], render_rest))
23 except ImportError:
24 pass
25
26
27 def get_renderer(filename):
28 _, ext = os.path.splitext(filename)
29 for extensions, renderer in LANGUAGES:
30 if ext in extensions:
31 return renderer
32
33
34 def can_render(filename):
35 return get_renderer(filename) is not None
36
37
38 def render(filename, content=None):
39 if content is None:
40 content = open(filename).read()
41
42 return get_renderer(filename)(content)
0 import os
1 import cStringIO
2
3 import dulwich, dulwich.patch
4
5 from klaus.utils import check_output
6 from klaus.diff import prepare_udiff
7
8
9 class FancyRepo(dulwich.repo.Repo):
10 # TODO: factor out stuff into dulwich
11 @property
12 def name(self):
13 return self.path.rstrip(os.sep).split(os.sep)[-1].replace('.git', '')
14
15 def get_last_updated_at(self):
16 refs = [self[ref_hash] for ref_hash in self.get_refs().itervalues()]
17 refs.sort(key=lambda obj:getattr(obj, 'commit_time', None),
18 reverse=True)
19 if refs:
20 return refs[0].commit_time
21 return None
22
23 def get_branch_or_commit(self, id):
24 """
25 Returns a `(commit_object, is_branch)` tuple for the commit or branch
26 identified by `id`.
27 """
28 try:
29 return self[id], False
30 except KeyError:
31 return self.get_branch(id), True
32
33 def get_branch(self, name):
34 """ Returns the commit object pointed to by the branch `name`. """
35 return self['refs/heads/'+name]
36
37 def get_default_branch(self):
38 """
39 Tries to guess the default repo branch name.
40 """
41 for candidate in ['master', 'trunk', 'default', 'gh-pages']:
42 try:
43 self.get_branch(candidate)
44 return candidate
45 except KeyError:
46 pass
47 return self.get_branch_names()[0]
48
49 def get_branch_names(self, exclude=()):
50 """ Returns a sorted list of branch names. """
51 branches = []
52 for ref in self.get_refs():
53 if ref.startswith('refs/heads/'):
54 name = ref[len('refs/heads/'):]
55 if name not in exclude:
56 branches.append(name)
57 branches.sort()
58 return branches
59
60 def get_tag_names(self):
61 """ Returns a sorted list of tag names. """
62 tags = []
63 for ref in self.get_refs():
64 if ref.startswith('refs/tags/'):
65 tags.append(ref[len('refs/tags/'):])
66 tags.sort()
67 return tags
68
69 def history(self, commit, path=None, max_commits=None, skip=0):
70 """
71 Returns a list of all commits that infected `path`, starting at branch
72 or commit `commit`. `skip` can be used for pagination, `max_commits`
73 to limit the number of commits returned.
74
75 Similar to `git log [branch/commit] [--skip skip] [-n max_commits]`.
76 """
77 # XXX The pure-Python/dulwich code is very slow compared to `git log`
78 # at the time of this writing (mid-2012).
79 # For instance, `git log .tx` in the Django root directory takes
80 # about 0.15s on my machine whereas the history() method needs 5s.
81 # Therefore we use `git log` here until dulwich gets faster.
82 # For the pure-Python implementation, see the 'purepy-hist' branch.
83
84 cmd = ['git', 'log', '--format=%H']
85 if skip:
86 cmd.append('--skip=%d' % skip)
87 if max_commits:
88 cmd.append('--max-count=%d' % max_commits)
89 cmd.append(commit)
90 if path:
91 cmd.extend(['--', path])
92
93 sha1_sums = check_output(cmd, cwd=os.path.abspath(self.path))
94 return [self[sha1] for sha1 in sha1_sums.strip().split('\n')]
95
96 def get_tree(self, commit, path):
97 """ Returns the Git tree object for `path` at `commit`. """
98 tree = self[commit.tree]
99 if path:
100 for directory in path.strip('/').split('/'):
101 if directory:
102 tree = self[tree[directory][1]]
103 return tree
104
105 def commit_diff(self, commit):
106 from klaus.utils import guess_is_binary, force_unicode
107
108 if commit.parents:
109 parent_tree = self[commit.parents[0]].tree
110 else:
111 parent_tree = None
112
113 changes = self.object_store.tree_changes(parent_tree, commit.tree)
114 for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes:
115 try:
116 if newsha and guess_is_binary(self[newsha]) or \
117 oldsha and guess_is_binary(self[oldsha]):
118 yield {
119 'is_binary': True,
120 'old_filename': oldpath or '/dev/null',
121 'new_filename': newpath or '/dev/null',
122 'chunks': None
123 }
124 continue
125 except KeyError:
126 # newsha/oldsha are probably related to submodules.
127 # Dulwich will handle that.
128 pass
129
130 stringio = cStringIO.StringIO()
131 dulwich.patch.write_object_diff(stringio, self.object_store,
132 (oldpath, oldmode, oldsha),
133 (newpath, newmode, newsha))
134 files = prepare_udiff(force_unicode(stringio.getvalue()),
135 want_header=False)
136 if not files:
137 # the diff module doesn't handle deletions/additions
138 # of empty files correctly.
139 yield {
140 'old_filename': oldpath or '/dev/null',
141 'new_filename': newpath or '/dev/null',
142 'chunks': []
143 }
144 else:
145 yield files[0]
0 @charset "utf-8";
1
2 /* Reset */
3 body { margin: 0; padding: 0; font-family: sans-serif; }
4 pre { line-height: 125%; }
5 a, a:visited { color: #003278; text-decoration: none; }
6 a:hover { text-decoration: underline; }
7 table { border-spacing: 0; border-collapse: collapse; }
8
9 h2 > span:last-of-type { font-size: 60%; }
10 h2 > code:last-of-type {
11 font-size: 60%;
12 border-bottom: 1px solid #E0E0E0;
13 padding: 4px 5px;
14 margin-left: 12px;
15 }
16
17 .slash { color: #666; margin: 0 -0.2em; }
18
19 .clearfloat { clear: both; }
20
21 .history ul, .repolist, .tree ul, .branch-selector ul {
22 list-style-type: none;
23 padding-left: 0;
24 }
25
26 /* Header */
27 header { font-size: 90%; padding: 8px 10px; border-bottom: 3px solid #e0e0e0; }
28 header > a { padding: 10px 0; }
29 header .breadcrumbs > span:before { content: ' » '; color: #666; }
30 header .slash { margin: 0 -3px; }
31
32 .branch-selector {
33 position: absolute;
34 top: 5px;
35 right: 4px;
36 font-size: 90%;
37 background-color: #fefefe;
38 }
39 .branch-selector > * {
40 background-color: #fcfcfc;
41 position: relative;
42 }
43 .branch-selector span {
44 border: 1px solid #f1f1f1;
45 padding: 4px 5px;
46 float: right;
47 }
48 .branch-selector span:after { content: "☟"; margin-left: 5px; }
49 .branch-selector span:hover { background-color: #fefefe; cursor: pointer; }
50 .branch-selector ul {
51 z-index: 1;
52 clear: both;
53 display: none;
54 margin: 0;
55 }
56 .branch-selector li a {
57 display: block;
58 padding: 4px 5px;
59 border-bottom: 1px solid #f1f1f1;
60 }
61 .branch-selector li:first-child a { border-top: 1px solid #f1f1f1; }
62 .branch-selector li a:hover { background-color: #fefefe; }
63 .branch-selector li:last-child a { border: 0; }
64 .branch-selector:hover { border: 1px solid #ccc; }
65 .branch-selector:hover span { border: 0; background-color: inherit; }
66 .branch-selector:hover ul { display: block; }
67
68 /* Footer */
69 footer {
70 clear: both;
71 font-size: 80%;
72 float: right;
73 color: #666;
74 padding: 50px 3px 3px 0;
75 }
76 footer a { color: inherit; border-bottom: 1px dotted #666; }
77 footer a:hover { text-decoration: none; }
78
79
80 /* Container */
81 #content { padding: 5px 1.5%; }
82 #content > div:nth-of-type(1),
83 #content > div:nth-of-type(2) { float: left; }
84 #content > div:nth-of-type(1) { width: 24%; }
85 #content > div:nth-of-type(2) {
86 width: 72%;
87 padding-left: 1.5%;
88 margin-left: 1.5%;
89 border-left: 1px dotted #ccc;
90 }
91
92
93 /* Pagination */
94 .pagination { float: right; margin: 0; font-size: 90%; }
95 .pagination > * {
96 border: 1px solid;
97 padding: 2px 10px;
98 text-align: center;
99 }
100 .pagination .n { font-size: 90%; padding: 1px 5px; position: relative; top: 1px; }
101 .pagination > a { opacity: 0.6; border-color: #6491bf; }
102 .pagination > a:hover { opacity: 1; text-decoration: none; border-color: #4D6FA0; }
103 .pagination span { color: #999; border-color: #ccc; }
104
105
106 /* Repo List */
107 .repolist { margin-left: 2em; font-size: 120%; }
108 .repolist li { margin-bottom: 10px; }
109 .repolist li a { display: block; }
110 .repolist li a .last-updated {
111 display: block;
112 color: #737373;
113 font-size: 60%;
114 margin-left: 1px;
115 }
116 .repolist li a:hover { text-decoration: none; }
117 .repolist li a:hover .name { text-decoration: underline; }
118
119
120 /* Base styles for history and commit views */
121 .commit {
122 background-color: #f9f9f9;
123 padding: 8px 10px;
124 margin-bottom: 2px;
125 display: block;
126 border: 1px solid #e0e0e0;
127 }
128 .commit:hover { text-decoration: none; }
129
130 .commit > span { display: block; }
131
132 .commit .line1 { font-family: monospace; padding-bottom: 2px; }
133 .commit .line1 span { white-space: pre-wrap; text-overflow: hidden; }
134 .commit:hover .line1 { text-decoration: underline; color: #aaa; }
135 .commit:hover .line1 span { color: black; }
136
137 .commit .line2 { position: relative; top: 3px; left: 1px; }
138 .commit .line2 > span:first-child { float: left; }
139 .commit .line2 > span:nth-child(2) { float: right; }
140 .commit .line2 { color: #737373; font-size: 80%; }
141
142
143 /* History View */
144 .history .pagination { margin-top: -2em; }
145 a.commit { color: black !important; }
146
147 .tree ul { font-family: monospace; border-top: 1px solid #e0e0e0; }
148 .tree li { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-top: 0; }
149 .tree li a { padding: 5px 7px 6px 7px; display: block; color: #001533; }
150 .tree li a:before, .diff .filename:before {
151 margin-right: 5px;
152 position: relative;
153 top: 2px;
154 opacity: 0.7;
155 content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAPCAYAAADUFP50AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMmAbS/QqsAAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAANBJREFUKM+Vkj2OgzAQhb8HSLupkKiQD8DPWbZMkSMgLsF9IlLmMpiKA9CncraIQGbXIPGqsec9faOx1TTNwxhz5YT6vr8lxphr13Wc1D1Zqnmecc4BIGl1LLUk4jgmTVMA1qBzDmvtxuhLEnVdr+fEb5ZleUj0lfgGn/hXh8SiKAKEF+/3F1EUhYkA4zhumlVVARfgBXzvjxoiSkK6/Bt9Q7TWHi7lM8HOVsNE7RMlMQxDkLRs078LEkPh3XfMsuzUZ1Xbts88z3/OhKZpuv8CNeMsq6Yg8OoAAAAASUVORK5CYII=);
156 }
157 .tree li a.dir:before {
158 content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMiMxgE1i8AAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAAYxJREFUKM+l0r1KXEEYxvH/bNS4K2RNCjsRBAlqITESI0QCuqBdWLwACSJaiZILiDcQkDRql8oUIqkkkRTRwiKVyvoRV5HFEFEX/DjomePsmfNauB4xrLAmD1PNML95eBnV0Fj/vjPRMcpfMcYAMP9jYXDzV3qSO1LSmegYfdvbV/DQ8zS+709oVzu7u78/FwQAHOeU9Y31gsjz5hYcx5lqbXsxdb23ld4eW15aGQmBaDRGZfzxXS1JvukBQCmFUoqZL9PDIWCMQWuX76tnpLIxisqjJC39SXmoM5thg1Q2xsd3XXjGFmWUlz1g6MPc0xIArV0A9o89dg7PiwJqqyoAiHieRzRaZPUCibiuGzb4J+B6Bv8F3LeBtQFeznLrH5RGAgQQEZRSiAgiEIhgrZCzAcYXLnxLzgrxirIbQGuXmvgFR2eGP0caRBEg5BciIAgieRjwrdwAB9lDnrW9Yjlzkr909boIBAiCApGwVWvdE+a+fkOvzX5STd0D86XV7a/vOzy7t/hzaXb85SVDycBfkNNgmgAAAABJRU5ErkJggg==);
159 }
160
161
162 /* Common for diff and blob view */
163 .line { display: block; }
164 .linenos { background-color: #f9f9f9; text-align: right; }
165 .linenos a { color: #888; }
166 .linenos a:hover { text-decoration: none; }
167 .highlight-line { background-color: #fefed0; }
168 .linenos a { padding: 0 6px 0 6px; }
169
170
171 /* Blob View */
172 .blobview img { max-width: 100%; padding: 1px; }
173 .blobview table, .blobview img { border: 1px solid #e0e0e0; }
174 .blobview .linenos { border: 1px solid #e0e0e0; padding: 0; }
175 .blobview .code { padding: 0; }
176 .blobview .code .line { padding: 0 5px 0 10px; }
177 .blobview .markup { font-family: Georgia, serif; }
178 .blobview .markup pre { background-color: #f9f9f9; padding: 10px 12px; }
179
180
181 /* Commit View */
182 .full-commit { width: 100% !important; margin-top: 10px; }
183
184 .full-commit .commit { padding: 15px 20px; }
185 .full-commit .commit .line1 { padding-bottom: 5px; }
186 .full-commit .commit:hover .line1 { text-decoration: none; }
187 .full-commit .commit .line2 > span:nth-child(2) { float: left; }
188 .full-commit .commit .line2 > span:nth-child(2):before { content: '·'; margin: 0 3px 0 5px; }
189
190 .diff { font-family: monospace; }
191 .diff .filename {
192 background-color: #f9f9f9;
193 padding: 8px 10px;
194 border: 1px solid #e0e0e0;
195 border-bottom: 0;
196 margin-top: 25px;
197 }
198 .diff .filename del { color: #999; }
199 .diff .filename:before { margin-right: 0; opacity: 0.3; }
200 .diff table, .diff .binarydiff {
201 border: 1px solid #e0e0e0;
202 background-color: #fdfdfd;
203 width: 100%;
204 }
205 .diff .binarydiff {
206 padding: 7px 10px;
207 }
208 .diff td {
209 padding: 0;
210 border-left: 1px solid #e0e0e0;
211 }
212 .diff td .line { padding: 1px 10px; display: block; height: 1.2em; }
213 .diff .linenos { font-size: 85%; padding: 0; }
214 .diff .linenos a { display: block; padding-top: 1px; padding-bottom: 1px; }
215 .diff td + td + td { width: 100%; }
216 .diff td + td + td > span { white-space: pre; }
217 .diff tr:first-of-type td { padding-top: 7px; }
218 .diff tr:last-of-type td { padding-bottom: 7px; }
219 .diff table .del { background-color: #ffdddd; }
220 .diff table .add { background-color: #ddffdd; }
221 .diff table del { background-color: #ee9999; text-decoration: none; }
222 .diff table ins { background-color: #99ee99; text-decoration: none; }
223 .diff .sep > td {
224 height: 1.2em;
225 background-color: #f9f9f9;
226 text-align: center;
227 border: 1px solid #e0e0e0;
228 }
229 .diff .sep:hover > td { background-color: #f9f9f9; }
0 var highlight_linenos = function(opts) {
1 var forEach = function(collection, func) {
2 for(var i = 0; i < collection.length; ++i) {
3 func(collection[i]);
4 }
5 }
6
7
8 var links = document.querySelectorAll(opts.linksSelector);
9 currentHash = location.hash;
10
11 forEach(links, function(a) {
12 var lineno = a.getAttribute('href').substr(1),
13 selector = 'a[name="' + lineno + '"]',
14 anchor = document.querySelector(selector),
15 associatedLine = opts.getLineFromAnchor(anchor);
16
17 var highlight = function() {
18 a.className = 'highlight-line';
19 associatedLine.className = 'line highlight-line';
20 currentHighlight = a;
21 }
22
23 var unhighlight = function() {
24 if (a.getAttribute('href') != location.hash) {
25 a.className = '';
26 associatedLine.className = 'line';
27 }
28 }
29
30 a.onmouseover = associatedLine.onmouseover = highlight;
31 a.onmouseout = associatedLine.onmouseout = unhighlight;
32 });
33
34
35 window.onpopstate = function() {
36 if (currentHash) {
37 forEach(document.querySelectorAll('a[href="' + currentHash + '"]'),
38 function(e) { e.onmouseout() })
39 }
40 if (location.hash) {
41 forEach(document.querySelectorAll('a[href="' + location.hash + '"]'),
42 function(e) { e.onmouseover() });
43 currentHash = location.hash;
44 }
45 };
46 }
0 /* This is the Pygments Trac theme */
1 .code .hll { background-color: #ffffcc }
2 .code { background: #ffffff; }
3 .code .c { color: #999988; font-style: italic } /* Comment */
4 .code .err { color: #a61717; background-color: #e3d2d2 } /* Error */
5 .code .k { font-weight: bold } /* Keyword */
6 .code .o { font-weight: bold } /* Operator */
7 .code .cm { color: #999988; font-style: italic } /* Comment.Multiline */
8 .code .cp { color: #999999; font-weight: bold } /* Comment.Preproc */
9 .code .c1 { color: #999988; font-style: italic } /* Comment.Single */
10 .code .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */
11 .code .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
12 .code .ge { font-style: italic } /* Generic.Emph */
13 .code .gr { color: #aa0000 } /* Generic.Error */
14 .code .gh { color: #999999 } /* Generic.Heading */
15 .code .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
16 .code .go { color: #888888 } /* Generic.Output */
17 .code .gp { color: #555555 } /* Generic.Prompt */
18 .code .gs { font-weight: bold } /* Generic.Strong */
19 .code .gu { color: #aaaaaa } /* Generic.Subheading */
20 .code .gt { color: #aa0000 } /* Generic.Traceback */
21 .code .kc { font-weight: bold } /* Keyword.Constant */
22 .code .kd { font-weight: bold } /* Keyword.Declaration */
23 .code .kn { font-weight: bold } /* Keyword.Namespace */
24 .code .kp { font-weight: bold } /* Keyword.Pseudo */
25 .code .kr { font-weight: bold } /* Keyword.Reserved */
26 .code .kt { color: #445588; font-weight: bold } /* Keyword.Type */
27 .code .m { color: #009999 } /* Literal.Number */
28 .code .s { color: #bb8844 } /* Literal.String */
29 .code .na { color: #008080 } /* Name.Attribute */
30 .code .nb { color: #999999 } /* Name.Builtin */
31 .code .nc { color: #445588; font-weight: bold } /* Name.Class */
32 .code .no { color: #008080 } /* Name.Constant */
33 .code .ni { color: #800080 } /* Name.Entity */
34 .code .ne { color: #990000; font-weight: bold } /* Name.Exception */
35 .code .nf { color: #990000; font-weight: bold } /* Name.Function */
36 .code .nn { color: #555555 } /* Name.Namespace */
37 .code .nt { color: #000080 } /* Name.Tag */
38 .code .nv { color: #008080 } /* Name.Variable */
39 .code .ow { font-weight: bold } /* Operator.Word */
40 .code .w { color: #bbbbbb } /* Text.Whitespace */
41 .code .mf { color: #009999 } /* Literal.Number.Float */
42 .code .mh { color: #009999 } /* Literal.Number.Hex */
43 .code .mi { color: #009999 } /* Literal.Number.Integer */
44 .code .mo { color: #009999 } /* Literal.Number.Oct */
45 .code .sb { color: #bb8844 } /* Literal.String.Backtick */
46 .code .sc { color: #bb8844 } /* Literal.String.Char */
47 .code .sd { color: #bb8844 } /* Literal.String.Doc */
48 .code .s2 { color: #bb8844 } /* Literal.String.Double */
49 .code .se { color: #bb8844 } /* Literal.String.Escape */
50 .code .sh { color: #bb8844 } /* Literal.String.Heredoc */
51 .code .si { color: #bb8844 } /* Literal.String.Interpol */
52 .code .sx { color: #bb8844 } /* Literal.String.Other */
53 .code .sr { color: #808000 } /* Literal.String.Regex */
54 .code .s1 { color: #bb8844 } /* Literal.String.Single */
55 .code .ss { color: #bb8844 } /* Literal.String.Symbol */
56 .code .bp { color: #999999 } /* Name.Builtin.Pseudo */
57 .code .vc { color: #008080 } /* Name.Variable.Class */
58 .code .vg { color: #008080 } /* Name.Variable.Global */
59 .code .vi { color: #008080 } /* Name.Variable.Instance */
60 .code .il { color: #009999 } /* Literal.Number.Integer.Long */
0 {% extends 'skeleton.html' %}
1
2 {% block breadcrumbs %}
3 <span>
4 <a href="{{ url_for('history', repo=repo.name) }}">{{ repo.name }}</a>
5 <span class=slash>/</span>
6 <a href="{{ url_for('history', repo=repo.name, commit_id=commit_id) }}">{{ commit_id|shorten_sha1 }}</a>
7 </span>
8
9 {% if subpaths %}
10 <span>
11 {% for name, subpath in subpaths %}
12 {% if loop.last %}
13 <a href="">{{ name|force_unicode }}</a>
14 {% else %}
15 <a href="{{ url_for('history', repo=repo.name, commit_id=commit_id, path=subpath) }}">{{ name|force_unicode }}</a>
16 <span class=slash>/</span>
17 {% endif %}
18 {% endfor %}
19 </span>
20 {% endif %}
21 {% endblock %}
22
23 {% block extra_header %}
24 <div class=branch-selector>
25 <span>{{ commit_id|shorten_sha1 }}</span>
26 <ul>
27 {% for branch in branches %}
28 <li><a href="{{ url_for(view, repo=repo.name, commit_id=branch, path=path) }}">{{ branch }}</a></li>
29 {% endfor %}
30 </ul>
31 </div>
32 {% endblock %}
0 {% extends 'base.html' %}
1 {% block content %}
2
3 {% include 'tree.inc.html' %}
4
5 {# TODO: move logic into view #}
6 {% set history = repo.history(branch, path.strip('/'), history_length+1, skip) %}
7 {% set has_more_commits = history|length == history_length+1 %}
8
9 {% macro pagination() %}
10 <div class=pagination>
11 {% if page %}
12 {% for n in previous_pages %}
13 {% if n is none %}
14 <span class=n>...</span>
15 {% else %}
16 <a href="?page={{n}}" class=n>{{ n }}</a>
17 {% endif %}
18 {% endfor %}
19 {% endif %}
20 {% if has_more_commits %}
21 <a href="?page={{page+1}}">»»</a>
22 {% else %}
23 <span>»»</span>
24 {% endif%}
25 </div>
26 <div class=clearfloat></div>
27 {% endmacro %}
28
29 <div>
30 <div class=history>
31 <h2>
32 {% if subpaths %}
33 History for
34 {% for name, subpath in subpaths %}
35 {{ name }}
36 {% if not loop.last %}
37 <span class=slash>/</span>
38 {% endif %}
39 {% endfor %}
40 {% else %}
41 Commit History
42 {% endif %}
43 <span>
44 @<a href="{{ url_for(view, repo=repo.name, commit_id=branch) }}">{{ branch }}</a>
45 </span>
46 {% if USE_SMARTHTTP %}
47 <code>git clone {{ url_for('history', repo=repo.name, _external=True) }}</code>
48 {% endif %}
49 </h2>
50
51 {{ pagination() }}
52
53 <ul>
54 {% for commit in history %}
55 {% if not loop.last or history|length < history_length %}
56 <li>
57 <a class=commit href="{{ url_for('commit', repo=repo.name, commit_id=commit.id) }}">
58 <span class=line1>
59 <span>{{ commit.message|force_unicode|shorten_message }}</span>
60 </span>
61 <span class=line2>
62 <span>{{ commit.author|force_unicode|extract_author_name }}</span>
63 <span>{{ commit.commit_time|timesince }} ago</span>
64 </span>
65 <span class=clearfloat></span>
66 </a>
67 </li>
68 {% endif %}
69 {% endfor %}
70 </ul>
71 </div>
72
73 {{ pagination() }}
74
75 </div>
76
77 {% endblock %}
0 {% extends 'skeleton.html' %}
1 {% block content %}
2
3 <h2>
4 Repositories
5 <span>
6 (<a href=?by-last-update=yep>order by last update</a>)
7 </span>
8 </h2>
9 <ul class=repolist>
10 {% for repo in repos %}
11 <li>
12 <a href="{{ url_for('history', repo=repo.name) }}">
13 <span class=name>{{ repo.name }}</span>
14 <span class=last-updated>
15 {% set last_updated_at = repo.get_last_updated_at() %}
16 {% if last_updated_at is not none %}
17 last updated {{ last_updated_at|timesince }} ago
18 {% else %}
19 no commits yet
20 {% endif %}
21 </span></a>
22 </li>
23 {% endfor %}
24 </ul>
25
26 {% endblock %}
0 <!doctype html>
1 <meta http-equiv="content-type" content="text/html; charset=utf-8">
2 <link rel="stylesheet" href={{ url_for('static', filename='pygments.css') }}>
3 <link rel="stylesheet" href={{ url_for('static', filename='klaus.css') }}>
4 <script src={{ url_for('static', filename='line-highlighter.js') }}></script>
5
6 <header>
7 <a href={{ url_for('repo_list') }}>
8 {{ SITENAME }}
9 </a>
10 <span class=breadcrumbs>{% block breadcrumbs %}{% endblock %}</span>
11 {% block extra_header %}{% endblock %}
12 </header>
13
14 <div id=content>
15 {% block content %}{% endblock %}
16 </div>
17
18 <footer>
19 powered by <a href="https://github.com/jonashaag/klaus">klaus</a> {{ KLAUS_VERSION }},
20 a simple Git viewer by Jonas Haag
21 </footer>
0 <div class=tree>
1 <h2>Tree @<a href="{{ url_for('commit', repo=repo.name, commit_id=commit_id) }}">{{ commit_id|shorten_sha1 }}</a></h2>
2 <ul>
3 {% for _, name, fullpath in tree.dirs %}
4 <li><a href="{{ url_for('history', repo=repo.name, commit_id=commit_id, path=fullpath) }}" class=dir>{{ name|force_unicode }}</a></li>
5 {% endfor %}
6 {% for _, name, fullpath in tree.files %}
7 <li><a href="{{ url_for('blob', repo=repo.name, commit_id=commit_id, path=fullpath) }}">{{ name|force_unicode }}</a></li>
8 {% endfor %}
9 </ul>
10 </div>
0 {% extends 'base.html' %}
1 {% block content %}
2
3 {% include 'tree.inc.html' %}
4
5 {% set raw_url = url_for('raw', repo=repo.name, commit_id=commit_id, path=path) %}
6
7 <div class=blobview>
8 <h2>
9 {{ filename|force_unicode }}
10 <span>
11 @<a href="{{ url_for('commit', repo=repo.name, commit_id=commit_id) }}">{{ commit_id|shorten_sha1 }}</a>
12 &mdash;
13 {% if is_markup %}
14 {% if render_markup %}
15 <a href="?markup">view markup</a>
16 {% else %}
17 <a href="?">view rendered</a>
18 {% endif %}
19 &middot;
20 {% endif %}
21 <a href="{{ raw_url }}">raw</a>
22 &middot; <a href="{{ url_for('history', repo=repo.name, commit_id=commit_id, path=path) }}">history</a>
23 </span>
24 </h2>
25 {% if is_binary %}
26 {% if is_image %}
27 <a href="{{ raw_url }}"><img src="{{ raw_url }}"></a>
28 {% else %}
29 <div class=binary-warning>(Binary data not shown)</div>
30 {% endif %}
31 {% else %}
32 {% if too_large %}
33 <div class=too-large-warning>(Large file not shown)</div>
34 {% else %}
35 {% autoescape false %}
36 {% if is_markup and render_markup %}
37 <div class=markup>{{ rendered_code }}</div>
38 {% else %}
39 {{ rendered_code }}
40 {% endif %}
41 {% endautoescape %}
42 {% endif %}
43 {% endif %}
44 </div>
45
46 <script>
47 highlight_linenos({
48 linksSelector: '.highlighttable .linenos a',
49 getLineFromAnchor: function(anchor) { return anchor.nextSibling }
50 })
51 </script>
52
53 {% endblock %}
0 {% extends 'base.html' %}
1
2 {% block extra_header %}{% endblock %} {# no branch selector on commits #}
3
4 {% block content %}
5
6 <div class=full-commit>
7
8 <div class=commit>
9 <span class=line1>
10 <span>{{ commit.message|force_unicode }}</span>
11 </span>
12 <span class=line2>
13 <span>{{ commit.author|force_unicode|extract_author_name }}</span>
14 <span>{{ commit.commit_time|timesince }} ago</span>
15 </span>
16 <span class=clearfloat></span>
17 </div>
18
19 <div class=diff>
20 {% for file in repo.commit_diff(commit) %}
21
22 {% set fileno = loop.index0 %}
23
24 <div class=filename>
25 {# TODO dulwich doesn't do rename recognition
26 {% if file.old_filename != file.new_filename %}
27 {{ file.old_filename }} →
28 {% endif %}#}
29 {% if file.new_filename == '/dev/null' %}
30 <del>{{ file.old_filename|force_unicode }}</del>
31 {% else %}
32 <a href="{{ url_for('blob', repo=repo.name, commit_id=commit_id, path=file.new_filename) }}">
33 {{ file.new_filename|force_unicode }}
34 </a>
35 {% endif %}
36 </div>
37
38 {% if file.chunks %}
39
40 <table>
41 {% for chunk in file.chunks %}
42
43 {%- for line in chunk -%}
44 <tr>
45
46 {#- left column: linenos -#}
47 {%- if line.old_lineno -%}
48 <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.old_lineno }}</a></td>
49 {%- if line.new_lineno -%}
50 <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.new_lineno }}</a></td>
51 {%- else -%}
52 <td class=linenos></td>
53 {%- endif -%}
54 {%- else %}
55 {%- if line.old_lineno -%}
56 <td class=linenos><a href="#{{fileno}}-R-{{line.old_lineno}}">{{ line.new_lineno }}</a></td>
57 {%- else -%}
58 <td class=linenos></td>
59 {%- endif -%}
60 <td class=linenos><a href="#{{fileno}}-R-{{line.new_lineno}}">{{ line.new_lineno }}</a></td>
61 {% endif %}
62
63 {#- right column: code -#}
64 <td class={{line.action}}>
65 {#- lineno anchors -#}
66 {%- if line.old_lineno -%}
67 <a name="{{fileno}}-L-{{line.old_lineno}}"></a>
68 {%- else -%}
69 <a name="{{fileno}}-R-{{line.new_lineno}}"></a>
70 {%- endif -%}
71
72 {#- the actual line of code -#}
73 <span class=line>{% autoescape false %}{{ line.line|force_unicode }}{% endautoescape %}</span>
74 </td>
75
76 </tr>
77 {%- endfor -%} {# lines #}
78
79 {% if not loop.last %}
80 <tr class=sep>
81 <td colspan=3></td>
82 </tr>
83 {% endif %}
84
85 {%- endfor -%} {# chunks #}
86 </table>
87
88 {% else %}
89 <div class=binarydiff>Binary diff not shown</div>
90 {% endif %}
91
92 {% endfor %}
93 </div>
94
95 </div>
96
97 <script>
98 highlight_linenos({
99 linksSelector: '.linenos a',
100 getLineFromAnchor: function(anchor) {
101 /* If we got the first (old_lineno) anchor, the span we're looking for is
102 the second-next sibling, otherwise it's the next. */
103 if (anchor.nextSibling instanceof HTMLSpanElement)
104 return anchor.nextSibling;
105 else
106 return anchor.nextSibling.nextSibling;
107 }
108 });
109 </script>
110
111 {% endblock %}
0 # encoding: utf-8
1
2 import re
3 import time
4 import mimetypes
5
6 from pygments import highlight
7 from pygments.lexers import get_lexer_for_filename, guess_lexer, ClassNotFound
8 from pygments.formatters import HtmlFormatter
9
10 from klaus import markup
11
12
13 class SubUri(object):
14 """
15 WSGI middleware that tweaks the WSGI environ so that it's possible to serve
16 the wrapped app (klaus) under a sub-URL and/or to use a different HTTP
17 scheme (http:// vs. https://) for proxy communication.
18
19 This is done by making your proxy pass appropriate HTTP_X_SCRIPT_NAME and
20 HTTP_X_SCHEME headers.
21
22 For instance if you have klaus mounted under /git/ and your site uses SSL
23 (but your proxy doesn't), make it pass ::
24
25 X-Script-Name = '/git'
26 X-Scheme = 'https'
27
28 Snippet stolen from http://flask.pocoo.org/snippets/35/
29 """
30 def __init__(self, app):
31 self.app = app
32
33 def __call__(self, environ, start_response):
34 script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
35 if script_name:
36 environ['SCRIPT_NAME'] = script_name.rstrip('/')
37
38 if script_name and environ['PATH_INFO'].startswith(script_name):
39 # strip `script_name` from PATH_INFO
40 environ['PATH_INFO'] = environ['PATH_INFO'][len(script_name):]
41
42 if 'HTTP_X_SCHEME' in environ:
43 environ['wsgi.url_scheme'] = environ['HTTP_X_SCHEME']
44
45 return self.app(environ, start_response)
46
47
48 class KlausFormatter(HtmlFormatter):
49 def __init__(self):
50 HtmlFormatter.__init__(self, linenos='table', lineanchors='L',
51 anchorlinenos=True)
52
53 def _format_lines(self, tokensource):
54 for tag, line in HtmlFormatter._format_lines(self, tokensource):
55 if tag == 1:
56 # sourcecode line
57 line = '<span class=line>%s</span>' % line
58 yield tag, line
59
60
61 def pygmentize(code, filename=None, render_markup=True):
62 """
63 Renders code using Pygments, markup (markdown, rst, ...) using the
64 corresponding renderer, if available.
65 """
66 if render_markup and markup.can_render(filename):
67 return markup.render(filename, code)
68
69 try:
70 lexer = get_lexer_for_filename(filename)
71 except ClassNotFound:
72 lexer = guess_lexer(code)
73
74 return highlight(code, lexer, KlausFormatter())
75
76
77 def timesince(when, now=time.time):
78 """ Returns the difference between `when` and `now` in human readable form. """
79 # TODO: rewrite this mess
80 delta = now() - when
81 result = []
82 break_next = False
83 for unit, seconds, break_immediately in [
84 ('year', 365*24*60*60, False),
85 ('month', 30*24*60*60, False),
86 ('week', 7*24*60*60, False),
87 ('day', 24*60*60, True),
88 ('hour', 60*60, False),
89 ('minute', 60, True),
90 ('second', 1, False),
91 ]:
92 if delta > seconds:
93 n = int(delta/seconds)
94 delta -= n*seconds
95 result.append((n, unit))
96 if break_immediately:
97 break
98 if not break_next:
99 break_next = True
100 continue
101 if break_next:
102 break
103
104 if len(result) > 1:
105 n, unit = result[0]
106 if unit == 'month':
107 if n == 1:
108 # 1 month, 3 weeks --> 7 weeks
109 result = [(result[1][0] + 4, 'week')]
110 else:
111 # 2 months, 1 week -> 2 months
112 result = result[:1]
113 elif unit == 'hour' and n > 5:
114 result = result[:1]
115
116 return ', '.join('%d %s%s' % (n, unit, 's' if n != 1 else '')
117 for n, unit in result[:2])
118
119
120 def guess_is_binary(dulwich_blob):
121 return any('\0' in chunk for chunk in dulwich_blob.chunked)
122
123
124 def guess_is_image(filename):
125 mime, _ = mimetypes.guess_type(filename)
126 if mime is None:
127 return False
128 return mime.startswith('image/')
129
130
131 def force_unicode(s):
132 """ Does all kind of magic to turn `s` into unicode """
133 if isinstance(s, unicode):
134 return s
135 try:
136 return s.decode('utf-8')
137 except UnicodeDecodeError as exc:
138 pass
139 try:
140 return s.decode('iso-8859-1')
141 except UnicodeDecodeError:
142 pass
143 try:
144 import chardet
145 encoding = chardet.detect(s)['encoding']
146 if encoding is not None:
147 return s.decode(encoding)
148 except (ImportError, UnicodeDecodeError):
149 raise exc
150
151
152 def extract_author_name(email):
153 """
154 Extracts the name from an email address...
155 >>> extract_author_name("John <john@example.com>")
156 "John"
157
158 ... or returns the address if none is given.
159 >>> extract_author_name("noname@example.com")
160 "noname@example.com"
161 """
162 match = re.match('^(.*?)<.*?>$', email)
163 if match:
164 return match.group(1).strip()
165 return email
166
167
168 def shorten_sha1(sha1):
169 if re.match('[a-z\d]{20,40}', sha1):
170 sha1 = sha1[:10]
171 return sha1
172
173
174 def subpaths(path):
175 """
176 Yields a `(last part, subpath)` tuple for all possible sub-paths of `path`.
177
178 >>> list(subpaths("foo/bar/spam"))
179 [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')]
180 """
181 seen = []
182 for part in path.split('/'):
183 seen.append(part)
184 yield part, '/'.join(seen)
185
186
187 def shorten_message(msg):
188 return msg.split('\n')[0]
189
190
191 def get_mimetype_and_encoding(blob, filename):
192 mime, encoding = mimetypes.guess_type(filename)
193 if mime and mime.startswith('text/'):
194 mime = 'text/plain'
195 return mime, encoding
196
197
198 try:
199 from subprocess import check_output
200 except ImportError:
201 # Python < 2.7 fallback, stolen from the 2.7 stdlib
202 def check_output(*popenargs, **kwargs):
203 from subprocess import Popen, PIPE, CalledProcessError
204 if 'stdout' in kwargs:
205 raise ValueError('stdout argument not allowed, it will be overridden.')
206 process = Popen(stdout=PIPE, *popenargs, **kwargs)
207 output, _ = process.communicate()
208 retcode = process.poll()
209 if retcode:
210 cmd = kwargs.get("args")
211 if cmd is None:
212 cmd = popenargs[0]
213 raise CalledProcessError(retcode, cmd, output=output)
214 return output
0 import os
1 import stat
2
3 from flask import request, render_template, current_app
4 from flask.views import View
5
6 from werkzeug.wrappers import Response
7 from werkzeug.exceptions import NotFound
8
9 from klaus import markup
10 from klaus.utils import subpaths, get_mimetype_and_encoding, pygmentize, \
11 force_unicode, guess_is_binary, guess_is_image
12
13
14 def repo_list():
15 """Shows a list of all repos and can be sorted by last update. """
16 if 'by-last-update' in request.args:
17 sort_key = lambda repo: repo.get_last_updated_at()
18 reverse = True
19 else:
20 sort_key = lambda repo: repo.name
21 reverse = False
22 repos = sorted(current_app.repos, key=sort_key, reverse=reverse)
23 return render_template('repo_list.html', repos=repos)
24
25
26 class BaseRepoView(View):
27 """
28 Base for all views with a repo context.
29
30 The arguments `repo`, `commit_id`, `path` (see `dispatch_request`) define
31 the repository, branch/commit and directory/file context, respectively --
32 that is, they specify what (and in what state) is being displayed in all the
33 derived views.
34
35 For example: The 'history' view is the `git log` equivalent, i.e. if `path`
36 is "/foo/bar", only commits related to "/foo/bar" are displayed, and if
37 `commit_id` is "master", the history of the "master" branch is displayed.
38 """
39 def __init__(self, view_name, template_name=None):
40 self.view_name = view_name
41 self.template_name = template_name
42 self.context = {}
43
44 def dispatch_request(self, repo, commit_id=None, path=''):
45 self.make_context(repo, commit_id, path)
46 return self.get_response()
47
48 def get_response(self):
49 return render_template(self.template_name, **self.context)
50
51 def make_context(self, repo, commit_id, path):
52 try:
53 repo = current_app.repo_map[repo]
54 if commit_id is None:
55 commit_id = repo.get_default_branch()
56 commit, isbranch = repo.get_branch_or_commit(commit_id)
57 except KeyError:
58 raise NotFound
59
60 self.context = {
61 'view': self.view_name,
62 'repo': repo,
63 'commit_id': commit_id,
64 'commit': commit,
65 'branch': commit_id if isbranch else 'master',
66 'branches': repo.get_branch_names(exclude=[commit_id]),
67 'path': path,
68 'subpaths': subpaths(path) if path else None,
69 }
70
71
72 class TreeViewMixin(object):
73 """
74 Implements the logic required for displaying the current directory in the sidebar
75 """
76 def make_context(self, *args):
77 super(TreeViewMixin, self).make_context(*args)
78 self.context['tree'] = self.listdir()
79
80 def listdir(self):
81 """
82 Returns a list of directories and files in the current path of the
83 selected commit
84 """
85 dirs, files = [], []
86 root = self.get_directory()
87 try:
88 tree = self.context['repo'].get_tree(self.context['commit'], root)
89 except KeyError:
90 raise NotFound
91
92 for entry in tree.iteritems():
93 name, entry = entry.path, entry.in_path(root)
94 if entry.mode & stat.S_IFDIR:
95 dirs.append((name.lower(), name, entry.path))
96 else:
97 files.append((name.lower(), name, entry.path))
98
99 files.sort()
100 dirs.sort()
101
102 if root:
103 dirs.insert(0, (None, '..', os.path.split(root)[0]))
104
105 return {'dirs' : dirs, 'files' : files}
106
107 def get_directory(self):
108 return self.context['path']
109
110
111 class HistoryView(TreeViewMixin, BaseRepoView):
112 """ Show commits of a branch + path, just like `git log`. With pagination. """
113 def make_context(self, *args):
114 super(HistoryView, self).make_context(*args)
115
116 try:
117 page = int(request.args.get('page'))
118 except (TypeError, ValueError):
119 page = 0
120
121 self.context['page'] = page
122
123 if page:
124 self.context['history_length'] = 30
125 self.context['skip'] = (self.context['page']-1) * 30 + 10
126 if page > 7:
127 self.context['previous_pages'] = [0, 1, 2, None] + range(page)[-3:]
128 else:
129 self.context['previous_pages'] = xrange(page)
130 else:
131 self.context['history_length'] = 10
132 self.context['skip'] = 0
133
134
135 class BlobViewMixin(object):
136 def make_context(self, *args):
137 super(BlobViewMixin, self).make_context(*args)
138 self.context['filename'] = os.path.basename(self.context['path'])
139 self.context['blob'] = self.context['repo'].get_tree(self.context['commit'],
140 self.context['path'])
141
142
143 class BlobView(BlobViewMixin, TreeViewMixin, BaseRepoView):
144 """ Shows a file rendered using ``pygmentize`` """
145 def make_context(self, *args):
146 super(BlobView, self).make_context(*args)
147 render_markup = 'markup' not in request.args
148 rendered_code = pygmentize(
149 force_unicode(self.context['blob'].data),
150 self.context['filename'],
151 render_markup
152 )
153 self.context.update({
154 'too_large': sum(map(len, self.context['blob'].chunked)) > 100*1024,
155 'is_markup': markup.can_render(self.context['filename']),
156 'render_markup': render_markup,
157 'rendered_code': rendered_code,
158 'is_binary': guess_is_binary(self.context['blob']),
159 'is_image': guess_is_image(self.context['filename']),
160 })
161
162 def get_directory(self):
163 return os.path.split(self.context['path'])[0]
164
165
166 class RawView(BlobViewMixin, BaseRepoView):
167 """
168 Shows a single file in raw for (as if it were a normal filesystem file
169 served through a static file server)
170 """
171 def get_response(self):
172 chunks = self.context['blob'].chunked
173
174 if len(chunks) == 1 and not chunks[0]:
175 # empty file
176 chunks = []
177 mime = 'text/plain'
178 encoding = 'utf-8'
179 else:
180 mime, encoding = get_mimetype_and_encoding(chunks, self.context['filename'])
181
182 headers = {'Content-Type': mime}
183 if encoding:
184 headers['Content-Encoding'] = encoding
185
186 return Response(chunks, headers=headers)
187
188
189 # TODO v
190 history = HistoryView.as_view('history', 'history', 'history.html')
191 commit = BaseRepoView.as_view('commit', 'commit', 'view_commit.html')
192 blob = BlobView.as_view('blob', 'blob', 'view_blob.html')
193 raw = RawView.as_view('raw', 'raw')
0 import os
1 from klaus import make_app
2
3 application = make_app(
4 os.environ['KLAUS_REPOS'].split(),
5 os.environ['KLAUS_SITE_TITLE'],
6 os.environ.get('KLAUS_USE_SMARTHTTP'),
7 os.environ.get('KLAUS_HTDIGEST_FILE'),
8 )
+0
-426
klaus.py less more
0 import sys
1 import os
2 import re
3 import stat
4 import time
5 import urlparse
6 import mimetypes
7 from future_builtins import map
8 from functools import wraps
9
10 from dulwich.objects import Commit, Blob
11
12 from jinja2 import Environment, FileSystemLoader
13
14 from pygments import highlight
15 from pygments.lexers import get_lexer_for_filename, get_lexer_by_name, \
16 guess_lexer, ClassNotFound
17 from pygments.formatters import HtmlFormatter
18
19 from nano import NanoApplication, HttpError
20 from repo import Repo
21
22
23 KLAUS_ROOT = os.path.join(os.path.dirname(__file__))
24 TEMPLATE_DIR = os.path.join(KLAUS_ROOT, 'templates')
25
26 try:
27 KLAUS_VERSION = ' ' + open(os.path.join(KLAUS_ROOT, '.git/refs/heads/master')).read()[:7]
28 except IOError:
29 KLAUS_VERSION = ''
30
31
32 def query_string_to_dict(query_string):
33 """ Transforms a POST/GET string into a Python dict """
34 return dict((k, v[0]) for k, v in urlparse.parse_qs(query_string).iteritems())
35
36 class KlausApplication(NanoApplication):
37 def __init__(self, *args, **kwargs):
38 super(KlausApplication, self).__init__(*args, **kwargs)
39 self.jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR),
40 extensions=['jinja2.ext.autoescape'],
41 autoescape=True)
42 self.jinja_env.globals['build_url'] = self.build_url
43 self.jinja_env.globals['KLAUS_VERSION'] = KLAUS_VERSION
44
45 def route(self, pattern):
46 """
47 Extends `NanoApplication.route` by multiple features:
48
49 - Overrides the WSGI `HTTP_HOST` by `self.custom_host` (if set)
50 - Tries to use the keyword arguments returned by the view function
51 to render the template called `<class>.html` (<class> being the
52 name of `self`'s class). Raising `Response` can be used to skip
53 this behaviour, directly returning information to Nano.
54 """
55 super_decorator = super(KlausApplication, self).route(pattern)
56 def decorator(callback):
57 @wraps(callback)
58 def wrapper(env, **kwargs):
59 if hasattr(self, 'custom_host'):
60 env['HTTP_HOST'] = self.custom_host
61 try:
62 return self.render_template(callback.__name__ + '.html',
63 **callback(env, **kwargs))
64 except Response as e:
65 if len(e.args) == 1:
66 return e.args[0]
67 return e.args
68 return super_decorator(wrapper)
69 return decorator
70
71 def render_template(self, template_name, **kwargs):
72 return self.jinja_env.get_template(template_name).render(**kwargs)
73
74 app = application = KlausApplication(debug=True, default_content_type='text/html')
75 # KLAUS_REPOS=/foo/bar/,/spam.git/ --> {'bar': '/foo/bar/', 'spam': '/spam/'}
76 app.repos = dict(
77 (repo.rstrip(os.sep).split(os.sep)[-1].replace('.git', ''), repo)
78 for repo in (sys.argv[1:] or os.environ.get('KLAUS_REPOS', '').split())
79 )
80
81 def pygmentize(code, filename=None, language=None):
82 if language:
83 lexer = get_lexer_by_name(language)
84 else:
85 try:
86 lexer = get_lexer_for_filename(filename)
87 except ClassNotFound:
88 lexer = guess_lexer(code)
89
90 return highlight(code, lexer, KlausFormatter())
91
92
93 class KlausFormatter(HtmlFormatter):
94 def __init__(self):
95 HtmlFormatter.__init__(self, linenos='table', lineanchors='L',
96 anchorlinenos=True)
97
98 def _format_lines(self, tokensource):
99 for tag, line in HtmlFormatter._format_lines(self, tokensource):
100 if tag == 1:
101 # sourcecode line
102 line = '<span class=line>%s</span>' % line
103 yield tag, line
104
105
106 def timesince(when, now=time.time):
107 """ Returns the difference between `when` and `now` in human readable form. """
108 delta = now() - when
109 result = []
110 break_next = False
111 for unit, seconds, break_immediately in [
112 ('year', 365*24*60*60, False),
113 ('month', 30*24*60*60, False),
114 ('week', 7*24*60*60, False),
115 ('day', 24*60*60, True),
116 ('hour', 60*60, False),
117 ('minute', 60, True),
118 ('second', 1, False),
119 ]:
120 if delta > seconds:
121 n = int(delta/seconds)
122 delta -= n*seconds
123 result.append((n, unit))
124 if break_immediately:
125 break
126 if not break_next:
127 break_next = True
128 continue
129 if break_next:
130 break
131
132 if len(result) > 1:
133 n, unit = result[0]
134 if unit == 'month':
135 if n == 1:
136 # 1 month, 3 weeks --> 7 weeks
137 result = [(result[1][0] + 4, 'week')]
138 else:
139 # 2 months, 1 week -> 2 months
140 result = result[:1]
141 elif unit == 'hour' and n > 5:
142 result = result[:1]
143
144 return ', '.join('%d %s%s' % (n, unit, 's' if n != 1 else '')
145 for n, unit in result[:2])
146
147 def guess_is_binary(data):
148 if isinstance(data, basestring):
149 return '\0' in data
150 else:
151 return any(map(guess_is_binary, data))
152
153 def guess_is_image(filename):
154 mime, encoding = mimetypes.guess_type(filename)
155 if mime is None:
156 return False
157 return mime.startswith('image/')
158
159 def force_unicode(s):
160 """ Does all kind of magic to turn `s` into unicode """
161 if isinstance(s, unicode):
162 return s
163 try:
164 return s.decode('utf-8')
165 except UnicodeDecodeError as exc:
166 pass
167 try:
168 return s.decode('iso-8859-1')
169 except UnicodeDecodeError:
170 pass
171 try:
172 import chardet
173 encoding = chardet.detect(s)['encoding']
174 if encoding is not None:
175 return s.decode(encoding)
176 except (ImportError, UnicodeDecodeError):
177 raise exc
178
179 def extract_author_name(email):
180 """
181 Extracts the name from an email address...
182 >>> extract_author_name("John <john@example.com>")
183 "John"
184
185 ... or returns the address if none is given.
186 >>> extract_author_name("noname@example.com")
187 "noname@example.com"
188 """
189 match = re.match('^(.*?)<.*?>$', email)
190 if match:
191 return match.group(1).strip()
192 return email
193
194 def shorten_sha1(sha1):
195 if re.match('[a-z\d]{20,40}', sha1):
196 sha1 = sha1[:10]
197 return sha1
198
199 app.jinja_env.filters['u'] = force_unicode
200 app.jinja_env.filters['timesince'] = timesince
201 app.jinja_env.filters['shorten_sha1'] = shorten_sha1
202 app.jinja_env.filters['shorten_message'] = lambda msg: msg.split('\n')[0]
203 app.jinja_env.filters['pygmentize'] = pygmentize
204 app.jinja_env.filters['is_binary'] = guess_is_binary
205 app.jinja_env.filters['is_image'] = guess_is_image
206 app.jinja_env.filters['shorten_author'] = extract_author_name
207
208 def subpaths(path):
209 """
210 Yields a `(last part, subpath)` tuple for all possible sub-paths of `path`.
211
212 >>> list(subpaths("foo/bar/spam"))
213 [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')]
214 """
215 seen = []
216 for part in path.split('/'):
217 seen.append(part)
218 yield part, '/'.join(seen)
219
220 def get_repo(name):
221 try:
222 return Repo(name, app.repos[name])
223 except KeyError:
224 raise HttpError(404, 'No repository named "%s"' % name)
225
226 class Response(Exception):
227 pass
228
229 class BaseView(dict):
230 def __init__(self, env):
231 dict.__init__(self)
232 self['environ'] = env
233 self.GET = query_string_to_dict(env.get('QUERY_STRING', ''))
234 self.view()
235
236 def direct_response(self, *args):
237 raise Response(*args)
238
239 def route(pattern, name=None):
240 def decorator(cls):
241 cls.__name__ = name or cls.__name__.lower()
242 app.route(pattern)(cls)
243 return cls
244 return decorator
245
246 @route('/', 'repo_list')
247 class RepoList(BaseView):
248 """ Shows a list of all repos and can be sorted by last update. """
249 def view(self):
250 self['repos'] = repos = []
251 for name in app.repos.iterkeys():
252 repo = get_repo(name)
253 refs = [repo[ref_hash] for ref_hash in repo.get_refs().itervalues()]
254 refs.sort(key=lambda obj:getattr(obj, 'commit_time', None),
255 reverse=True)
256 last_updated_at = None
257 if refs:
258 last_updated_at = refs[0].commit_time
259 repos.append((name, last_updated_at))
260 if 'by-last-update' in self.GET:
261 repos.sort(key=lambda x: x[1], reverse=True)
262 else:
263 repos.sort(key=lambda x: x[0])
264
265 class BaseRepoView(BaseView):
266 def __init__(self, env, repo, commit_id, path=None):
267 self['repo'] = repo = get_repo(repo)
268 self['commit_id'] = commit_id
269 self['commit'], isbranch = self.get_commit(repo, commit_id)
270 self['branch'] = commit_id if isbranch else 'master'
271 self['branches'] = repo.get_branch_names(exclude=[commit_id])
272 self['path'] = path
273 if path:
274 self['subpaths'] = list(subpaths(path))
275 self['build_url'] = self.build_url
276 super(BaseRepoView, self).__init__(env)
277
278 def get_commit(self, repo, id):
279 try:
280 commit, isbranch = repo.get_branch_or_commit(id)
281 if not isinstance(commit, Commit):
282 raise KeyError
283 except KeyError:
284 raise HttpError(404, '"%s" has no commit "%s"' % (repo.name, id))
285 return commit, isbranch
286
287 def build_url(self, view=None, **kwargs):
288 """ Builds url relative to the current repo + commit """
289 if view is None:
290 view = self.__class__.__name__
291 default_kwargs = {
292 'repo': self['repo'].name,
293 'commit_id': self['commit_id']
294 }
295 if view == 'history' and kwargs.get('path') is None:
296 kwargs['path'] = ''
297 return app.build_url(view, **dict(default_kwargs, **kwargs))
298
299
300 class TreeViewMixin(object):
301 def view(self):
302 self['tree'] = self.listdir()
303
304 def listdir(self):
305 """
306 Returns a list of directories and files in the current path of the
307 selected commit
308 """
309 dirs, files = [], []
310 tree, root = self.get_tree()
311 for entry in tree.iteritems():
312 name, entry = entry.path, entry.in_path(root)
313 if entry.mode & stat.S_IFDIR:
314 dirs.append((name.lower(), name, entry.path))
315 else:
316 files.append((name.lower(), name, entry.path))
317 files.sort()
318 dirs.sort()
319 if root:
320 dirs.insert(0, (None, '..', os.path.split(root)[0]))
321 return {'dirs' : dirs, 'files' : files}
322
323 def get_tree(self):
324 """ Gets the Git tree of the selected commit and path """
325 root = self['path']
326 tree = self['repo'].get_tree(self['commit'], root)
327 if isinstance(tree, Blob):
328 root = os.path.split(root)[0]
329 tree = self['repo'].get_tree(self['commit'], root)
330 return tree, root
331
332 @route('/:repo:/tree/:commit_id:/(?P<path>.*)', 'history')
333 class TreeView(TreeViewMixin, BaseRepoView):
334 """
335 Shows a list of files/directories for the current path as well as all
336 commit history for that path in a paginated form.
337 """
338 def view(self):
339 super(TreeView, self).view()
340 try:
341 page = int(self.GET.get('page'))
342 except (TypeError, ValueError):
343 page = 0
344
345 self['page'] = page
346
347 if page:
348 self['history_length'] = 30
349 self['skip'] = (self['page']-1) * 30 + 10
350 if page > 7:
351 self['previous_pages'] = [0, 1, 2, None] + range(page)[-3:]
352 else:
353 self['previous_pages'] = xrange(page)
354 else:
355 self['history_length'] = 10
356 self['skip'] = 0
357
358 class BaseBlobView(BaseRepoView):
359 def view(self):
360 self['blob'] = self['repo'].get_tree(self['commit'], self['path'])
361 self['directory'], self['filename'] = os.path.split(self['path'].strip('/'))
362
363 @route('/:repo:/blob/:commit_id:/(?P<path>.*)', 'view_blob')
364 class BlobView(BaseBlobView, TreeViewMixin):
365 """ Shows a single file, syntax highlighted """
366 def view(self):
367 BaseBlobView.view(self)
368 TreeViewMixin.view(self)
369 self['raw_url'] = self.build_url('raw_blob', path=self['path'])
370 self['too_large'] = sum(map(len, self['blob'].chunked)) > 100*1024
371
372
373 @route('/:repo:/raw/:commit_id:/(?P<path>.*)', 'raw_blob')
374 class RawBlob(BaseBlobView):
375 """
376 Shows a single file in raw form
377 (as if it were a normal filesystem file served through a static file server)
378 """
379 def view(self):
380 super(RawBlob, self).view()
381 mime, encoding = self.get_mimetype_and_encoding()
382 headers = {'Content-Type': mime}
383 if encoding:
384 headers['Content-Encoding'] = encoding
385 body = self['blob'].chunked
386 if len(body) == 1 and not body[0]:
387 body = []
388 self.direct_response('200 yo', headers, body)
389
390
391 def get_mimetype_and_encoding(self):
392 if guess_is_binary(self['blob'].chunked):
393 mime, encoding = mimetypes.guess_type(self['filename'])
394 if mime is None:
395 mime = 'application/octet-stream'
396 return mime, encoding
397 else:
398 return 'text/plain', 'utf-8'
399
400
401 @route('/:repo:/commit/:commit_id:/', 'view_commit')
402 class CommitView(BaseRepoView):
403 """ Shows a single commit diff """
404 def view(self):
405 pass
406
407
408 @route('/static/(?P<path>.+)', 'static')
409 class StaticFilesView(BaseView):
410 """
411 Serves assets (everything under /static/).
412
413 Don't use this in production! Use a static file server instead.
414 """
415 def __init__(self, env, path):
416 self['path'] = path
417 super(StaticFilesView, self).__init__(env)
418
419 def view(self):
420 path = './static/' + self['path']
421 relpath = os.path.join(KLAUS_ROOT, path)
422 if os.path.isfile(relpath):
423 self.direct_response(open(relpath))
424 else:
425 raise HttpError(404, 'Not Found')
+0
-426
nano less more
0 import sys
1 import os
2 import re
3 import stat
4 import time
5 import urlparse
6 import mimetypes
7 from future_builtins import map
8 from functools import wraps
9
10 from dulwich.objects import Commit, Blob
11
12 from jinja2 import Environment, FileSystemLoader
13
14 from pygments import highlight
15 from pygments.lexers import get_lexer_for_filename, get_lexer_by_name, \
16 guess_lexer, ClassNotFound
17 from pygments.formatters import HtmlFormatter
18
19 from nano import NanoApplication, HttpError
20 from repo import Repo
21
22
23 KLAUS_ROOT = os.path.join(os.path.dirname(__file__))
24 TEMPLATE_DIR = os.path.join(KLAUS_ROOT, 'templates')
25
26 try:
27 KLAUS_VERSION = ' ' + open(os.path.join(KLAUS_ROOT, '.git/refs/heads/master')).read()[:7]
28 except IOError:
29 KLAUS_VERSION = ''
30
31
32 def query_string_to_dict(query_string):
33 """ Transforms a POST/GET string into a Python dict """
34 return dict((k, v[0]) for k, v in urlparse.parse_qs(query_string).iteritems())
35
36 class KlausApplication(NanoApplication):
37 def __init__(self, *args, **kwargs):
38 super(KlausApplication, self).__init__(*args, **kwargs)
39 self.jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR),
40 extensions=['jinja2.ext.autoescape'],
41 autoescape=True)
42 self.jinja_env.globals['build_url'] = self.build_url
43 self.jinja_env.globals['KLAUS_VERSION'] = KLAUS_VERSION
44
45 def route(self, pattern):
46 """
47 Extends `NanoApplication.route` by multiple features:
48
49 - Overrides the WSGI `HTTP_HOST` by `self.custom_host` (if set)
50 - Tries to use the keyword arguments returned by the view function
51 to render the template called `<class>.html` (<class> being the
52 name of `self`'s class). Raising `Response` can be used to skip
53 this behaviour, directly returning information to Nano.
54 """
55 super_decorator = super(KlausApplication, self).route(pattern)
56 def decorator(callback):
57 @wraps(callback)
58 def wrapper(env, **kwargs):
59 if hasattr(self, 'custom_host'):
60 env['HTTP_HOST'] = self.custom_host
61 try:
62 return self.render_template(callback.__name__ + '.html',
63 **callback(env, **kwargs))
64 except Response as e:
65 if len(e.args) == 1:
66 return e.args[0]
67 return e.args
68 return super_decorator(wrapper)
69 return decorator
70
71 def render_template(self, template_name, **kwargs):
72 return self.jinja_env.get_template(template_name).render(**kwargs)
73
74 app = application = KlausApplication(debug=True, default_content_type='text/html')
75 # KLAUS_REPOS=/foo/bar/,/spam.git/ --> {'bar': '/foo/bar/', 'spam': '/spam/'}
76 app.repos = dict(
77 (repo.rstrip(os.sep).split(os.sep)[-1].replace('.git', ''), repo)
78 for repo in (sys.argv[1:] or os.environ.get('KLAUS_REPOS', '').split())
79 )
80
81 def pygmentize(code, filename=None, language=None):
82 if language:
83 lexer = get_lexer_by_name(language)
84 else:
85 try:
86 lexer = get_lexer_for_filename(filename)
87 except ClassNotFound:
88 lexer = guess_lexer(code)
89
90 return highlight(code, lexer, KlausFormatter())
91
92
93 class KlausFormatter(HtmlFormatter):
94 def __init__(self):
95 HtmlFormatter.__init__(self, linenos='table', lineanchors='L',
96 anchorlinenos=True)
97
98 def _format_lines(self, tokensource):
99 for tag, line in HtmlFormatter._format_lines(self, tokensource):
100 if tag == 1:
101 # sourcecode line
102 line = '<span class=line>%s</span>' % line
103 yield tag, line
104
105
106 def timesince(when, now=time.time):
107 """ Returns the difference between `when` and `now` in human readable form. """
108 delta = now() - when
109 result = []
110 break_next = False
111 for unit, seconds, break_immediately in [
112 ('year', 365*24*60*60, False),
113 ('month', 30*24*60*60, False),
114 ('week', 7*24*60*60, False),
115 ('day', 24*60*60, True),
116 ('hour', 60*60, False),
117 ('minute', 60, True),
118 ('second', 1, False),
119 ]:
120 if delta > seconds:
121 n = int(delta/seconds)
122 delta -= n*seconds
123 result.append((n, unit))
124 if break_immediately:
125 break
126 if not break_next:
127 break_next = True
128 continue
129 if break_next:
130 break
131
132 if len(result) > 1:
133 n, unit = result[0]
134 if unit == 'month':
135 if n == 1:
136 # 1 month, 3 weeks --> 7 weeks
137 result = [(result[1][0] + 4, 'week')]
138 else:
139 # 2 months, 1 week -> 2 months
140 result = result[:1]
141 elif unit == 'hour' and n > 5:
142 result = result[:1]
143
144 return ', '.join('%d %s%s' % (n, unit, 's' if n != 1 else '')
145 for n, unit in result[:2])
146
147 def guess_is_binary(data):
148 if isinstance(data, basestring):
149 return '\0' in data
150 else:
151 return any(map(guess_is_binary, data))
152
153 def guess_is_image(filename):
154 mime, encoding = mimetypes.guess_type(filename)
155 if mime is None:
156 return False
157 return mime.startswith('image/')
158
159 def force_unicode(s):
160 """ Does all kind of magic to turn `s` into unicode """
161 if isinstance(s, unicode):
162 return s
163 try:
164 return s.decode('utf-8')
165 except UnicodeDecodeError as exc:
166 pass
167 try:
168 return s.decode('iso-8859-1')
169 except UnicodeDecodeError:
170 pass
171 try:
172 import chardet
173 encoding = chardet.detect(s)['encoding']
174 if encoding is not None:
175 return s.decode(encoding)
176 except (ImportError, UnicodeDecodeError):
177 raise exc
178
179 def extract_author_name(email):
180 """
181 Extracts the name from an email address...
182 >>> extract_author_name("John <john@example.com>")
183 "John"
184
185 ... or returns the address if none is given.
186 >>> extract_author_name("noname@example.com")
187 "noname@example.com"
188 """
189 match = re.match('^(.*?)<.*?>$', email)
190 if match:
191 return match.group(1).strip()
192 return email
193
194 def shorten_sha1(sha1):
195 if re.match('[a-z\d]{20,40}', sha1):
196 sha1 = sha1[:10]
197 return sha1
198
199 app.jinja_env.filters['u'] = force_unicode
200 app.jinja_env.filters['timesince'] = timesince
201 app.jinja_env.filters['shorten_sha1'] = shorten_sha1
202 app.jinja_env.filters['shorten_message'] = lambda msg: msg.split('\n')[0]
203 app.jinja_env.filters['pygmentize'] = pygmentize
204 app.jinja_env.filters['is_binary'] = guess_is_binary
205 app.jinja_env.filters['is_image'] = guess_is_image
206 app.jinja_env.filters['shorten_author'] = extract_author_name
207
208 def subpaths(path):
209 """
210 Yields a `(last part, subpath)` tuple for all possible sub-paths of `path`.
211
212 >>> list(subpaths("foo/bar/spam"))
213 [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')]
214 """
215 seen = []
216 for part in path.split('/'):
217 seen.append(part)
218 yield part, '/'.join(seen)
219
220 def get_repo(name):
221 try:
222 return Repo(name, app.repos[name])
223 except KeyError:
224 raise HttpError(404, 'No repository named "%s"' % name)
225
226 class Response(Exception):
227 pass
228
229 class BaseView(dict):
230 def __init__(self, env):
231 dict.__init__(self)
232 self['environ'] = env
233 self.GET = query_string_to_dict(env.get('QUERY_STRING', ''))
234 self.view()
235
236 def direct_response(self, *args):
237 raise Response(*args)
238
239 def route(pattern, name=None):
240 def decorator(cls):
241 cls.__name__ = name or cls.__name__.lower()
242 app.route(pattern)(cls)
243 return cls
244 return decorator
245
246 @route('/', 'repo_list')
247 class RepoList(BaseView):
248 """ Shows a list of all repos and can be sorted by last update. """
249 def view(self):
250 self['repos'] = repos = []
251 for name in app.repos.iterkeys():
252 repo = get_repo(name)
253 refs = [repo[ref_hash] for ref_hash in repo.get_refs().itervalues()]
254 refs.sort(key=lambda obj:getattr(obj, 'commit_time', None),
255 reverse=True)
256 last_updated_at = None
257 if refs:
258 last_updated_at = refs[0].commit_time
259 repos.append((name, last_updated_at))
260 if 'by-last-update' in self.GET:
261 repos.sort(key=lambda x: x[1], reverse=True)
262 else:
263 repos.sort(key=lambda x: x[0])
264
265 class BaseRepoView(BaseView):
266 def __init__(self, env, repo, commit_id, path=None):
267 self['repo'] = repo = get_repo(repo)
268 self['commit_id'] = commit_id
269 self['commit'], isbranch = self.get_commit(repo, commit_id)
270 self['branch'] = commit_id if isbranch else 'master'
271 self['branches'] = repo.get_branch_names(exclude=[commit_id])
272 self['path'] = path
273 if path:
274 self['subpaths'] = list(subpaths(path))
275 self['build_url'] = self.build_url
276 super(BaseRepoView, self).__init__(env)
277
278 def get_commit(self, repo, id):
279 try:
280 commit, isbranch = repo.get_branch_or_commit(id)
281 if not isinstance(commit, Commit):
282 raise KeyError
283 except KeyError:
284 raise HttpError(404, '"%s" has no commit "%s"' % (repo.name, id))
285 return commit, isbranch
286
287 def build_url(self, view=None, **kwargs):
288 """ Builds url relative to the current repo + commit """
289 if view is None:
290 view = self.__class__.__name__
291 default_kwargs = {
292 'repo': self['repo'].name,
293 'commit_id': self['commit_id']
294 }
295 if view == 'history' and kwargs.get('path') is None:
296 kwargs['path'] = ''
297 return app.build_url(view, **dict(default_kwargs, **kwargs))
298
299
300 class TreeViewMixin(object):
301 def view(self):
302 self['tree'] = self.listdir()
303
304 def listdir(self):
305 """
306 Returns a list of directories and files in the current path of the
307 selected commit
308 """
309 dirs, files = [], []
310 tree, root = self.get_tree()
311 for entry in tree.iteritems():
312 name, entry = entry.path, entry.in_path(root)
313 if entry.mode & stat.S_IFDIR:
314 dirs.append((name.lower(), name, entry.path))
315 else:
316 files.append((name.lower(), name, entry.path))
317 files.sort()
318 dirs.sort()
319 if root:
320 dirs.insert(0, (None, '..', os.path.split(root)[0]))
321 return {'dirs' : dirs, 'files' : files}
322
323 def get_tree(self):
324 """ Gets the Git tree of the selected commit and path """
325 root = self['path']
326 tree = self['repo'].get_tree(self['commit'], root)
327 if isinstance(tree, Blob):
328 root = os.path.split(root)[0]
329 tree = self['repo'].get_tree(self['commit'], root)
330 return tree, root
331
332 @route('/:repo:/tree/:commit_id:/(?P<path>.*)', 'history')
333 class TreeView(TreeViewMixin, BaseRepoView):
334 """
335 Shows a list of files/directories for the current path as well as all
336 commit history for that path in a paginated form.
337 """
338 def view(self):
339 super(TreeView, self).view()
340 try:
341 page = int(self.GET.get('page'))
342 except (TypeError, ValueError):
343 page = 0
344
345 self['page'] = page
346
347 if page:
348 self['history_length'] = 30
349 self['skip'] = (self['page']-1) * 30 + 10
350 if page > 7:
351 self['previous_pages'] = [0, 1, 2, None] + range(page)[-3:]
352 else:
353 self['previous_pages'] = xrange(page)
354 else:
355 self['history_length'] = 10
356 self['skip'] = 0
357
358 class BaseBlobView(BaseRepoView):
359 def view(self):
360 self['blob'] = self['repo'].get_tree(self['commit'], self['path'])
361 self['directory'], self['filename'] = os.path.split(self['path'].strip('/'))
362
363 @route('/:repo:/blob/:commit_id:/(?P<path>.*)', 'view_blob')
364 class BlobView(BaseBlobView, TreeViewMixin):
365 """ Shows a single file, syntax highlighted """
366 def view(self):
367 BaseBlobView.view(self)
368 TreeViewMixin.view(self)
369 self['raw_url'] = self.build_url('raw_blob', path=self['path'])
370 self['too_large'] = sum(map(len, self['blob'].chunked)) > 100*1024
371
372
373 @route('/:repo:/raw/:commit_id:/(?P<path>.*)', 'raw_blob')
374 class RawBlob(BaseBlobView):
375 """
376 Shows a single file in raw form
377 (as if it were a normal filesystem file served through a static file server)
378 """
379 def view(self):
380 super(RawBlob, self).view()
381 mime, encoding = self.get_mimetype_and_encoding()
382 headers = {'Content-Type': mime}
383 if encoding:
384 headers['Content-Encoding'] = encoding
385 body = self['blob'].chunked
386 if len(body) == 1 and not body[0]:
387 body = []
388 self.direct_response('200 yo', headers, body)
389
390
391 def get_mimetype_and_encoding(self):
392 if guess_is_binary(self['blob'].chunked):
393 mime, encoding = mimetypes.guess_type(self['filename'])
394 if mime is None:
395 mime = 'application/octet-stream'
396 return mime, encoding
397 else:
398 return 'text/plain', 'utf-8'
399
400
401 @route('/:repo:/commit/:commit_id:/', 'view_commit')
402 class CommitView(BaseRepoView):
403 """ Shows a single commit diff """
404 def view(self):
405 pass
406
407
408 @route('/static/(?P<path>.+)', 'static')
409 class StaticFilesView(BaseView):
410 """
411 Serves assets (everything under /static/).
412
413 Don't use this in production! Use a static file server instead.
414 """
415 def __init__(self, env, path):
416 self['path'] = path
417 super(StaticFilesView, self).__init__(env)
418
419 def view(self):
420 path = './static/' + self['path']
421 relpath = os.path.join(KLAUS_ROOT, path)
422 if os.path.isfile(relpath):
423 self.direct_response(open(relpath))
424 else:
425 raise HttpError(404, 'Not Found')
+0
-189
repo.py less more
0 import os
1 import itertools
2 import cStringIO
3 import subprocess
4
5 import dulwich, dulwich.patch
6 from diff import prepare_udiff
7
8 def pairwise(iterable):
9 """
10 Yields the items in `iterable` pairwise:
11
12 >>> list(pairwise(['a', 'b', 'c', 'd']))
13 [('a', 'b'), ('b', 'c'), ('c', 'd')]
14 """
15 prev = None
16 for item in iterable:
17 if prev is not None:
18 yield prev, item
19 prev = item
20
21 class RepoWrapper(dulwich.repo.Repo):
22 def get_branch_or_commit(self, id):
23 """
24 Returns a `(commit_object, is_branch)` tuple for the commit or branch
25 identified by `id`.
26 """
27 try:
28 return self[id], False
29 except KeyError:
30 return self.get_branch(id), True
31
32 def get_branch(self, name):
33 """ Returns the commit object pointed to by the branch `name`. """
34 return self['refs/heads/'+name]
35
36 def get_default_branch(self):
37 return self.get_branch('master')
38
39 def get_branch_names(self, exclude=()):
40 """ Returns a sorted list of branch names. """
41 branches = []
42 for ref in self.get_refs():
43 if ref.startswith('refs/heads/'):
44 name = ref[len('refs/heads/'):]
45 if name not in exclude:
46 branches.append(name)
47 branches.sort()
48 return branches
49
50 def get_tag_names(self):
51 """ Returns a sorted list of tag names. """
52 tags = []
53 for ref in self.get_refs():
54 if ref.startswith('refs/tags/'):
55 tags.append(ref[len('refs/tags/'):])
56 tags.sort()
57 return tags
58
59 def history(self, commit, path=None, max_commits=None, skip=0):
60 """
61 Returns a list of all commits that infected `path`, starting at branch
62 or commit `commit`. `skip` can be used for pagination, `max_commits`
63 to limit the number of commits returned.
64
65 Similar to `git log [branch/commit] [--skip skip] [-n max_commits]`.
66 """
67 # XXX The pure-Python/dulwich code is very slow compared to `git log`
68 # at the time of this writing (Oct 2011).
69 # For instance, `git log .tx` in the Django root directory takes
70 # about 0.15s on my machine whereas the history() method needs 5s.
71 # Therefore we use `git log` here unless dulwich gets faster.
72
73 cmd = ['git', 'log', '--format=%H']
74 if skip:
75 cmd.append('--skip=%d' % skip)
76 if max_commits:
77 cmd.append('--max-count=%d' % max_commits)
78 cmd.append(commit)
79 if path:
80 cmd.extend(['--', path])
81
82 # sha1_sums = subprocess.check_output(cmd, cwd=os.path.abspath(self.path))
83 # Can't use 'check_output' for Python 2.6 compatibility reasons
84 sha1_sums = subprocess.Popen(cmd, cwd=os.path.abspath(self.path),
85 stdout=subprocess.PIPE).communicate()[0]
86 return [self[sha1] for sha1 in sha1_sums.strip().split('\n')]
87 #
88 # if not isinstance(commit, dulwich.objects.Commit):
89 # commit, _ = self.get_branch_or_commit(commit)
90 # commits = self._history(commit)
91 # path = path.strip('/')
92 # if path:
93 # commits = (c1 for c1, c2 in pairwise(commits)
94 # if self._path_changed_between(path, c1, c2))
95 # return list(itertools.islice(commits, skip, skip+max_commits))
96
97 # def _history(self, commit):
98 # """ Yields all commits that lead to `commit`. """
99 # if commit is None:
100 # commit = self.get_default_branch()
101 # while commit.parents:
102 # yield commit
103 # commit = self[commit.parents[0]]
104 # yield commit
105
106 # def _path_changed_between(self, path, commit1, commit2):
107 # """
108 # Returns `True` if `path` changed between `commit1` and `commit2`,
109 # including the case that the file was added or deleted in `commit2`.
110 # """
111 # path, filename = os.path.split(path)
112 # try:
113 # blob1 = self.get_tree(commit1, path)
114 # if not isinstance(blob1, dulwich.objects.Tree):
115 # return True
116 # blob1 = blob1[filename]
117 # except KeyError:
118 # blob1 = None
119 # try:
120 # blob2 = self.get_tree(commit2, path)
121 # if not isinstance(blob2, dulwich.objects.Tree):
122 # return True
123 # blob2 = blob2[filename]
124 # except KeyError:
125 # blob2 = None
126 # if blob1 is None and blob2 is None:
127 # # file present in neither tree
128 # return False
129 # return blob1 != blob2
130
131 def get_tree(self, commit, path, noblobs=False):
132 """ Returns the Git tree object for `path` at `commit`. """
133 tree = self[commit.tree]
134 if path:
135 for directory in path.strip('/').split('/'):
136 if directory:
137 tree = self[tree[directory][1]]
138 return tree
139
140 def commit_diff(self, commit):
141 from klaus import guess_is_binary, force_unicode
142
143 if commit.parents:
144 parent_tree = self[commit.parents[0]].tree
145 else:
146 parent_tree = None
147
148 changes = self.object_store.tree_changes(parent_tree, commit.tree)
149 for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in changes:
150 try:
151 if newsha and guess_is_binary(self[newsha].chunked) or \
152 oldsha and guess_is_binary(self[oldsha].chunked):
153 yield {
154 'is_binary': True,
155 'old_filename': oldpath or '/dev/null',
156 'new_filename': newpath or '/dev/null',
157 'chunks': [[{'line' : 'Binary diff not shown'}]]
158 }
159 continue
160 except KeyError:
161 # newsha/oldsha are probably related to submodules.
162 # Dulwich will handle that.
163 pass
164
165 stringio = cStringIO.StringIO()
166 dulwich.patch.write_object_diff(stringio, self.object_store,
167 (oldpath, oldmode, oldsha),
168 (newpath, newmode, newsha))
169 files = prepare_udiff(force_unicode(stringio.getvalue()),
170 want_header=False)
171 if not files:
172 # the diff module doesn't handle deletions/additions
173 # of empty files correctly.
174 yield {
175 'old_filename': oldpath or '/dev/null',
176 'new_filename': newpath or '/dev/null',
177 'chunks': []
178 }
179 else:
180 yield files[0]
181
182
183 def Repo(name, path, _cache={}):
184 repo = _cache.get(path)
185 if repo is None:
186 repo = _cache[path] = RepoWrapper(path)
187 repo.name = name
188 return repo
+0
-4
requirements.txt less more
0 jinja2
1 pygments
2 dulwich
3 argparse
0 # encoding: utf-8
1
2 import glob
3 from distutils.core import setup
4
5
6 def install_data_files_hack():
7 # This is a clever hack to circumvent distutil's data_files
8 # policy "install once, find never". Definitely a TODO!
9 # -- https://groups.google.com/group/comp.lang.python/msg/2105ee4d9e8042cb
10 from distutils.command.install import INSTALL_SCHEMES
11 for scheme in INSTALL_SCHEMES.values():
12 scheme['data'] = scheme['purelib']
13
14
15 install_data_files_hack()
16
17 requires = ['flask', 'pygments', 'dulwich', 'httpauth']
18
19 try:
20 import argparse # not available for Python 2.6
21 except ImportError:
22 requires.append('argparse')
23
24
25 setup(
26 name='klaus',
27 version='0.2',
28 author='Jonas Haag',
29 author_email='jonas@lophus.org',
30 packages=['klaus'],
31 scripts=['bin/klaus'],
32 data_files=[
33 ['klaus/templates', glob.glob('klaus/templates/*')],
34 ['klaus/static', glob.glob('klaus/static/*')],
35 ],
36 url='https://github.com/jonashaag/klaus',
37 license='2-clause BSD',
38 description='The first Git web viewer that Just Works™.',
39 long_description=__doc__,
40 classifiers=[
41 "Development Status :: 5 - Production/Stable",
42 "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
43 "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
44 "Topic :: Software Development :: Version Control",
45 "Environment :: Web Environment",
46 "Intended Audience :: Developers",
47 "License :: OSI Approved :: ICS License",
48 "Programming Language :: Python",
49 "Programming Language :: Python :: 2.6",
50 "Programming Language :: Python :: 2.7",
51 ],
52 install_requires=requires,
53 )
54
+0
-214
static/klaus.css less more
0 @charset "utf-8";
1
2 body { margin: 0; padding: 0; font-family: sans-serif; }
3 pre { line-height: 125%; }
4 a, a:visited { color: #003278; text-decoration: none; }
5 a:hover { text-decoration: underline; }
6 ul { list-style-type: none; padding-left: 0; }
7 table { border-spacing: 0; border-collapse: collapse; }
8
9 h2 > span:last-of-type { font-size: 60%; }
10
11 .slash { color: #666; margin: 0 -0.2em; }
12
13 .clearfloat { clear: both; }
14
15
16 /* Header */
17 header { font-size: 90%; padding: 8px 10px; border-bottom: 3px solid #e0e0e0; }
18 header > a { padding: 10px 0; }
19 header .breadcrumbs > span:before { content: ' » '; color: #666; }
20 header .slash { margin: 0 -3px; }
21
22 .branch-selector {
23 position: absolute;
24 top: 5px;
25 right: 4px;
26 font-size: 90%;
27 }
28 .branch-selector > * {
29 background-color: #fcfcfc;
30 position: relative;
31 }
32 .branch-selector span {
33 z-index: 2;
34 border: 1px solid #f1f1f1;
35 padding: 3px 5px;
36 float: right;
37 }
38 .branch-selector span:after { content: "☟"; margin-left: 5px; }
39 .branch-selector span:hover { background-color: #fefefe; cursor: pointer; }
40 .branch-selector ul {
41 z-index: 1;
42 top: -1px;
43 clear: both;
44 border: 1px solid #ccc;
45 display: none;
46 }
47 .branch-selector li a {
48 display: block;
49 padding: 5px;
50 border-bottom: 1px solid #f1f1f1;
51 }
52 .branch-selector li a:hover { background-color: #fefefe; }
53 .branch-selector li:last-child a { border: 0; padding-bottom: 4px; }
54 .branch-selector:hover span { border-color: #ccc; border-bottom-color: #f1f1f1; }
55 .branch-selector:hover ul { display: block; }
56
57 /* Footer */
58 footer {
59 clear: both;
60 font-size: 80%;
61 float: right;
62 color: #666;
63 padding: 50px 3px 3px 0;
64 }
65 footer a { color: inherit; border-bottom: 1px dotted #666; }
66 footer a:hover { text-decoration: none; }
67
68
69 /* Container */
70 #content { padding: 5px 1.5%; }
71 #content > div:nth-of-type(1),
72 #content > div:nth-of-type(2) { float: left; }
73 #content > div:nth-of-type(1) { width: 24%; }
74 #content > div:nth-of-type(2) {
75 width: 72%;
76 padding-left: 1.5%;
77 margin-left: 1.5%;
78 border-left: 1px dotted #ccc;
79 }
80
81
82 /* Pagination */
83 .pagination { float: right; margin: 0; font-size: 90%; }
84 .pagination > * {
85 border: 1px solid;
86 padding: 2px 10px;
87 text-align: center;
88 }
89 .pagination .n { font-size: 90%; padding: 1px 5px; position: relative; top: 1px; }
90 .pagination > a { opacity: 0.6; border-color: #6491bf; }
91 .pagination > a:hover { opacity: 1; text-decoration: none; border-color: #4D6FA0; }
92 .pagination span { color: #999; border-color: #ccc; }
93
94
95 /* Repo List */
96 .repolist { margin-left: 2em; font-size: 120%; }
97 .repolist li { margin-bottom: 10px; }
98 .repolist li a { display: block; }
99 .repolist li a .last-updated {
100 display: block;
101 color: #737373;
102 font-size: 60%;
103 margin-left: 1px;
104 }
105 .repolist li a:hover { text-decoration: none; }
106 .repolist li a:hover .name { text-decoration: underline; }
107
108
109 /* Base styles for history and commit views */
110 .commit {
111 background-color: #f9f9f9;
112 padding: 8px 10px;
113 margin-bottom: 2px;
114 display: block;
115 border: 1px solid #e0e0e0;
116 }
117 .commit:hover { text-decoration: none; }
118
119 .commit > span { display: block; }
120
121 .commit .line1 { font-family: monospace; padding-bottom: 2px; }
122 .commit .line1 span { white-space: pre-wrap; text-overflow: hidden; }
123 .commit:hover .line1 { text-decoration: underline; color: #aaa; }
124 .commit:hover .line1 span { color: black; }
125
126 .commit .line2 { position: relative; top: 3px; left: 1px; }
127 .commit .line2 > span:first-child { float: left; }
128 .commit .line2 > span:nth-child(2) { float: right; }
129 .commit .line2 { color: #737373; font-size: 80%; }
130
131
132 /* History View */
133 .history .pagination { margin-top: -2em; }
134 a.commit { color: black !important; }
135
136 .tree ul { font-family: monospace; border-top: 1px solid #e0e0e0; }
137 .tree li { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-top: 0; }
138 .tree li a { padding: 5px 7px 6px 7px; display: block; color: #001533; }
139 .tree li a:before, .diff .filename:before {
140 margin-right: 5px;
141 position: relative;
142 top: 2px;
143 opacity: 0.7;
144 content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAPCAYAAADUFP50AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMmAbS/QqsAAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAANBJREFUKM+Vkj2OgzAQhb8HSLupkKiQD8DPWbZMkSMgLsF9IlLmMpiKA9CncraIQGbXIPGqsec9faOx1TTNwxhz5YT6vr8lxphr13Wc1D1Zqnmecc4BIGl1LLUk4jgmTVMA1qBzDmvtxuhLEnVdr+fEb5ZleUj0lfgGn/hXh8SiKAKEF+/3F1EUhYkA4zhumlVVARfgBXzvjxoiSkK6/Bt9Q7TWHi7lM8HOVsNE7RMlMQxDkLRs078LEkPh3XfMsuzUZ1Xbts88z3/OhKZpuv8CNeMsq6Yg8OoAAAAASUVORK5CYII=);
145 }
146 .tree li a.dir:before {
147 content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMiMxgE1i8AAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAAYxJREFUKM+l0r1KXEEYxvH/bNS4K2RNCjsRBAlqITESI0QCuqBdWLwACSJaiZILiDcQkDRql8oUIqkkkRTRwiKVyvoRV5HFEFEX/DjomePsmfNauB4xrLAmD1PNML95eBnV0Fj/vjPRMcpfMcYAMP9jYXDzV3qSO1LSmegYfdvbV/DQ8zS+709oVzu7u78/FwQAHOeU9Y31gsjz5hYcx5lqbXsxdb23ld4eW15aGQmBaDRGZfzxXS1JvukBQCmFUoqZL9PDIWCMQWuX76tnpLIxisqjJC39SXmoM5thg1Q2xsd3XXjGFmWUlz1g6MPc0xIArV0A9o89dg7PiwJqqyoAiHieRzRaZPUCibiuGzb4J+B6Bv8F3LeBtQFeznLrH5RGAgQQEZRSiAgiEIhgrZCzAcYXLnxLzgrxirIbQGuXmvgFR2eGP0caRBEg5BciIAgieRjwrdwAB9lDnrW9Yjlzkr909boIBAiCApGwVWvdE+a+fkOvzX5STd0D86XV7a/vOzy7t/hzaXb85SVDycBfkNNgmgAAAABJRU5ErkJggg==);
148 }
149
150
151 /* Common for diff and blob view */
152 .line { display: block; }
153 .linenos { background-color: #f9f9f9; text-align: right; }
154 .linenos a { color: #888; }
155 .linenos a:hover { text-decoration: none; }
156 .highlight-line { background-color: #fefed0; }
157 .linenos a { padding: 0 6px 0 6px; }
158
159
160 /* Blob View */
161 .blobview img { max-width: 100%; padding: 1px; }
162 .blobview table, .blobview img { border: 1px solid #e0e0e0; }
163 .blobview .linenos { border: 1px solid #e0e0e0; padding: 0; }
164 .blobview .code { padding: 0; }
165 .blobview .code .line { padding: 0 5px 0 10px; }
166
167
168 /* Commit View */
169 .full-commit { width: 100% !important; margin-top: 10px; }
170
171 .full-commit .commit { padding: 15px 20px; }
172 .full-commit .commit .line1 { padding-bottom: 5px; }
173 .full-commit .commit:hover .line1 { text-decoration: none; }
174 .full-commit .commit .line2 > span:nth-child(2) { float: left; }
175 .full-commit .commit .line2 > span:nth-child(2):before { content: '·'; margin: 0 3px 0 5px; }
176
177 .diff { font-family: monospace; }
178 .diff .filename {
179 background-color: #f9f9f9;
180 padding: 8px 10px;
181 border: 1px solid #e0e0e0;
182 border-bottom: 0;
183 margin-top: 25px;
184 }
185 .diff .filename del { color: #999; }
186 .diff .filename:before { margin-right: 0; opacity: 0.3; }
187 .diff table {
188 border: 1px solid #e0e0e0;
189 background-color: #fdfdfd;
190 width: 100%;
191 }
192 .diff td {
193 padding: 0;
194 border-left: 1px solid #e0e0e0;
195 }
196 .diff td .line { padding: 1px 10px; display: block; height: 1.2em; }
197 .diff .linenos { font-size: 85%; padding: 0; }
198 .diff .linenos a { display: block; padding-top: 1px; padding-bottom: 1px; }
199 .diff td + td + td { width: 100%; }
200 .diff td + td + td > span { white-space: pre; }
201 .diff tr:first-of-type td { padding-top: 7px; }
202 .diff tr:last-of-type td { padding-bottom: 7px; }
203 .diff table .del { background-color: #ffdddd; }
204 .diff table .add { background-color: #ddffdd; }
205 .diff table del { background-color: #ee9999; text-decoration: none; }
206 .diff table ins { background-color: #99ee99; text-decoration: none; }
207 .diff .sep > td {
208 height: 1.2em;
209 background-color: #f9f9f9;
210 text-align: center;
211 border: 1px solid #e0e0e0;
212 }
213 .diff .sep:hover > td { background-color: #f9f9f9; }
+0
-47
static/line-highlighter.js less more
0 var highlight_linenos = function(opts) {
1 var forEach = function(collection, func) {
2 for(var i = 0; i < collection.length; ++i) {
3 func(collection[i]);
4 }
5 }
6
7
8 var links = document.querySelectorAll(opts.linksSelector);
9 currentHash = location.hash;
10
11 forEach(links, function(a) {
12 var lineno = a.getAttribute('href').substr(1),
13 selector = 'a[name="' + lineno + '"]',
14 anchor = document.querySelector(selector),
15 associatedLine = opts.getLineFromAnchor(anchor);
16
17 var highlight = function() {
18 a.className = 'highlight-line';
19 associatedLine.className = 'line highlight-line';
20 currentHighlight = a;
21 }
22
23 var unhighlight = function() {
24 if (a.getAttribute('href') != location.hash) {
25 a.className = '';
26 associatedLine.className = 'line';
27 }
28 }
29
30 a.onmouseover = associatedLine.onmouseover = highlight;
31 a.onmouseout = associatedLine.onmouseout = unhighlight;
32 });
33
34
35 window.onpopstate = function() {
36 if (currentHash) {
37 forEach(document.querySelectorAll('a[href="' + currentHash + '"]'),
38 function(e) { e.onmouseout() })
39 }
40 if (location.hash) {
41 forEach(document.querySelectorAll('a[href="' + location.hash + '"]'),
42 function(e) { e.onmouseover() });
43 currentHash = location.hash;
44 }
45 };
46 }
+0
-61
static/pygments.css less more
0 /* This is the Pygments Trac theme */
1 .code .hll { background-color: #ffffcc }
2 .code { background: #ffffff; }
3 .code .c { color: #999988; font-style: italic } /* Comment */
4 .code .err { color: #a61717; background-color: #e3d2d2 } /* Error */
5 .code .k { font-weight: bold } /* Keyword */
6 .code .o { font-weight: bold } /* Operator */
7 .code .cm { color: #999988; font-style: italic } /* Comment.Multiline */
8 .code .cp { color: #999999; font-weight: bold } /* Comment.Preproc */
9 .code .c1 { color: #999988; font-style: italic } /* Comment.Single */
10 .code .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */
11 .code .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
12 .code .ge { font-style: italic } /* Generic.Emph */
13 .code .gr { color: #aa0000 } /* Generic.Error */
14 .code .gh { color: #999999 } /* Generic.Heading */
15 .code .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
16 .code .go { color: #888888 } /* Generic.Output */
17 .code .gp { color: #555555 } /* Generic.Prompt */
18 .code .gs { font-weight: bold } /* Generic.Strong */
19 .code .gu { color: #aaaaaa } /* Generic.Subheading */
20 .code .gt { color: #aa0000 } /* Generic.Traceback */
21 .code .kc { font-weight: bold } /* Keyword.Constant */
22 .code .kd { font-weight: bold } /* Keyword.Declaration */
23 .code .kn { font-weight: bold } /* Keyword.Namespace */
24 .code .kp { font-weight: bold } /* Keyword.Pseudo */
25 .code .kr { font-weight: bold } /* Keyword.Reserved */
26 .code .kt { color: #445588; font-weight: bold } /* Keyword.Type */
27 .code .m { color: #009999 } /* Literal.Number */
28 .code .s { color: #bb8844 } /* Literal.String */
29 .code .na { color: #008080 } /* Name.Attribute */
30 .code .nb { color: #999999 } /* Name.Builtin */
31 .code .nc { color: #445588; font-weight: bold } /* Name.Class */
32 .code .no { color: #008080 } /* Name.Constant */
33 .code .ni { color: #800080 } /* Name.Entity */
34 .code .ne { color: #990000; font-weight: bold } /* Name.Exception */
35 .code .nf { color: #990000; font-weight: bold } /* Name.Function */
36 .code .nn { color: #555555 } /* Name.Namespace */
37 .code .nt { color: #000080 } /* Name.Tag */
38 .code .nv { color: #008080 } /* Name.Variable */
39 .code .ow { font-weight: bold } /* Operator.Word */
40 .code .w { color: #bbbbbb } /* Text.Whitespace */
41 .code .mf { color: #009999 } /* Literal.Number.Float */
42 .code .mh { color: #009999 } /* Literal.Number.Hex */
43 .code .mi { color: #009999 } /* Literal.Number.Integer */
44 .code .mo { color: #009999 } /* Literal.Number.Oct */
45 .code .sb { color: #bb8844 } /* Literal.String.Backtick */
46 .code .sc { color: #bb8844 } /* Literal.String.Char */
47 .code .sd { color: #bb8844 } /* Literal.String.Doc */
48 .code .s2 { color: #bb8844 } /* Literal.String.Double */
49 .code .se { color: #bb8844 } /* Literal.String.Escape */
50 .code .sh { color: #bb8844 } /* Literal.String.Heredoc */
51 .code .si { color: #bb8844 } /* Literal.String.Interpol */
52 .code .sx { color: #bb8844 } /* Literal.String.Other */
53 .code .sr { color: #808000 } /* Literal.String.Regex */
54 .code .s1 { color: #bb8844 } /* Literal.String.Single */
55 .code .ss { color: #bb8844 } /* Literal.String.Symbol */
56 .code .bp { color: #999999 } /* Name.Builtin.Pseudo */
57 .code .vc { color: #008080 } /* Name.Variable.Class */
58 .code .vg { color: #008080 } /* Name.Variable.Global */
59 .code .vi { color: #008080 } /* Name.Variable.Instance */
60 .code .il { color: #009999 } /* Literal.Number.Integer.Long */
+0
-33
templates/base.html less more
0 {% extends 'skeleton.html' %}
1
2 {% block breadcrumbs %}
3 <span>
4 <a href="{{ build_url('history', commit_id='master') }}">{{ repo.name }}</a>
5 <span class=slash>/</span>
6 <a href="{{ build_url('history') }}">{{ commit_id|shorten_sha1 }}</a>
7 </span>
8
9 {% if subpaths %}
10 <span>
11 {% for name, subpath in subpaths %}
12 {% if loop.last %}
13 <a href="">{{ name|u }}</a>
14 {% else %}
15 <a href="{{ build_url('history', path=subpath) }}">{{ name|u }}</a>
16 <span class=slash>/</span>
17 {% endif %}
18 {% endfor %}
19 </span>
20 {% endif %}
21 {% endblock %}
22
23 {% block extra_header %}
24 <div class=branch-selector>
25 <span>{{ commit_id|shorten_sha1 }}</span>
26 <ul>
27 {% for branch in branches %}
28 <li><a href="{{ build_url(commit_id=branch, path=path) }}">{{ branch }}</a></li>
29 {% endfor %}
30 </ul>
31 </div>
32 {% endblock %}
+0
-74
templates/history.html less more
0 {% extends 'base.html' %}
1 {% block content %}
2
3 {% include 'tree.inc.html' %}
4
5 {% set history = repo.history(branch, path.strip('/'), history_length+1, skip) %}
6 {% set has_more_commits = history|length == history_length+1 %}
7
8 {% macro pagination() %}
9 <div class=pagination>
10 {% if page %}
11 {% for n in previous_pages %}
12 {% if n is none %}
13 <span class=n>...</span>
14 {% else %}
15 <a href="?page={{n}}" class=n>{{ n }}</a>
16 {% endif %}
17 {% endfor %}
18 {% endif %}
19 {% if has_more_commits %}
20 <a href="?page={{page+1}}">»»</a>
21 {% else %}
22 <span>»»</span>
23 {% endif%}
24 </div>
25 <div class=clearfloat></div>
26 {% endmacro %}
27
28 <div>
29 <div class=history>
30 <h2>
31 {% if subpaths %}
32 History for
33 {% for name, subpath in subpaths %}
34 {{ name }}
35 {% if not loop.last %}
36 <span class=slash>/</span>
37 {% endif %}
38 {% endfor %}
39 {% else %}
40 Commit History
41 {% endif %}
42 <span>
43 @<a href="{{ build_url(commit_id=branch) }}">{{ branch }}</a>
44 </span>
45 </h2>
46
47 {{ pagination() }}
48
49 <ul>
50 {% for commit in history %}
51 {% if not loop.last or history|length < history_length %}
52 <li>
53 <a class=commit href="{{ build_url('view_commit', commit_id=commit.id) }}">
54 <span class=line1>
55 <span>{{ commit.message|u|shorten_message }}</span>
56 </span>
57 <span class=line2>
58 <span>{{ commit.author|u|shorten_author }}</span>
59 <span>{{ commit.commit_time|timesince }} ago</span>
60 </span>
61 <span class=clearfloat></span>
62 </a>
63 </li>
64 {% endif %}
65 {% endfor %}
66 </ul>
67 </div>
68
69 {{ pagination() }}
70
71 </div>
72
73 {% endblock %}
+0
-26
templates/repo_list.html less more
0 {% extends 'skeleton.html' %}
1 {% block content %}
2
3 <h2>
4 Repositories
5 <span>
6 (<a href=?by-last-update=yep>order by last update</a>)
7 </span>
8 </h2>
9 <ul class=repolist>
10 {% for name, last_update_at in repos %}
11 <li>
12 <a href="{{ build_url('history', repo=name, commit_id='master', path='') }}">
13 <span class=name>{{ name }}</span>
14 <span class=last-updated>
15 {% if last_update_at is not none %}
16 last updated {{ last_update_at|timesince }} ago
17 {% else %}
18 no commits yet
19 {% endif %}
20 </span></a>
21 </li>
22 {% endfor %}
23 </ul>
24
25 {% endblock %}
+0
-20
templates/skeleton.html less more
0 <!doctype html>
1 <meta http-equiv="content-type" content="text/html; charset=utf-8">
2 <link rel=stylesheet href=/static/pygments.css>
3 <link rel=stylesheet href=/static/klaus.css>
4 <script src=/static/line-highlighter.js></script>
5
6 <header>
7 <a href=/>{{ environ.HTTP_HOST }}</a>
8 <span class=breadcrumbs>{% block breadcrumbs %}{% endblock %}</span>
9 {% block extra_header %}{% endblock %}
10 </header>
11
12 <div id=content>
13 {% block content %}{% endblock %}
14 </div>
15
16 <footer>
17 powered by <a href="https://github.com/jonashaag/klaus">klaus</a>{{ KLAUS_VERSION }},
18 a simple Git viewer by Jonas Haag
19 </footer>
+0
-11
templates/tree.inc.html less more
0 <div class=tree>
1 <h2>Tree @<a href="{{ build_url('view_commit') }}">{{ commit_id|shorten_sha1 }}</a></h2>
2 <ul>
3 {% for _, name, fullpath in tree.dirs %}
4 <li><a href="{{ build_url('history', path=fullpath) }}" class=dir>{{ name|u }}</a></li>
5 {% endfor %}
6 {% for _, name, fullpath in tree.files %}
7 <li><a href="{{ build_url('view_blob', path=fullpath) }}">{{ name|u }}</a></li>
8 {% endfor %}
9 </ul>
10 </div>
+0
-39
templates/view_blob.html less more
0 {% extends 'base.html' %}
1 {% block content %}
2
3 {% include 'tree.inc.html' %}
4
5 <div class=blobview>
6 <h2>
7 {{ filename|u }}
8 <span>
9 @<a href="{{ build_url('view_commit') }}">{{ commit_id|shorten_sha1 }}</a>
10 (<a href="{{ raw_url }}">raw</a>
11 &middot; <a href="{{ build_url('history', path=path) }}">history</a>)
12 </span>
13 </h2>
14 {% if blob.chunked|is_binary %}
15 {% if filename|is_image %}
16 <a href="{{ raw_url }}"><img src="{{ raw_url }}"></a>
17 {% else %}
18 <div class=binary-warning>(Binary data not shown)</div>
19 {% endif %}
20 {% else %}
21 {% if too_large %}
22 <div class=too-large-warning>(Large file not shown)</div>
23 {% else %}
24 {% autoescape off %}
25 {{ blob.data|u|pygmentize(filename=filename) }}
26 {% endautoescape %}
27 {% endif %}
28 {% endif %}
29 </div>
30
31 <script>
32 highlight_linenos({
33 linksSelector: '.highlighttable .linenos a',
34 getLineFromAnchor: function(anchor) { return anchor.nextSibling }
35 })
36 </script>
37
38 {% endblock %}
+0
-107
templates/view_commit.html less more
0 {% set title = 'Commit %s to %s' % (commit.id, repo.name) %}
1 {% extends 'base.html' %}
2
3 {% block extra_header %}{% endblock %} {# no branch selector on commits #}
4
5 {% block content %}
6
7 <div class=full-commit>
8
9 <div class=commit>
10 <span class=line1>
11 <span>{{ commit.message|u }}</span>
12 </span>
13 <span class=line2>
14 <span>{{ commit.author|u|shorten_author }}</span>
15 <span>{{ commit.commit_time|timesince }} ago</span>
16 </span>
17 <span class=clearfloat></span>
18 </div>
19
20 <div class=diff>
21 {% for file in repo.commit_diff(commit) %}
22
23 {% set fileno = loop.index0 %}
24
25 <div class=filename>
26 {# TODO dulwich doesn't do rename recognition
27 {% if file.old_filename != file.new_filename %}
28 {{ file.old_filename }} →
29 {% endif %}#}
30 {% if file.new_filename == '/dev/null' %}
31 <del>{{ file.old_filename|u }}</del>
32 {% else %}
33 <a href="{{ build_url('view_blob', path=file.new_filename) }}">
34 {{ file.new_filename|u }}
35 </a>
36 {% endif %}
37 </div>
38
39 <table>
40 {% for chunk in file.chunks %}
41
42 {%- for line in chunk -%}
43 <tr>
44
45 {#- left column: linenos -#}
46 {%- if line.old_lineno -%}
47 <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.old_lineno }}</a></td>
48 {%- if line.new_lineno -%}
49 <td class=linenos><a href="#{{fileno}}-L-{{line.old_lineno}}">{{ line.new_lineno }}</a></td>
50 {%- else -%}
51 <td class=linenos></td>
52 {%- endif -%}
53 {%- else %}
54 {%- if line.old_lineno -%}
55 <td class=linenos><a href="#{{fileno}}-R-{{line.old_lineno}}">{{ line.new_lineno }}</a></td>
56 {%- else -%}
57 <td class=linenos></td>
58 {%- endif -%}
59 <td class=linenos><a href="#{{fileno}}-R-{{line.new_lineno}}">{{ line.new_lineno }}</a></td>
60 {% endif %}
61
62 {#- right column: code -#}
63 <td class={{line.action}}>
64 {#- lineno anchors -#}
65 {%- if line.old_lineno -%}
66 <a name="{{fileno}}-L-{{line.old_lineno}}"></a>
67 {%- else -%}
68 <a name="{{fileno}}-R-{{line.new_lineno}}"></a>
69 {%- endif -%}
70
71 {#- the actual line of code -#}
72 <span class=line>{% autoescape off %}{{ line.line|u }}{% endautoescape %}</span>
73 </td>
74
75 </tr>
76 {%- endfor -%} {# lines #}
77
78 {% if not loop.last %}
79 <tr class=sep>
80 <td colspan=3></td>
81 </tr>
82 {% endif %}
83
84 {%- endfor -%} {# chunks #}
85 </table>
86
87 {% endfor %}
88 </div>
89
90 </div>
91
92 <script>
93 highlight_linenos({
94 linksSelector: '.linenos a',
95 getLineFromAnchor: function(anchor) {
96 /* If we got the first (old_lineno) anchor, the span we're looking for is
97 the second-next sibling, otherwise it's the next. */
98 if (anchor.nextSibling instanceof HTMLSpanElement)
99 return anchor.nextSibling;
100 else
101 return anchor.nextSibling.nextSibling;
102 }
103 });
104 </script>
105
106 {% endblock %}
+0
-43
tools/devserver.py less more
0 #!/usr/bin/env python2
1 import sys, os
2 import inspect
3
4 sys.path.append(os.path.join(os.path.dirname(__file__), 'nano'))
5
6 class ReloadApplicationMiddleware(object):
7 def __init__(self, import_func):
8 self.import_func = import_func
9 self.app = import_func()
10 self.files = self.get_module_mtimes()
11
12 def get_module_mtimes(self):
13 files = {}
14 for module in sys.modules.itervalues():
15 try:
16 file = inspect.getsourcefile(module)
17 files[file] = os.stat(file).st_mtime
18 except TypeError:
19 continue
20 return files
21
22 def shall_reload(self):
23 for file, mtime in self.get_module_mtimes().iteritems():
24 if not file in self.files or self.files[file] < mtime:
25 self.files = self.get_module_mtimes()
26 return True
27 return False
28
29 def __call__(self, *args, **kwargs):
30 if self.shall_reload():
31 print 'Reloading...'
32 self.app = self.import_func()
33 return self.app(*args, **kwargs)
34
35 def import_app():
36 sys.modules.pop('klaus', None)
37 sys.modules.pop('repo', None)
38 from klaus import app
39 return app
40
41 import bjoern
42 bjoern.run(ReloadApplicationMiddleware(import_app), '127.0.0.1', 8080)
1212 return url
1313
1414 AHREF_RE = re.compile('href="([\w/][^"]+)"')
15
16 BASE_URL = 'http://localhost:8080'
1715
1816 seen = set()
1917 errors = defaultdict(set)
3634 if '-v' in sys.argv:
3735 print 'Requesting %r...' % url
3836 start = time.time()
39 http_conn.request('GET', BASE_URL + url)
37 http_conn.request('GET', url)
4038 response = http_conn.getresponse()
4139 durations[view_from_url(url)].append(time.time() - start)
4240 status = str(response.status)
+0
-58
tools/quickstart.py less more
0 #!/usr/bin/env python2
1 # coding: utf-8
2 import sys, os
3 import argparse
4
5
6 PROJECT_ROOT = os.path.join(os.path.dirname(__file__), os.pardir)
7
8 sys.path.append(PROJECT_ROOT)
9
10
11 try:
12 import nano
13 except ImportError:
14 sys.path.append(os.path.join(PROJECT_ROOT, 'nano'))
15 try:
16 import nano
17 except ImportError:
18 raise ImportError(
19 "Could not find a copy of nano (https://github.com/jonashaag/nano). "
20 "Use 'git submodule update --init' to initialize the nano submodule "
21 "or copy the 'nano.py' into the klaus root directory by hand."
22 )
23
24
25 try:
26 from bjoern import run
27 except ImportError:
28 from wsgiref.simple_server import make_server
29 def run(app, host, port):
30 make_server(host, port, app).serve_forever()
31
32
33 def valid_directory(path):
34 if not os.path.exists(path):
35 raise argparse.ArgumentTypeError('%r: No such directory' % path)
36 return path
37
38
39 def main():
40 parser = argparse.ArgumentParser(epilog='Gemüse kaufen!')
41 parser.add_argument('host', help='(without http://)')
42 parser.add_argument('port', type=int)
43 parser.add_argument('--display-host', dest='custom_host')
44 parser.add_argument('repo', nargs='+', type=valid_directory,
45 help='repository directories to serve')
46 args = parser.parse_args()
47 sys.argv = ['this is a hack'] + args.repo
48
49 from klaus import app
50 if args.custom_host:
51 app.custom_host = args.custom_host
52
53 run(app, args.host, args.port)
54
55
56 if __name__ == '__main__':
57 main()