Compare commits

..

73 Commits
0.11 ... master

Author SHA1 Message Date
Alberto Bertogli
dff4ff6757 css: Work around Pygments' element layout changes
Pygments 2.12 changes the element layout slightly, adding a wrapper
<div> that was accidentally removed before:
https://github.com/pygments/pygments/issues/632.

This patch adds a workaround, so the styling is consistent on both 2.11
and 2.12.
2022-10-13 22:32:21 +01:00
Alberto Bertogli
6ea59bad51 css: Dark mode for Pygments' syntax highlight
This patch updates Pygments' syntax highlight CSS to support dark mode.
It uses two themes from Pygments: `default` for light (same as before),
and `native` for dark.
2022-10-13 22:32:21 +01:00
Alberto Bertogli
4b1e1eb84c css: Introduce dark mode
This patch extends our CSS to introduce dark mode, so the style shown
matches the user media preference.

It is very analogous to the previous one, only minor adjustments have
been made to make the contrast levels pass the accessibility standards.

No changes have been made to the pygments CSS. It works surprisingly
well as-is, but there are some minor changes that may be needed. Those
will be done in subsequent patches.
2022-10-13 22:32:21 +01:00
Alberto Bertogli
518188288e Cache some (possibly) expensive function calls
This patch memoizes some of the functions to help speed up execution.
The speedup is quite variable, but ~30% is normal when generating a
medium size repository, and the output is byte-for-byte identical.
2022-08-31 23:15:16 +01:00
Alberto Bertogli
15547b2796 utils: Update Markdown local links extension to the new API
The Markdown extension for rewriting local links was using an API that
is now deprecated, and as of python-markdown 3.4 it is no longer
available.

This patch adjusts the code to use the new API which should be available
from 3.0 onwards.
2022-08-31 21:07:53 +01:00
Alberto Bertogli
9f3df4899f css: Improve handling of text overflow in <pre>
When a <pre> section (commit message, blob, diff) has a very long line,
today it makes the entire page very wide, causing usability issues.

This patch makes <pre> have a horizontal scroll in those cases, which is
easier to use.
2021-05-15 01:24:58 +01:00
Alberto Bertogli
bc1ee87dfe css: Reduce commit-message left padding
The commit message has a very large left and right padding, but doesn't
improve readability and might make the commit message more difficult to
read on smaller screens.

This patch shortens the padding.
2021-05-15 00:58:11 +01:00
Alberto Bertogli
0d61bbf7f5 css: Auto-format git-arr.css
Auto-format static/git-arr.css with https://blitiri.com.ar/git/r/css3fmt/
for consistency.
2021-05-15 00:53:42 +01:00
Alberto Bertogli
78f1b6def0 Update README
This patch updates README, converting it to markdown, adding more links
and references, and explicitly mention the Python 3 dependency.
2020-05-25 02:22:53 +01:00
Alberto Bertogli
4cb2f59dd4 Remove TODO
The TODO includes many obsolete entries and is generally not kept up to
date; remove it to avoid confusion.
2020-05-25 02:22:53 +01:00
Alberto Bertogli
e2155f6b33 Remove unused/unnecessary code
This patch removes some code that is unused and/or unnecessary. Most of
it is left over from previous situations, but is no longer needed.
2020-05-25 02:04:55 +01:00
Alberto Bertogli
aee18d0edd Simplify smstr
With the Python 2 to 3 migration and the type checking, we can be
fairly confident that smstr are always constructed from strings, not
bytes.

This allows the code to be simplified, as we no longer need to carry
the dual raw/unicode representation.
2020-05-24 16:05:18 +01:00
Alberto Bertogli
20b99ee568 Introduce type annotations
This patch introduces type annotations, which can be checked with mypy.

The coverage is not very comprehensive for now, but it is a starting
point and will be expanded in later patches.
2020-05-24 16:04:24 +01:00
Alberto Bertogli
ad950208bf Auto-format the code with black
This patch applies auto-formatting of the source code using black
(https://github.com/psf/black).

This makes the code style more uniform and simplifies editing.

Note I also tried yapf, and IMO produced nicer output and handled some
corner cases much better, but unfortunately it doesn't yet support type
annotations, which will be introduced in later commits.

So in the future we might switch to yapf instead.
2020-05-24 16:04:04 +01:00
Alberto Bertogli
1183d6f817 Move to Python 3
Python 3 was released more than 10 years ago, and support for Python 2
is going away, with many Linux distributions starting to phase it out.

This patch migrates git-arr to Python 3.

The generated output is almost exactly the same, there are some minor
differences such as HTML characters being quoted more aggresively, and
handling of paths with non-utf8 values.
2020-05-24 04:50:39 +01:00
Alberto Bertogli
cbb36e087c Implement a "patch" view
This commit implements a "patch" view, with a simple plain-text
representation of a commit, that can be used as a patch file.
2018-10-01 21:39:57 +01:00
Alberto Bertogli
722d765973 markdown: Handle local links
By default, the markdown generator creates links for local files
transparently. For example, "[text](link.md)" will generate
"<a href=link.md>text</a>".

This works fine for absolute and external links, but breaks for links
relative to the repository itself, as git-arr links are of the form
"dir/f=file.ext.html".

So this patch adds a markdown extension to rewrite the links. It uses a
heuristic to detect them, which should work for the vast majority of
common cases.
2018-03-04 20:53:35 +00:00
Alberto Bertogli
5e75a1e7a1 Tune markdown CSS to increase readability
The default CSS is not very comfortable for markdown, as for example the
links are hidden.

This patch makes the markdown CSS tunable by wrapping it into a div, and
then adjusting the default styles to increase readability.
2018-03-04 19:14:49 +00:00
Alberto Bertogli
e1349d418c views: In the summary, make the sections toggable
As an experiment, make the sections of the summary to be toggable. This
can help readability, although it's unclear if it's worth the additional
complexity and could be removed later.
2017-08-27 19:43:36 +01:00
Alberto Bertogli
5def4c9e01 views: Include the root tree in the summary
Including the tree as part of the summary gives a bit more information
and provides an easy path into the tree.

It does clutter things a bit, so this is an experiment and may be
removed later.
2017-08-27 19:17:26 +01:00
Alberto Bertogli
891a944381 git: Don't use an empty pathspec when listing
An empty string as a pathspec element matches all paths, but git has
recently started complaining about it, as it could be problematic for
some operations like rm. In the future, it will be considered an error.

So this patch uses "." instead of the empty pathspec, as recommended.

d426430e6e
2017-08-27 17:37:12 +01:00
Alberto Bertogli
d7f0e4a265 views: Change the "repository" line into "git clone"
We display the location of the repository, but the entire row is not
convenient for copy-pasting.

This patch changes the wording to "git clone" so the entire row can be
copied and pasted into a terminal.

There's a trick, because if we just changed the wording to:

  <td>git clone</td> <td>https://example.com/repo</td>

that would get copied as:

  git clone\thttps://example.com/repo

which does not work well when pasted into a terminal (as the \t gets
"eaten" in most cases).

So this patch changes the HTML to have a space after "clone":

  <td>git clone </td> <td>https://example.com/repo</td>

and the CSS to preserve the space, so the following gets copied:

  git clone \thttps://example.com/repo

which works when pasting on a terminal.
2017-08-27 16:16:56 +01:00
Alberto Bertogli
56b0b34930 style: In the index, make the project names explicit links
For readability, make the project names in the index to be explicit
links.
2017-08-27 15:29:24 +01:00
Alberto Bertogli
9b21bd6f19 style: Normal font sizes, and use monospace for listings
There's a significant amount of overrides to make the font sizes
smaller, but that can hurt readability in some cases. We should try to
use the "natural" sizes as much as possible.

This patch does that, removing a lot of the font sizes and bringing them
to be based on the normal sizes.

It also changes listings to use monospace, for readability.
2017-08-27 15:29:20 +01:00
Alberto Bertogli
c96d0dbea6 style: Make line numbers grey
When using pygments, make the line numbers grey.

This was the intention all along, but the <a> style overrides the <div>
style and the grey colour does not take effect.

This patch fixes the problem by setting the style specifically to <a>
within the line numbers div.
2017-08-27 15:29:10 +01:00
Alberto Bertogli
9c8a6d2408 Add a "prefix" configuration option
This patch adds a "prefix" configuration option, so repositories created
with recursion are named with a prefix.

This can be useful to disambiguate between repositories that are named
the same but live in different directories.
2017-07-30 20:33:37 +01:00
Alberto Bertogli
53155e566a markdown: Enable table and fenced code extensions
This patch enables the table and fenced code extensions in markdown
processing.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2016-11-03 01:45:46 +00:00
Alberto Bertogli
c648cfb593 views: Improve display on mobile browsers
This patch moves the pages to HTML5, and adds some simple meta tags and CSS media
constraints so things render better on mobile browsers, while leaving the
desktop unaffected.

It's still not ideal, though.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-11-18 00:29:34 +00:00
Alberto Bertogli
cacf2ee2cc git-arr: Implement an "ignore" option
When having symbolic links to the same repositories (e.g. if you have "repo"
and a "repo.git" linking to it), it can be useful to ignore based on regular
expressions to avoid having duplicates in the output.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-11-07 12:04:09 +00:00
Alberto Bertogli
c4e6484bb0 git-arr: Always generate the top level index
The top level index contains a "last updated" field, but it doesn't get
updated if using the --only option, which is very common in post-update hooks,
and causes the date to be stale.

This patch fixes that by always generating the top level index, even if --only
was given.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-11-07 12:04:09 +00:00
Eric Sunshine
88dd6fab76 views/tree: signify root directory in page title
The page title in a root tree displays as "git >> repo >> branch >>",
which looks odd and fails to convey the fact that the page represents a
tree. Appending a '/' (for example "git >> repo >> branch >> /") makes
it more obvious that the page shows a tree, in general, and the root
tree, in particular.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-24 19:23:18 +00:00
Eric Sunshine
84d628c690 views/blob: suppress double-slash in page title
For blobs in subdirectories, the page title always includes a double
slash between the final directory component and the filename (for
example, "git >> repo >> branch >> doc//readme.txt"). This is unsightly.

git-arr:blob() ensures that the directory passed to views/blob always
has a trailing slash, so we can drop the slash inserted by views/blob
between the directory and the filename.

As a side-effect, this also changes the page title for blobs in the root
directory. Instead of "git >> repo >> branch >> /readme.txt", the title
becomes "git >> repo >> branch >> readme.txt", which is slightly more
aesthetically pleasing.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-24 19:23:18 +00:00
Eric Sunshine
5568fd50c2 Repo: retire new_in_branch() and notion of "bound" branch
Binding (or "pegging") a Repo at a particular branch via new_in_branch()
increases the cognitive burden since the reader must maintain a mental
model of which Repo instances are pegged and which are not. This burden
outweighs whatever minor convenience (if any) is gained by pegging the
Repo at a particular branch. It is easier to reason about the code when
the branch name is passed to clients directly rather than indirectly via
a pegged Repo.

Preceding patches retired all callers of new_in_branch(), therefore
remove it.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-17 13:11:46 +00:00
Eric Sunshine
89a637660f branch: pass branch name view explicitly
Passing the branch name into the view indirectly via
Repo.new_in_branch() increases cognitive burden, thus outweighing
whatever minor convenience (if any) is gained by doing so. The code is
easier to reason about when the branch name is passed to the view
directly.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-17 13:11:43 +00:00
Eric Sunshine
37e731fc2e blob: pass branch name to view explicitly
Passing the branch name into the view indirectly via
Repo.new_in_branch() increases cognitive burden, thus outweighing
whatever minor convenience (if any) is gained by doing so. The code is
easier to reason about when the branch name is passed to the view
directly.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-17 13:11:39 +00:00
Eric Sunshine
e6099cf272 tree: pass branch name to view explicitly
Passing the branch name into the view indirectly via
Repo.new_in_branch() increases cognitive burden, thus outweighing
whatever minor convenience (if any) is gained by doing so. The code is
easier to reason about when the branch name is passed to the view
directly.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-17 13:11:27 +00:00
Eric Sunshine
46640c68b9 views: blob: render empty blobs specially
Empty (zero-length) blobs are currently rendered by 'pygments'
misleadingly as a single empty line, or, when 'pygments' is unavailable,
as "nothingness" preceding a horizontal rule. In either case, it is
somewhat difficult to glean concrete information about the blob.

Address this by instead rendering summary information about the blob: in
particular, its classification ("empty") and its size ("0 bytes"). This
is analogous to the summary information rendered for binary blobs
("binary" and size).

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:45 +00:00
Eric Sunshine
c91beccdb0 blob: cap amount of rendered binary blob content
Although hexdump(1)-style rendering of binary blob content may reveal
some meaningful information about the data, it wastes even more storage
space than embedding the raw data itself. However, many binary files
have a "magic number" or other signature near the beginning of the file,
so it is often possible to glean useful information from just the
initial chunk of the file without having the entire content available.

Thus, limiting the rendered data to just an initial chunk saves storage
space while still potentially presenting useful information about the
binary content.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:45 +00:00
Eric Sunshine
6f3942ce38 blob: render hexdump(1)-style binary blob content
Raw binary blob content tends to look like "line noise" and is rarely,
if ever, meaningful. A hexdump(1)-style rendering (specifically,
"hexdump -C"), on the other hand, showing runs of hexadecimal byte
values along with an ASCII representation of those bytes can sometimes
reveal useful information about the data.

(A subsequent patch will add the ability to cap the amount of data
rendered in order to reduce storage space requirements.)

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
09c2f33f5a blob: render binary blob summary information rather than raw content
Binary blobs are currently rendered as raw data directly into the HTML
output, looking much like "line noise". This is rarely, if ever,
meaningful, and consumes considerable storage space since the entire raw
blob content is embedded in the generated HTML file.

Address this issue by instead emitting summary information about the
blob, such as its classification ("binary") and its size. Other
information can be added as needed.

As in Git itself, a blob is considered binary if a NUL is present in the
first ~8KB.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
58037e57c5 Repo.blob: respect reported blob size
Batch output of git-cat-file has the form:

    <sha1> SP <type> SP <size> LF <contents> LF

It unconditionally includes a trailing line-feed which Repo.blob()
incorrectly returns as part of blob content. For textual blobs, this
extra character is often benign, however, for binary blobs, it can
easily change the meaning of the data in unexpected or disastrous ways.
Fix this by respecting the blob size reported by git-cat-file.

(The alternate approach of unconditionally dropping the final LF also
works, however, respecting the reported size is perhaps a bit more
robust and "correct".)

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
50c004f8a5 embed_image_blob: retire reload of image blob
Historically, the 'blob' view was unconditionally handed cooked
(utf8-encoded) blob content, so embed_image_blob(), which requires raw
blob content, has been forced to reload the blob in raw form, which is
ugly and expensive. However, now that the Blob returned by Repo.blob()
is able to vend raw or cooked content, it is no longer necessary for
embed_image_blob() to reload the blob to gain access to the raw content.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
1d79988228 Blob: vend raw or cooked content
Some blob representations require raw blob content, however, the 'blob'
view is unconditionally handed cooked (utf8-encoded) content, thus
representations which need raw content are forced to reload the blob in
raw form, which is ugly and expensive.

The ultimate goal is to eliminate the wasteful blob reloading when raw
content is needed. Toward that end, teach Blob how to vend raw or cooked
content.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
0ba89d75e6 git.py: introduce Blob abstraction
Some blob representations (such as embedded images) require raw blob
content, however, the 'blob' view is unconditionally handed cooked
(utf8-encoded) content, thus representations which need raw content are
forced to reload the blob in raw form, which is ugly and expensive (due
to shelling out to git-cat-file a second time).

The ultimate goal is to eliminate the wasteful blob reloading when raw
content is needed. As a first step, introduce a Blob abstraction to be
returned by Repo.blob() rather than the cooked content. A subsequent
change will flesh out Blob, allowing it to return raw or cooked content
on demand without the client having to specify one or the other when
invoking Repo.blob().

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
6b83e32bc1 Repo.blob: employ formal mechanism for requesting raw command output
Sneakily extracting the raw 'fd' from the utf8-encoding wrapper
returned by GitCommand.run() is ugly and fragile. Instead, take
advantage of the new formal API for requesting raw command output.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
43f4132bf1 GitCommand: teach run() how to return raw output stream
Currently, clients which want the raw output from a Git command must
sneakily extract the raw 'fd' from the utf8-encoding wrapper returned
by GitCommand.run(). This is ugly and fragile. Instead, provide a
formal mechanism for requesting raw output.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
66afd72d6d run_git: add option to return raw output stream
Currently, clients which want the raw output from a Git command must
sneakily extract the raw 'fd' from the utf8-encoding wrapper returned
by run_git(). This is ugly and fragile. Instead, provide a formal
mechanism for requesting raw output.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-13 19:51:44 +00:00
Eric Sunshine
bb9bad89d1 git-arr: increase default 'max_pages' value
The 'max_pages' default value of 5 is quite low. Coupled with
'commits_per_page' default 50, this allows for only 250 commits, which
is likely unsuitable for even relatively small projects. Options are to
remove the cap altogether or to raise the default limit. At this time,
choose the latter, which should be friendlier to larger projects, in
general, while still guarding against run-away storage space
consumption.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-12 09:00:18 +00:00
Eric Sunshine
56fcfd0278 route: recognize hierarchical branch names
Branch names in Git may be hierarchical (for example, "wip/parser/fix"),
however, git-arr's Bottle routing rules do not take this into account.
Fix this shortcoming.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:22 +00:00
Eric Sunshine
e930f9e4f7 route: prepare to fix routing of hierarchical branch names
Branch names in Git may be hierarchical (for example, "wip/parser/fix"),
however, git-arr does not take this into account in its Bottle routing
rules.

Unfortunately, when updated to recognize hierarchical branch names, the
rules become ambiguous in their present order since Bottle matches them
in the order registered. The ambiguity results in incorrect matches. For
instance, branch pages (/r/<repo>/b/<bname>/) are matched before tree
pages (/r/<repo>/b/<bname>/t/), however, when branch names can be
hierarchical, a tree path such as "/r/proj/b/branch/t/" also looks like
a branch named "branch/t", and thus undesirably matches the branch rule
rather than the tree rule. This problem can be resolved by adjusting the
order of rules.

Therefore, re-order the rules from most to least specific as a
preparatory step prior to actually fixing them to accept hierarchical
branch names. This is a purely textual relocation.  No functional
changes intended.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:22 +00:00
Eric Sunshine
93b161c23e views: fix broken URLs involving hierarchical branch names
Git branch names can be hierarchical (for example, "wip/parser/fix"),
however, git-arr does not take this into account when formulating URLs
on branch, tree, and blobs pages. These URLs are dysfunctional because
it is assumed incorrectly that a single "../" is sufficient to climb
over the branch name when computing relative paths to resources higher
in the hierarchy. This problem manifests as failure to load static
resources (stylesheet, etc.), broken links to commits on branch pages,
and malfunctioning breadcrumb trails.

Fix this problem by computing the the proper number of "../" based upon
the branch name, rather than assuming that a single "../" will work
unconditionally. (This is analogous to the treatment already given to
hierarchical pathnames in tree and blob views.)

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:22 +00:00
Eric Sunshine
7f2f67629f views: branch/paginate: teach "next" link to respect 'max_pages'
Pagination link "next" does not respect 'max_pages', thus it incorrectly
remains enabled on the final page capped by 'max_pages'. When clicked,
the user is taken to a "404 Page not found" error page, which makes for
a poor user experience.

Fix this problem by teaching the "next" link to respect 'max_pages'.

(As a side-effect, this also causes 'serve' mode to respect 'max_pages',
which was not previously the case. This change of behavior is
appropriate since it brings 'serve' mode, which is intended primarily
for testing, more in line with 'generate' mode.)

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:22 +00:00
Eric Sunshine
ac105c8383 views: branch/paginate: fix incorrectly enabled "next" link
When the number of commits on a branch page is less than
'commits_per_page', the pagination "next" link is disabled, indicating
correctly that this is the final page. However, if the number of commits
on the branch page is exactly 'commits_per_page', then the "next" link
is incorrectly enabled, even on the final page. When clicked, the user
is taken to a "404 Page not found" error page, which makes for a poor
user experience.

Fix this problem by reliably detecting when the branch page is the final
one. Do so by asking for (but not displaying) one commit more than
actually needed by the page. If the additional commit is successfully
retrieved, then another page definitely follows this one. If not
retrieved, then this is definitely the final page.

(Unfortunately, the seemingly more expedient approach of checking if the
final commit on the current page is a root commit -- has no parents --
is not a reliable indicator that this the final page since a branch may
have multiple root commits due to merging.)

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:22 +00:00
Eric Sunshine
bebc7fa3f0 repo: diff: add option to show "creation event" diff for root commit
At its inception, Git did not show a "creation event" diff for a
project's root commit since early projects, such as the Linux kernel,
were already well established, and a large root diff was considered
uninteresting noise.

On the other hand, new projects adopting Git typically have small root
commits, and such a "creation event" is likely to have meaning, rather
than being pure noise. Consequently, git-diff-tree gained a --root flag
in dc26bd89 (diff-tree: add "--root" flag to show a root commit as a big
creation event, 2005-05-19), though it was disabled by default.

Displaying the root "creation event" diff, however, became the default
behavior when configuration option 'log.showroot' was added to git-log
in 0f03ca94 (config option log.showroot to show the diff of root
commits; 2006-11-23). And, gitk (belatedly) followed suit when it
learned to respect 'log.showroot' in b2b76d10 (gitk: Teach gitk to
respect log.showroot; 2011-10-04).

By default, these tools now all show the root diff as a "creation
event", however, git-arr suppresses it unconditionally. Resolve this
shortcoming by adding a new git-arr configuration option "rootdiff" to
control the behavior (enabled by default).

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:22 +00:00
Eric Sunshine
9ef78aaffd git-arr: interpret 'max_pages = 0' as unlimited
By default, git-arr limits the number of pages of commits to 5, however,
it is reasonable to expect that some projects will want all commits to
be shown. Rather than forcing such projects to choose an arbitrarily
large number as the value of 'max_pages', provide a formal mechanism to
specify unlimited commit pages.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:18 +00:00
Eric Sunshine
d7604dab4d write_tree: suppress double-slash in blob HTML filename
When emitting a blob in the root tree of a commit, write_tree() composes
the blob's HTML filename with an extra slash before the "f=", like this:

    output/r/repo/b/master/t//f=README.txt.html

Although the double-slash is not harmful on Unix, it is unsightly, and
may be problematic for other platforms or filesystems which interpret
double-slash specially or disallow it. Therefore, suppress the extra
slash for blobs in the root tree.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:14 +00:00
Eric Sunshine
aaf2968538 route: commit: match only hexadecimal rather than digits + full alphabet
A human-readable representation of a Git SHA1 commit ID is composed
only of hexadecimal digits, thus there is no need to match against
the full alphabet.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:10 +00:00
Eric Sunshine
420afd3206 views: summary: suppress extra horizontal rule when no "master" branch
When a repository has a "master" branch, a short summary of its most
recent commits is shown, followed by a horizontal rule. If there is no
"master" branch, then the commit summary is suppressed, however, the
rule is shown unconditionally, which is ugly, particularly when there
is already a rule following the web_url/git_url block. Therefore,
suppress the "master" branch horizontal rule when not needed. (This is
analogous to how the rule following the web_url/git_url block is
suppressed when that information is not shown).

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:06 +00:00
Eric Sunshine
605421f2d6 sample.conf: document embed_markdown and embed_images
These repo-specific options were added in 54026b75 (Make embedding
markdown and images configurable per-repo, 2013-11-02) but not
documented.

Signed-off-by: Eric Sunshine <sunshine@sunshineco.com>
Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2015-01-11 21:18:00 +00:00
Alberto Bertogli
df00293a7c git: Add '--' to "git rev-list" runs to avoid ambiguous arguments
If there is a branch and a file with the same name, git-arr will fail to
generate, as git will complain when running git rev-list.

For example, if there is both a file and a branch called "hooks" in the
repository, git-arr would fail as follows:

=== git-arr running: ['git', '--git-dir=/some/repo', 'rev-list', '--max-count=1', '--header', u'hooks'])
fatal: ambiguous argument 'hooks': both revision and filename
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
Traceback (most recent call last):
  File "./git-arr", line 457, in <module>
    main()
  File "./git-arr", line 452, in main
    skip_index = len(opts.only) > 0)
  File "./git-arr", line 388, in generate
    branch_mtime = r.commit(bn).committer_date.epoch
AttributeError: 'NoneType' object has no attribute 'committer_date'

To fix that, this patch appends a "--" as the last argument to rev-list, which
indicates that it has completed the revision list, which disambiguates the
argument.

While at it, a minor typo in a comment is also fixed.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2014-12-31 17:07:38 +00:00
Alberto Bertogli
7898b2becd git.py: Parse timestamps from UTC, not from local time
The current parsing of dates from git incorrectly uses
datetime.fromtimestamp(), which returns the *local* date and time
corresponding to the given timestamp.

Instead, it should be using datetime.utcfromtimestamp() which returns the UTC
date and time, as the rest of the code expects.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2014-10-05 22:15:54 +01:00
Alberto Bertogli
47d500715a views/tree.html: Fix lambda syntax
Some versions of bottle.py don't deal well with the "if" inside the lambda, so
work around it by just using comparison and simplifying the function.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2014-07-28 23:46:50 +01:00
Vanya Sergeev
eb7cadd64f Enable line number anchors when using pygments HtmlFormatter
Signed-off-by: Vanya Sergeev <vsergeev@gmail.com>
2014-07-03 00:56:19 +01:00
Vanya Sergeev
48a00cb460 Fix one-line 'if' termination in tree, blob templates
The missing '% end' template keyword to these one-line if statements was
causing bottle 0.12.7 to incorrectly indent the following line, leading to an
IndentationError at runtime when the blob and tree templates are compiled.

Signed-off-by: Vanya Sergeev <vsergeev@gmail.com>
2014-06-30 08:45:36 +01:00
Alberto Bertogli
2f65291ef1 Fix committer field in the commit view
The commit view shows the author information in the committer field; this
patch fixes it by showing the appropriate fields.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2013-11-03 10:57:54 +00:00
Alberto Bertogli
f6a75820e8 Work around HTTPError status code issues
It turned out that bottle.py is not backwards-compatible with the status code
change: older versions encode the status in e.status; newer ones use
e.status_code (and e.status became a string).

This patch works around that by trying to pick up which of the two variants we
have, and deciding accordingly.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2013-11-02 23:32:43 +00:00
Alberto Bertogli
e49c69da2e Show the age of a repository in the index, via javascript
This patch adds the age of the repository to the index view, using javascript
to give a nice human string for the age.

When javascript is not available, the element remains hidden.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2013-11-02 22:18:33 +00:00
Alberto Bertogli
6764bfcfd6 Use the status_code attribute to tell 404s appart
Newer versions of bottle have a string in the e.status attribute, and the
status code can be found in e.status_code, which should be backwards
compatible.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2013-11-02 21:15:23 +00:00
Alberto Bertogli
54026b7585 Make embedding markdown and images configurable per-repo
This patch introduces the embed_markdown and embed_images configuration
options, so users can enable and disable those features on a per-repository
basis.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2013-11-02 21:12:50 +00:00
Alberto Bertogli
a42d7da6a4 utils: Make the embedded image code use mimetypes
This patch makes minor changes to the code that handles embedded images,
mostly to make it use mimetypes, and to remove SVG support (at least for now)
due to security concerns.

Signed-off-by: Alberto Bertogli <albertito@blitiri.com.ar>
2013-11-02 19:14:36 +00:00
Vanya Sergeev
21522f8a3a Add embed data URI image blob support 2013-11-02 19:07:59 +00:00
Vanya Sergeev
f62ca211eb Add markdown blob support 2013-11-02 19:03:59 +00:00
Vanya Sergeev
d3bf98ea00 Fix parsing of empty commit messages 2013-10-12 01:19:57 +01:00
22 changed files with 1359 additions and 612 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.pyc
__pycache__
.*.swp
.*
!.gitignore

56
README
View File

@ -1,56 +0,0 @@
git-arr - A git repository browser
----------------------------------
git-arr is a git repository browser that can generate static HTML instead of
having to run dynamically.
It is smaller, with less features and a different set of tradeoffs than
other similar software, so if you're looking for a robust and featureful git
browser, please look at gitweb or cgit instead.
However, if you want to generate static HTML at the expense of features, then
it's probably going to be useful.
It's open source under the MIT licence, please see the LICENSE file for more
information.
Getting started
---------------
You will need Python, and the bottle.py framework (the package is usually
called python-bottle in most distributions).
If pygments is available, it will be used for syntax highlighting, otherwise
everything will work fine, just in black and white.
First, create a configuration file for your repositories. You can start by
copying sample.conf, which has the list of the available options.
Then, to generate the output to "/var/www/git-arr/" directory, run:
$ ./git-arr --config config.conf generate --output /var/www/git-arr/
That's it!
The first time you generate, depending on the size of your repositories, it
can take some time. Subsequent runs should take less time, as it is smart
enough to only generate what has changed.
You can also use git-arr dynamically, although it's not its intended mode of
use, by running:
$ ./git-arr --config config.conf serve
That can be useful when making changes to the software itself.
Contact
-------
If you want to report bugs, send patches, or have any questions or comments,
just let me know at albertito@blitiri.com.ar.

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# git-arr - A git repository browser
[git-arr] is a [git] repository browser that can generate static HTML.
It is smaller, with less features and a different set of tradeoffs than
other similar software, so if you're looking for a robust and featureful git
browser, please look at [gitweb] or [cgit] instead.
However, if you want to generate static HTML at the expense of features, then
it's probably going to be useful.
It's open source under the MIT licence, please see the `LICENSE` file for more
information.
[git-arr]: https://blitiri.com.ar/p/git-arr/
[git]: https://git-scm.com/
[gitweb]: https://git-scm.com/docs/gitweb
[cgit]: https://git.zx2c4.com/cgit/about/
## Getting started
You will need [Python 3], and the [bottle.py] framework (the package is usually
called `python3-bottle` in most distributions).
If [pygments] is available, it will be used for syntax highlighting, otherwise
everything will work fine, just in black and white.
First, create a configuration file for your repositories. You can start by
copying `sample.conf`, which has the list of the available options.
Then, to generate the output to `/var/www/git-arr/` directory, run:
```sh
./git-arr --config config.conf generate --output /var/www/git-arr/
```
That's it!
The first time you generate, depending on the size of your repositories, it
can take some time. Subsequent runs should take less time, as it is smart
enough to only generate what has changed.
You can also use git-arr dynamically, although it's not its intended mode of
use, by running:
```
./git-arr --config config.conf serve
```
That can be useful when making changes to the software itself.
[Python 3]: https://www.python.org/
[bottle.py]: https://bottlepy.org/
[pygments]: https://pygments.org/
## Contact
If you want to report bugs, send patches, or have any questions or comments,
just let me know at albertito@blitiri.com.ar.

13
TODO
View File

@ -1,13 +0,0 @@
In no particular order.
- Atom/RSS.
- Nicer diff:
- Better stat section, with nicer handling of filenames. We should switch to
--patch-with-raw and parse from that.
- Nicer output, don't use pygments but do our own.
- Anchors in diff sections so we can link to them.
- Short symlinks to commits, with configurable length.
- Handle symlinks properly.
- "X hours ago" via javascript (only if it's not too ugly).

469
git-arr
View File

@ -1,21 +1,17 @@
#!/usr/bin/env python
#!/usr/bin/env python3
"""
git-arr: A git web html generator.
"""
from __future__ import print_function
import sys
import os
import configparser
import math
import optparse
import os
import re
import sys
from typing import Union
try:
import configparser
except ImportError:
import ConfigParser as configparser
import bottle
import bottle # type: ignore
import git
import utils
@ -25,12 +21,13 @@ import utils
# Note this assumes they live next to the executable, and that is not a good
# assumption; but it's good enough for now.
bottle.TEMPLATE_PATH.insert(
0, os.path.abspath(os.path.dirname(sys.argv[0])) + '/views/')
0, os.path.abspath(os.path.dirname(sys.argv[0])) + "/views/"
)
# The path to our static files.
# Note this assumes they live next to the executable, and that is not a good
# assumption; but it's good enough for now.
static_path = os.path.abspath(os.path.dirname(sys.argv[0])) + '/static/'
static_path = os.path.abspath(os.path.dirname(sys.argv[0])) + "/static/"
# The list of repositories is a global variable for convenience. It will be
@ -45,78 +42,100 @@ def load_config(path):
as configured.
"""
defaults = {
'tree': 'yes',
'desc': '',
'recursive': 'no',
'commits_in_summary': '10',
'commits_per_page': '50',
'max_pages': '5',
'web_url': '',
'web_url_file': 'web_url',
'git_url': '',
'git_url_file': 'cloneurl',
"tree": "yes",
"rootdiff": "yes",
"desc": "",
"recursive": "no",
"prefix": "",
"commits_in_summary": "10",
"commits_per_page": "50",
"max_pages": "250",
"web_url": "",
"web_url_file": "web_url",
"git_url": "",
"git_url_file": "cloneurl",
"embed_markdown": "yes",
"embed_images": "no",
"ignore": "",
"generate_patch": "yes",
}
config = configparser.SafeConfigParser(defaults)
config = configparser.ConfigParser(defaults)
config.read(path)
# Do a first pass for general sanity checking and recursive expansion.
for s in config.sections():
if config.getboolean(s, 'recursive'):
for path in os.listdir(config.get(s, 'path')):
fullpath = find_git_dir(config.get(s, 'path') + '/' + path)
if config.getboolean(s, "recursive"):
root = config.get(s, "path")
prefix = config.get(s, "prefix")
for path in os.listdir(root):
fullpath = find_git_dir(root + "/" + path)
if not fullpath:
continue
if os.path.exists(fullpath + '/disable_gitweb'):
if os.path.exists(fullpath + "/disable_gitweb"):
continue
if config.has_section(path):
section = prefix + path
if config.has_section(section):
continue
config.add_section(path)
for opt, value in config.items(s, raw = True):
config.set(path, opt, value)
config.add_section(section)
for opt, value in config.items(s, raw=True):
config.set(section, opt, value)
config.set(path, 'path', fullpath)
config.set(path, 'recursive', 'no')
config.set(section, "path", fullpath)
config.set(section, "recursive", "no")
# This recursive section is no longer useful.
config.remove_section(s)
for s in config.sections():
fullpath = find_git_dir(config.get(s, 'path'))
if config.get(s, "ignore") and re.search(config.get(s, "ignore"), s):
continue
fullpath = find_git_dir(config.get(s, "path"))
if not fullpath:
raise ValueError(
'%s: path %s is not a valid git repository' % (
s, config.get(s, 'path')))
"%s: path %s is not a valid git repository"
% (s, config.get(s, "path"))
)
config.set(s, 'path', fullpath)
config.set(s, 'name', s)
config.set(s, "path", fullpath)
config.set(s, "name", s)
desc = config.get(s, 'desc')
if not desc and os.path.exists(fullpath + '/description'):
desc = open(fullpath + '/description').read().strip()
desc = config.get(s, "desc")
if not desc and os.path.exists(fullpath + "/description"):
desc = open(fullpath + "/description").read().strip()
r = git.Repo(fullpath, name = s)
r = git.Repo(fullpath, name=s)
r.info.desc = desc
r.info.commits_in_summary = config.getint(s, 'commits_in_summary')
r.info.commits_per_page = config.getint(s, 'commits_per_page')
r.info.max_pages = config.getint(s, 'max_pages')
r.info.generate_tree = config.getboolean(s, 'tree')
r.info.commits_in_summary = config.getint(s, "commits_in_summary")
r.info.commits_per_page = config.getint(s, "commits_per_page")
r.info.max_pages = config.getint(s, "max_pages")
if r.info.max_pages <= 0:
r.info.max_pages = sys.maxsize
r.info.generate_tree = config.getboolean(s, "tree")
r.info.root_diff = config.getboolean(s, "rootdiff")
r.info.generate_patch = config.getboolean(s, "generate_patch")
r.info.web_url = config.get(s, 'web_url')
web_url_file = fullpath + '/' + config.get(s, 'web_url_file')
r.info.web_url = config.get(s, "web_url")
web_url_file = fullpath + "/" + config.get(s, "web_url_file")
if not r.info.web_url and os.path.isfile(web_url_file):
r.info.web_url = open(web_url_file).read()
r.info.git_url = config.get(s, 'git_url')
git_url_file = fullpath + '/' + config.get(s, 'git_url_file')
r.info.git_url = config.get(s, "git_url")
git_url_file = fullpath + "/" + config.get(s, "git_url_file")
if not r.info.git_url and os.path.isfile(git_url_file):
r.info.git_url = open(git_url_file).read()
r.info.embed_markdown = config.getboolean(s, "embed_markdown")
r.info.embed_images = config.getboolean(s, "embed_images")
repos[r.name] = r
def find_git_dir(path):
"""Returns the path to the git directory for the given repository.
@ -126,25 +145,26 @@ def find_git_dir(path):
An empty string is returned if the given path is not a valid repository.
"""
def check(p):
"""A dirty check for whether this is a git dir or not."""
# Note silent stderr because we expect this to fail and don't want the
# noise; and also we strip the final \n from the output.
return git.run_git(p,
['rev-parse', '--git-dir'],
silent_stderr = True).read()[:-1]
return git.run_git(
p, ["rev-parse", "--git-dir"], silent_stderr=True
).read()[:-1]
for p in [ path, path + '/.git' ]:
for p in [path, path + "/.git"]:
if check(p):
return p
return ''
return ""
def repo_filter(unused_conf):
"""Bottle route filter for repos."""
# TODO: consider allowing /, which is tricky.
regexp = r'[\w\.~-]+'
regexp = r"[\w\.~-]+"
def to_python(s):
"""Return the corresponding Python object."""
@ -158,24 +178,31 @@ def repo_filter(unused_conf):
return regexp, to_python, to_url
app = bottle.Bottle()
app.router.add_filter('repo', repo_filter)
app.router.add_filter("repo", repo_filter)
bottle.app.push(app)
def with_utils(f):
"""Decorator to add the utilities to the return value.
Used to wrap functions that return dictionaries which are then passed to
templates.
"""
utilities = {
'shorten': utils.shorten,
'can_colorize': utils.can_colorize,
'colorize_diff': utils.colorize_diff,
'colorize_blob': utils.colorize_blob,
'abort': bottle.abort,
'smstr': git.smstr,
"shorten": utils.shorten,
"can_colorize": utils.can_colorize,
"colorize_diff": utils.colorize_diff,
"colorize_blob": utils.colorize_blob,
"can_markdown": utils.can_markdown,
"markdown_blob": utils.markdown_blob,
"can_embed_image": utils.can_embed_image,
"embed_image_blob": utils.embed_image_blob,
"is_binary": utils.is_binary,
"hexdump": utils.hexdump,
"abort": bottle.abort,
"smstr": git.smstr,
}
def wrapped(*args, **kwargs):
@ -189,88 +216,131 @@ def with_utils(f):
return wrapped
@bottle.route('/')
@bottle.view('index')
@bottle.route("/")
@bottle.view("index")
@with_utils
def index():
return dict(repos = repos)
return dict(repos=repos)
@bottle.route('/r/<repo:repo>/')
@bottle.view('summary')
@bottle.route("/r/<repo:repo>/")
@bottle.view("summary")
@with_utils
def summary(repo):
return dict(repo = repo)
return dict(repo=repo)
@bottle.route('/r/<repo:repo>/b/<bname>/')
@bottle.route('/r/<repo:repo>/b/<bname>/<offset:int>.html')
@bottle.view('branch')
@with_utils
def branch(repo, bname, offset = 0):
return dict(repo = repo.new_in_branch(bname), offset = offset)
@bottle.route('/r/<repo:repo>/c/<cid:re:[0-9a-z]{5,40}>/')
@bottle.view('commit')
@bottle.route("/r/<repo:repo>/c/<cid:re:[0-9a-f]{5,40}>/")
@bottle.view("commit")
@with_utils
def commit(repo, cid):
c = repo.commit(cid)
if not c:
bottle.abort(404, 'Commit not found')
bottle.abort(404, "Commit not found")
return dict(repo = repo, c=c)
return dict(repo=repo, c=c)
@bottle.route('/r/<repo:repo>/b/<bname>/t/')
@bottle.route('/r/<repo:repo>/b/<bname>/t/<dirname:path>/')
@bottle.view('tree')
@bottle.route("/r/<repo:repo>/c/<cid:re:[0-9a-f]{5,40}>.patch")
@bottle.view(
"patch",
# Output is text/plain, don't do HTML escaping.
template_settings={"noescape": True},
)
def patch(repo, cid):
c = repo.commit(cid)
if not c:
bottle.abort(404, "Commit not found")
bottle.response.content_type = "text/plain; charset=utf8"
return dict(repo=repo, c=c)
@bottle.route("/r/<repo:repo>/b/<bname:path>/t/f=<fname:path>.html")
@bottle.route(
"/r/<repo:repo>/b/<bname:path>/t/<dirname:path>/f=<fname:path>.html"
)
@bottle.view("blob")
@with_utils
def tree(repo, bname, dirname = ''):
if dirname and not dirname.endswith('/'):
dirname = dirname + '/'
dirname = git.smstr.from_url(dirname)
r = repo.new_in_branch(bname)
return dict(repo = r, tree = r.tree(), dirname = dirname)
@bottle.route('/r/<repo:repo>/b/<bname>/t/f=<fname:path>.html')
@bottle.route('/r/<repo:repo>/b/<bname>/t/<dirname:path>/f=<fname:path>.html')
@bottle.view('blob')
@with_utils
def blob(repo, bname, fname, dirname = ''):
r = repo.new_in_branch(bname)
if dirname and not dirname.endswith('/'):
dirname = dirname + '/'
def blob(repo, bname, fname, dirname=""):
if dirname and not dirname.endswith("/"):
dirname = dirname + "/"
dirname = git.smstr.from_url(dirname)
fname = git.smstr.from_url(fname)
path = dirname.raw + fname.raw
content = r.blob(path)
# Handle backslash-escaped characters, which are not utf8.
# This matches the generated links from git.unquote().
path = path.encode("utf8").decode("unicode-escape").encode("latin1")
content = repo.blob(path, bname)
if content is None:
bottle.abort(404, "File %r not found in branch %s" % (path, bname))
return dict(repo = r, dirname = dirname, fname = fname, blob = content)
return dict(
repo=repo, branch=bname, dirname=dirname, fname=fname, blob=content
)
@bottle.route('/static/<path:path>')
@bottle.route("/r/<repo:repo>/b/<bname:path>/t/")
@bottle.route("/r/<repo:repo>/b/<bname:path>/t/<dirname:path>/")
@bottle.view("tree")
@with_utils
def tree(repo, bname, dirname=""):
if dirname and not dirname.endswith("/"):
dirname = dirname + "/"
dirname = git.smstr.from_url(dirname)
return dict(
repo=repo, branch=bname, tree=repo.tree(bname), dirname=dirname
)
@bottle.route("/r/<repo:repo>/b/<bname:path>/")
@bottle.route("/r/<repo:repo>/b/<bname:path>/<offset:int>.html")
@bottle.view("branch")
@with_utils
def branch(repo, bname, offset=0):
return dict(repo=repo, branch=bname, offset=offset)
@bottle.route("/static/<path:path>")
def static(path):
return bottle.static_file(path, root = static_path)
return bottle.static_file(path, root=static_path)
#
# Static HTML generation
#
def generate(output, skip_index = False):
def is_404(e):
"""True if e is an HTTPError with status 404, False otherwise."""
# We need this because older bottle.py versions put the status code in
# e.status as an integer, and newer versions make that a string, and using
# e.status_code for the code.
if isinstance(e.status, int):
return e.status == 404
else:
return e.status_code == 404
def generate(output: str, only=None):
"""Generate static html to the output directory."""
def write_to(path, func_or_str, args = (), mtime = None):
path = output + '/' + path
def write_to(path: str, func_or_str, args=(), mtime=None):
path = output + "/" + path
dirname = os.path.dirname(path)
if not os.path.exists(dirname):
os.makedirs(dirname)
if mtime:
path_mtime = 0
path_mtime: Union[float, int] = 0
if os.path.exists(path):
path_mtime = os.stat(path).st_mtime
@ -290,7 +360,7 @@ def generate(output, skip_index = False):
else:
# Otherwise, be lazy if we were given a function to run, or write
# always if they gave us a string.
if isinstance(func_or_str, (str, unicode)):
if isinstance(func_or_str, str):
print(path)
s = func_or_str
else:
@ -299,135 +369,184 @@ def generate(output, skip_index = False):
print(path)
s = func_or_str(*args)
open(path, 'w').write(s.encode('utf8', errors = 'xmlcharrefreplace'))
open(path, "w").write(s)
if mtime:
os.utime(path, (mtime, mtime))
def link(from_path, to_path):
from_path = output + '/' + from_path
from_path = output + "/" + from_path
if os.path.lexists(from_path):
return
print(from_path, '->', to_path)
print(from_path, "->", to_path)
os.symlink(to_path, from_path)
def write_tree(r, bn, mtime):
t = r.tree(bn)
def write_tree(r: git.Repo, bn: str, mtime):
t: git.Tree = r.tree(bn)
write_to('r/%s/b/%s/t/index.html' % (r.name, bn),
tree, (r, bn), mtime)
write_to("r/%s/b/%s/t/index.html" % (r.name, bn), tree, (r, bn), mtime)
for otype, oname, _ in t.ls('', recursive = True):
for otype, oname, _ in t.ls("", recursive=True):
# FIXME: bottle cannot route paths with '\n' so those are sadly
# expected to fail for now; we skip them.
if '\n' in oname.raw:
print('skipping file with \\n: %r' % (oname.raw))
if "\n" in oname.raw:
print("skipping file with \\n: %r" % (oname.raw))
continue
if otype == 'blob':
if otype == "blob":
dirname = git.smstr(os.path.dirname(oname.raw))
fname = git.smstr(os.path.basename(oname.raw))
write_to(
'r/%s/b/%s/t/%s/f=%s.html' %
(str(r.name), str(bn), dirname.raw, fname.raw),
blob, (r, bn, fname.url, dirname.url), mtime)
"r/%s/b/%s/t/%s%sf=%s.html"
% (
str(r.name),
str(bn),
dirname.raw,
"/" if dirname.raw else "",
fname.raw,
),
blob,
(r, bn, fname.url, dirname.url),
mtime,
)
else:
write_to('r/%s/b/%s/t/%s/index.html' %
(str(r.name), str(bn), oname.raw),
tree, (r, bn, oname.url), mtime)
write_to(
"r/%s/b/%s/t/%s/index.html"
% (str(r.name), str(bn), oname.raw),
tree,
(r, bn, oname.url),
mtime,
)
if not skip_index:
write_to('index.html', index())
# Always generate the index, to keep the "last updated" time fresh.
write_to("index.html", index())
# We can't call static() because it relies on HTTP headers.
read_f = lambda f: open(f).read()
write_to('static/git-arr.css', read_f, [static_path + '/git-arr.css'],
os.stat(static_path + '/git-arr.css').st_mtime)
write_to('static/syntax.css', read_f, [static_path + '/syntax.css'],
os.stat(static_path + '/syntax.css').st_mtime)
write_to(
"static/git-arr.css",
read_f,
[static_path + "/git-arr.css"],
os.stat(static_path + "/git-arr.css").st_mtime,
)
write_to(
"static/git-arr.js",
read_f,
[static_path + "/git-arr.js"],
os.stat(static_path + "/git-arr.js").st_mtime,
)
write_to(
"static/syntax.css",
read_f,
[static_path + "/syntax.css"],
os.stat(static_path + "/syntax.css").st_mtime,
)
for r in sorted(repos.values(), key = lambda r: r.name):
write_to('r/%s/index.html' % r.name, summary(r))
rs = sorted(list(repos.values()), key=lambda r: r.name)
if only:
rs = [r for r in rs if r.name in only]
for r in rs:
write_to("r/%s/index.html" % r.name, summary(r))
for bn in r.branch_names():
commit_count = 0
commit_ids = r.commit_ids('refs/heads/' + bn,
limit = r.info.commits_per_page * r.info.max_pages)
commit_ids = r.commit_ids(
"refs/heads/" + bn,
limit=r.info.commits_per_page * r.info.max_pages,
)
for cid in commit_ids:
write_to('r/%s/c/%s/index.html' % (r.name, cid),
commit, (r, cid))
write_to(
"r/%s/c/%s/index.html" % (r.name, cid), commit, (r, cid)
)
if r.info.generate_patch:
write_to(
"r/%s/c/%s.patch" % (r.name, cid), patch, (r, cid)
)
commit_count += 1
# To avoid regenerating files that have not changed, we will
# instruct write_to() to set their mtime to the branch's committer
# date, and then compare against it to decide wether or not to
# date, and then compare against it to decide whether or not to
# write.
branch_mtime = r.commit(bn).committer_date.epoch
nr_pages = int(math.ceil(
float(commit_count) / r.info.commits_per_page))
nr_pages = int(
math.ceil(float(commit_count) / r.info.commits_per_page)
)
nr_pages = min(nr_pages, r.info.max_pages)
for page in range(nr_pages):
write_to('r/%s/b/%s/%d.html' % (r.name, bn, page),
branch, (r, bn, page), branch_mtime)
write_to(
"r/%s/b/%s/%d.html" % (r.name, bn, page),
branch,
(r, bn, page),
branch_mtime,
)
link(from_path = 'r/%s/b/%s/index.html' % (r.name, bn),
to_path = '0.html')
link(
from_path="r/%s/b/%s/index.html" % (r.name, bn),
to_path="0.html",
)
if r.info.generate_tree:
write_tree(r, bn, branch_mtime)
for tag_name, obj_id in r.tags():
try:
write_to('r/%s/c/%s/index.html' % (r.name, obj_id),
commit, (r, obj_id))
write_to(
"r/%s/c/%s/index.html" % (r.name, obj_id),
commit,
(r, obj_id),
)
except bottle.HTTPError as e:
# Some repos can have tags pointing to non-commits. This
# happens in the Linux Kernel's v2.6.11, which points directly
# to a tree. Ignore them.
if e.status == 404:
print('404 in tag %s (%s)' % (tag_name, obj_id))
if is_404(e):
print("404 in tag %s (%s)" % (tag_name, obj_id))
else:
raise
def main():
parser = optparse.OptionParser('usage: %prog [options] serve|generate')
parser.add_option('-c', '--config', metavar = 'FILE',
help = 'configuration file')
parser.add_option('-o', '--output', metavar = 'DIR',
help = 'output directory (for generate)')
parser.add_option('', '--only', metavar = 'REPO', action = 'append',
default = [],
help = 'generate/serve only this repository')
parser = optparse.OptionParser("usage: %prog [options] serve|generate")
parser.add_option(
"-c", "--config", metavar="FILE", help="configuration file"
)
parser.add_option(
"-o", "--output", metavar="DIR", help="output directory (for generate)"
)
parser.add_option(
"",
"--only",
metavar="REPO",
action="append",
default=[],
help="generate/serve only this repository",
)
opts, args = parser.parse_args()
if not opts.config:
parser.error('--config is mandatory')
parser.error("--config is mandatory")
try:
load_config(opts.config)
except (configparser.NoOptionError, ValueError) as e:
print('Error parsing config:', e)
print("Error parsing config:", e)
return
if not args:
parser.error('Must specify an action (serve|generate)')
parser.error("Must specify an action (serve|generate)")
if opts.only:
for rname in list(repos.keys()):
if rname not in opts.only:
del repos[rname]
if args[0] == 'serve':
bottle.run(host = 'localhost', port = 8008, reloader = True)
elif args[0] == 'generate':
if args[0] == "serve":
bottle.run(host="localhost", port=8008, reloader=True)
elif args[0] == "generate":
if not opts.output:
parser.error('Must specify --output')
generate(output = opts.output,
skip_index = len(opts.only) > 0)
parser.error("Must specify --output")
generate(output=opts.output, only=opts.only)
else:
parser.error('Unknown action %s' % args[0])
parser.error("Unknown action %s" % args[0])
if __name__ == '__main__':
if __name__ == "__main__":
main()

457
git.py
View File

@ -6,97 +6,93 @@ command line tool directly, so please be careful with using untrusted
parameters.
"""
import functools
import sys
import io
import subprocess
from collections import defaultdict
import email.utils
import datetime
import urllib
from cgi import escape
import urllib.request, urllib.parse, urllib.error
from html import escape
from typing import Any, Dict, IO, Iterable, List, Optional, Tuple, Union
# Path to the git binary.
GIT_BIN = "git"
class EncodeWrapper:
"""File-like wrapper that returns data utf8 encoded."""
def __init__(self, fd, encoding = 'utf8', errors = 'replace'):
self.fd = fd
self.encoding = encoding
self.errors = errors
def __iter__(self):
for line in self.fd:
yield line.decode(self.encoding, errors = self.errors)
def read(self):
"""Returns the whole content."""
s = self.fd.read()
return s.decode(self.encoding, errors = self.errors)
def readline(self):
"""Returns a single line."""
s = self.fd.readline()
return s.decode(self.encoding, errors = self.errors)
def run_git(repo_path, params, stdin = None, silent_stderr = False):
def run_git(
repo_path: str, params, stdin: bytes = None, silent_stderr=False, raw=False
) -> Union[IO[str], IO[bytes]]:
"""Invokes git with the given parameters.
This function invokes git with the given parameters, and returns a
file-like object with the output (from a pipe).
"""
params = [GIT_BIN, '--git-dir=%s' % repo_path] + list(params)
params = [GIT_BIN, "--git-dir=%s" % repo_path] + list(params)
stderr = None
if silent_stderr:
stderr = subprocess.PIPE
if not stdin:
p = subprocess.Popen(params,
stdin = None, stdout = subprocess.PIPE, stderr = stderr)
p = subprocess.Popen(
params, stdin=None, stdout=subprocess.PIPE, stderr=stderr
)
else:
p = subprocess.Popen(params,
stdin = subprocess.PIPE, stdout = subprocess.PIPE,
stderr = stderr)
p = subprocess.Popen(
params,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stderr,
)
assert p.stdin is not None
p.stdin.write(stdin)
p.stdin.close()
# We need to wrap stdout if we want to decode it as utf8, subprocess
# doesn't support us telling it the encoding.
if sys.version_info.major == 3:
return io.TextIOWrapper(p.stdout, encoding = 'utf8',
errors = 'replace')
else:
return EncodeWrapper(p.stdout)
assert p.stdout is not None
if raw:
return p.stdout
return io.TextIOWrapper(
p.stdout, encoding="utf8", errors="backslashreplace"
)
class GitCommand (object):
class GitCommand(object):
"""Convenient way of invoking git."""
def __init__(self, path, cmd, *args, **kwargs):
def __init__(self, path: str, cmd: str):
self._override = True
self._path = path
self._cmd = cmd
self._args = list(args)
self._kwargs = {}
self._stdin_buf = None
self._args: List[str] = []
self._kwargs: Dict[str, str] = {}
self._stdin_buf: Optional[bytes] = None
self._raw = False
self._override = False
for k, v in kwargs:
self.__setattr__(k, v)
def __setattr__(self, k, v):
if k == '_override' or self._override:
if k == "_override" or self._override:
self.__dict__[k] = v
return
k = k.replace('_', '-')
k = k.replace("_", "-")
self._kwargs[k] = v
def arg(self, a):
def arg(self, a: str):
"""Adds an argument."""
self._args.append(a)
def stdin(self, s):
def raw(self, b: bool):
"""Request raw rather than utf8-encoded command output."""
self._override = True
self._raw = b
self._override = False
def stdin(self, s: bytes):
"""Sets the contents we will send in stdin."""
self._override = True
self._stdin_buf = s
@ -106,46 +102,37 @@ class GitCommand (object):
"""Runs the git command."""
params = [self._cmd]
for k, v in self._kwargs.items():
dash = '--' if len(k) > 1 else '-'
for k, v in list(self._kwargs.items()):
dash = "--" if len(k) > 1 else "-"
if v is None:
params.append('%s%s' % (dash, k))
params.append("%s%s" % (dash, k))
else:
params.append('%s%s=%s' % (dash, k, str(v)))
params.append("%s%s=%s" % (dash, k, str(v)))
params.extend(self._args)
return run_git(self._path, params, self._stdin_buf)
return run_git(self._path, params, self._stdin_buf, raw=self._raw)
class SimpleNamespace (object):
class SimpleNamespace(object):
"""An entirely flexible object, which provides a convenient namespace."""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class smstr:
"""A "smart" string, containing many representations for ease of use.
"""A "smart" string, containing many representations for ease of use."""
This is a string class that contains:
.raw -> raw string, authoritative source.
.unicode -> unicode representation, may not be perfect if .raw is not
proper utf8 but should be good enough to show.
.url -> escaped for safe embedding in URLs, can be not quite
readable.
.html -> an HTML-embeddable representation.
"""
def __init__(self, raw):
if not isinstance(raw, str):
raise TypeError("The raw string must be instance of 'str'")
self.raw = raw
self.unicode = raw.decode('utf8', errors = 'replace')
self.url = urllib.pathname2url(raw)
raw: str # string, probably utf8-encoded, good enough to show.
url: str # escaped for safe embedding in URLs (not human-readable).
html: str # HTML-embeddable representation.
def __init__(self, s: str):
self.raw = s
self.url = urllib.request.pathname2url(s)
self.html = self._to_html()
def __cmp__(self, other):
return cmp(self.raw, other.raw)
# Note we don't define __repr__() or __str__() to prevent accidental
# misuse. It does mean that some uses become more annoying, so it's a
# tradeoff that may change in the future.
@ -153,11 +140,11 @@ class smstr:
@staticmethod
def from_url(url):
"""Returns an smstr() instance from an url-encoded string."""
return smstr(urllib.url2pathname(url))
return smstr(urllib.request.url2pathname(url))
def split(self, sep):
"""Like str.split()."""
return [ smstr(s) for s in self.raw.split(sep) ]
return [smstr(s) for s in self.raw.split(sep)]
def __add__(self, other):
if isinstance(other, smstr):
@ -166,10 +153,10 @@ class smstr:
def _to_html(self):
"""Returns an html representation of the unicode string."""
html = u''
for c in escape(self.unicode):
if c in '\t\r\n\r\f\a\b\v\0':
esc_c = c.encode('ascii').encode('string_escape')
html = ""
for c in escape(self.raw):
if c in "\t\r\n\r\f\a\b\v\0":
esc_c = c.encode("unicode-escape").decode("utf8")
html += '<span class="ctrlchr">%s</span>' % esc_c
else:
html += c
@ -177,17 +164,26 @@ class smstr:
return html
def unquote(s):
def unquote(s: str):
"""Git can return quoted file names, unquote them. Always return a str."""
if not (s[0] == '"' and s[-1] == '"'):
# Unquoted strings are always safe, no need to mess with them; just
# make sure we return str.
s = s.encode('ascii')
# Unquoted strings are always safe, no need to mess with them
return s
# Get rid of the quotes, we never want them in the output, and convert to
# a raw string, un-escaping the backslashes.
s = s[1:-1].decode('string-escape')
# The string will be of the form `"<escaped>"`, where <escaped> is a
# backslash-escaped representation of the name of the file.
# Examples: "with\ttwo\ttabs" , "\303\261aca-utf8", "\361aca-latin1"
# Get rid of the quotes, we never want them in the output.
s = s[1:-1]
# Un-escape the backslashes.
# latin1 is ok to use here because in Python it just maps the code points
# 0-255 to the bytes 0x-0xff, which is what we expect.
s = s.encode("latin1").decode("unicode-escape")
# Convert to utf8.
s = s.encode("latin1").decode("utf8", errors="backslashreplace")
return s
@ -195,93 +191,86 @@ def unquote(s):
class Repo:
"""A git repository."""
def __init__(self, path, branch = None, name = None, info = None):
def __init__(self, path: str, name=None, info=None):
self.path = path
self.branch = branch
# We don't need these, but provide them for the users' convenience.
self.name = name
self.info = info or SimpleNamespace()
self.info: Any = info or SimpleNamespace()
def cmd(self, cmd):
"""Returns a GitCommand() on our path."""
return GitCommand(self.path, cmd)
def for_each_ref(self, pattern = None, sort = None):
@functools.lru_cache
def _for_each_ref(self, pattern=None, sort=None, count=None):
"""Returns a list of references."""
cmd = self.cmd('for-each-ref')
cmd = self.cmd("for-each-ref")
if sort:
cmd.sort = sort
if count:
cmd.count = count
if pattern:
cmd.arg(pattern)
refs = []
for l in cmd.run():
obj_id, obj_type, ref = l.split()
yield obj_id, obj_type, ref
def branches(self, sort = '-authordate'):
"""Get the (name, obj_id) of the branches."""
refs = self.for_each_ref(pattern = 'refs/heads/', sort = sort)
for obj_id, _, ref in refs:
yield ref[len('refs/heads/'):], obj_id
refs.append((obj_id, obj_type, ref))
return refs
@functools.cache
def branch_names(self):
"""Get the names of the branches."""
return ( name for name, _ in self.branches() )
refs = self._for_each_ref(pattern="refs/heads/", sort="-authordate")
return [ref[len("refs/heads/") :] for _, _, ref in refs]
def tags(self, sort = '-taggerdate'):
@functools.cache
def tags(self, sort="-taggerdate"):
"""Get the (name, obj_id) of the tags."""
refs = self.for_each_ref(pattern = 'refs/tags/', sort = sort)
for obj_id, _, ref in refs:
yield ref[len('refs/tags/'):], obj_id
refs = self._for_each_ref(pattern="refs/tags/", sort=sort)
return [(ref[len("refs/tags/") :], obj_id) for obj_id, _, ref in refs]
def tag_names(self):
"""Get the names of the tags."""
return ( name for name, _ in self.tags() )
def new_in_branch(self, branch):
"""Returns a new Repo, but on the specific branch."""
return Repo(self.path, branch = branch, name = self.name,
info = self.info)
def commit_ids(self, ref, limit = None):
@functools.lru_cache
def commit_ids(self, ref, limit=None):
"""Generate commit ids."""
cmd = self.cmd('rev-list')
cmd = self.cmd("rev-list")
if limit:
cmd.max_count = limit
cmd.arg(ref)
cmd.arg("--")
for l in cmd.run():
yield l.rstrip('\n')
return [l.rstrip("\n") for l in cmd.run()]
@functools.lru_cache
def commit(self, commit_id):
"""Return a single commit."""
cs = list(self.commits(commit_id, limit = 1))
cs = list(self.commits(commit_id, limit=1))
if len(cs) != 1:
return None
return cs[0]
def commits(self, ref, limit = None, offset = 0):
@functools.lru_cache
def commits(self, ref, limit, offset=0):
"""Generate commit objects for the ref."""
cmd = self.cmd('rev-list')
if limit:
cmd.max_count = limit + offset
cmd = self.cmd("rev-list")
cmd.max_count = limit + offset
cmd.header = None
cmd.arg(ref)
cmd.arg("--")
info_buffer = ''
info_buffer = ""
count = 0
commits = []
for l in cmd.run():
if '\0' in l:
pre, post = l.split('\0', 1)
if "\0" in l:
pre, post = l.split("\0", 1)
info_buffer += pre
count += 1
if count > offset:
yield Commit.from_str(self, info_buffer)
commits.append(Commit.from_str(self, info_buffer))
# Start over.
info_buffer = post
@ -291,14 +280,19 @@ class Repo:
if info_buffer:
count += 1
if count > offset:
yield Commit.from_str(self, info_buffer)
commits.append(Commit.from_str(self, info_buffer))
return commits
@functools.lru_cache
def diff(self, ref):
"""Return a Diff object for the ref."""
cmd = self.cmd('diff-tree')
cmd = self.cmd("diff-tree")
cmd.patch = None
cmd.numstat = None
cmd.find_renames = None
if self.info.root_diff:
cmd.root = None
# Note we intentionally do not use -z, as the filename is just for
# reference, and it is safer to let git do the escaping.
@ -306,52 +300,72 @@ class Repo:
return Diff.from_str(cmd.run())
@functools.lru_cache
def refs(self):
"""Return a dict of obj_id -> ref."""
cmd = self.cmd('show-ref')
cmd = self.cmd("show-ref")
cmd.dereference = None
r = defaultdict(list)
for l in cmd.run():
l = l.strip()
obj_id, ref = l.split(' ', 1)
obj_id, ref = l.split(" ", 1)
r[obj_id].append(ref)
return r
def tree(self, ref = None):
@functools.lru_cache
def tree(self, ref):
"""Returns a Tree instance for the given ref."""
if not ref:
ref = self.branch
return Tree(self, ref)
def blob(self, path, ref = None):
"""Returns the contents of the given path."""
if not ref:
ref = self.branch
cmd = self.cmd('cat-file')
cmd.batch = None
@functools.lru_cache
def blob(self, path, ref):
"""Returns a Blob instance for the given path."""
cmd = self.cmd("cat-file")
cmd.raw(True)
cmd.batch = "%(objectsize)"
if isinstance(ref, unicode):
ref = ref.encode('utf8')
cmd.stdin('%s:%s' % (ref, path))
# Format: <ref>:<path>
# Construct it in binary since the path might not be utf8.
cmd.stdin(ref.encode("utf8") + b":" + path)
out = cmd.run()
head = out.readline()
if not head or head.strip().endswith('missing'):
if not head or head.strip().endswith(b"missing"):
return None
return out.read()
return Blob(out.read()[: int(head)])
@functools.cache
def last_commit_timestamp(self):
"""Return the timestamp of the last commit."""
refs = self._for_each_ref(
pattern="refs/heads/", sort="-committerdate", count=1
)
for obj_id, _, _ in refs:
commit = self.commit(obj_id)
return commit.committer_epoch
return -1
class Commit (object):
class Commit(object):
"""A git commit."""
def __init__(self, repo,
commit_id, parents, tree,
author, author_epoch, author_tz,
committer, committer_epoch, committer_tz,
message):
def __init__(
self,
repo,
commit_id,
parents,
tree,
author,
author_epoch,
author_tz,
committer,
committer_epoch,
committer_tz,
message,
):
self._repo = repo
self.id = commit_id
self.parents = parents
@ -364,28 +378,30 @@ class Commit (object):
self.committer_tz = committer_tz
self.message = message
self.author_name, self.author_email = \
email.utils.parseaddr(self.author)
self.author_name, self.author_email = email.utils.parseaddr(
self.author
)
self.committer_name, self.committer_email = \
email.utils.parseaddr(self.committer)
self.committer_name, self.committer_email = email.utils.parseaddr(
self.committer
)
self.subject, self.body = self.message.split('\n', 1)
self.subject, self.body = self.message.split("\n", 1)
self.author_date = Date(self.author_epoch, self.author_tz)
self.committer_date = Date(self.committer_epoch, self.committer_tz)
# Only get this lazily when we need it; most of the time it's not
# required by the caller.
self._diff = None
def __repr__(self):
return '<C %s p:%s a:%s s:%r>' % (
self.id[:7],
','.join(p[:7] for p in self.parents),
self.author_email,
self.subject[:20])
return "<C %s p:%s a:%s s:%r>" % (
self.id[:7],
",".join(p[:7] for p in self.parents),
self.author_email,
self.subject[:20],
)
@property
def diff(self):
@ -397,52 +413,68 @@ class Commit (object):
@staticmethod
def from_str(repo, buf):
"""Parses git rev-list output, returns a commit object."""
header, raw_message = buf.split('\n\n', 1)
if "\n\n" in buf:
# Header, commit message
header, raw_message = buf.split("\n\n", 1)
else:
# Header only, no commit message
header, raw_message = buf.rstrip(), " "
header_lines = header.split('\n')
header_lines = header.split("\n")
commit_id = header_lines.pop(0)
header_dict = defaultdict(list)
for line in header_lines:
k, v = line.split(' ', 1)
k, v = line.split(" ", 1)
header_dict[k].append(v)
tree = header_dict['tree'][0]
parents = set(header_dict['parent'])
author, author_epoch, author_tz = \
header_dict['author'][0].rsplit(' ', 2)
committer, committer_epoch, committer_tz = \
header_dict['committer'][0].rsplit(' ', 2)
tree = header_dict["tree"][0]
parents = set(header_dict["parent"])
authorhdr = header_dict["author"][0]
author, author_epoch, author_tz = authorhdr.rsplit(" ", 2)
committerhdr = header_dict["committer"][0]
committer, committer_epoch, committer_tz = committerhdr.rsplit(" ", 2)
# Remove the first four spaces from the message's lines.
message = ''
for line in raw_message.split('\n'):
message += line[4:] + '\n'
message = ""
for line in raw_message.split("\n"):
message += line[4:] + "\n"
return Commit(
repo,
commit_id=commit_id,
tree=tree,
parents=parents,
author=author,
author_epoch=author_epoch,
author_tz=author_tz,
committer=committer,
committer_epoch=committer_epoch,
committer_tz=committer_tz,
message=message,
)
return Commit(repo,
commit_id = commit_id, tree = tree, parents = parents,
author = author,
author_epoch = author_epoch, author_tz = author_tz,
committer = committer,
committer_epoch = committer_epoch, committer_tz = committer_tz,
message = message)
class Date:
"""Handy representation for a datetime from git."""
def __init__(self, epoch, tz):
self.epoch = int(epoch)
self.tz = tz
self.utc = datetime.datetime.fromtimestamp(self.epoch)
self.utc = datetime.datetime.utcfromtimestamp(self.epoch)
self.tz_sec_offset_min = int(tz[1:3]) * 60 + int(tz[4:])
if tz[0] == '-':
if tz[0] == "-":
self.tz_sec_offset_min = -self.tz_sec_offset_min
self.local = self.utc + datetime.timedelta(
minutes = self.tz_sec_offset_min)
minutes=self.tz_sec_offset_min
)
self.str = self.utc.strftime('%a, %d %b %Y %H:%M:%S +0000 ')
self.str += '(%s %s)' % (self.local.strftime('%H:%M'), self.tz)
self.str = self.utc.strftime("%a, %d %b %Y %H:%M:%S +0000 ")
self.str += "(%s %s)" % (self.local.strftime("%H:%M"), self.tz)
def __str__(self):
return self.str
@ -450,6 +482,7 @@ class Date:
class Diff:
"""A diff between two trees."""
def __init__(self, ref, changes, body):
"""Constructor.
@ -469,60 +502,82 @@ class Diff:
ref_id = next(lines)
except StopIteration:
# No diff; this can happen in merges without conflicts.
return Diff(None, [], '')
return Diff(None, [], "")
# First, --numstat information.
changes = []
l = next(lines)
while l != '\n':
l = l.rstrip('\n')
added, deleted, fname = l.split('\t', 2)
added = added.replace('-', '0')
deleted = deleted.replace('-', '0')
while l != "\n":
l = l.rstrip("\n")
added, deleted, fname = l.split("\t", 2)
added = added.replace("-", "0")
deleted = deleted.replace("-", "0")
fname = smstr(unquote(fname))
changes.append((int(added), int(deleted), fname))
l = next(lines)
# And now the diff body. We just store as-is, we don't really care for
# the contents.
body = ''.join(lines)
body = "".join(lines)
return Diff(ref_id, changes, body)
class Tree:
""" A git tree."""
"""A git tree."""
def __init__(self, repo, ref):
def __init__(self, repo: Repo, ref: str):
self.repo = repo
self.ref = ref
def ls(self, path, recursive = False):
@functools.lru_cache
def ls(
self, path, recursive=False
) -> Iterable[Tuple[str, smstr, Optional[int]]]:
"""Generates (type, name, size) for each file in path."""
cmd = self.repo.cmd('ls-tree')
cmd = self.repo.cmd("ls-tree")
cmd.long = None
if recursive:
cmd.r = None
cmd.t = None
cmd.arg(self.ref)
cmd.arg(path)
if not path:
cmd.arg(".")
else:
cmd.arg(path)
files = []
for l in cmd.run():
_mode, otype, _oid, size, name = l.split(None, 4)
if size == '-':
if size == "-":
size = None
else:
size = int(size)
# Remove the quoting (if any); will always give us a str.
name = unquote(name.strip('\n'))
name = unquote(name.strip("\n"))
# Strip the leading path, the caller knows it and it's often
# easier to work with this way.
name = name[len(path):]
name = name[len(path) :]
# We use a smart string for the name, as it's often tricky to
# manipulate otherwise.
yield otype, smstr(name), size
files.append((otype, smstr(name), size))
return files
class Blob:
"""A git blob."""
def __init__(self, raw_content: bytes):
self.raw_content = raw_content
self._utf8_content = None
@property
def utf8_content(self):
if not self._utf8_content:
self._utf8_content = self.raw_content.decode("utf8", "replace")
return self._utf8_content

3
pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[tool.black]
line-length = 79
include = "(git-arr|git.py|utils.py)$"

View File

@ -11,6 +11,15 @@ path = /srv/git/repo/
# Useful to disable an expensive operation in very large repositories.
#tree = yes
# Show a "creation event" diff for a root commit? (optional)
# For projects placed under revision control from inception, the root commit
# diff is often meaningful. However, in cases when a well established, large
# project is placed under revision control belatedly, the root commit may
# represent a lump import of the entire project, in which case such a
# "creation event" diff would likely be considered meaningless noise.
# Default: yes
#rootdiff = yes
# How many commits to show in the summary page (optional).
#commits_in_summary = 10
@ -19,8 +28,9 @@ path = /srv/git/repo/
# Maximum number of per-branch pages for static generation (optional).
# When generating static html, this is the maximum number of pages we will
# generate for each branch's commit listings.
#max_pages = 5
# generate for each branch's commit listings. Zero (0) means unlimited.
# Default: 250
#max_pages = 250
# Project website (optional).
# URL to the project's website. %(name)s will be replaced with the current
@ -48,6 +58,22 @@ path = /srv/git/repo/
# excluded.
#recursive = no
# Render Markdown blobs (*.md) formatted rather than as raw text? (optional)
# Requires 'markdown' module.
# Default: yes
#embed_markdown = yes
# Render image blobs graphically rather than as raw binary data? (optional)
# Default: no
#embed_images = no
# Ignore repositories that match this regular expression.
# Generally used with recursive = yes, to ignore repeated repositories (for
# example, if using symlinks).
# For ignoring specific repositories, putting a "disable_gitweb" is a much
# better alternative.
# Default: empty (don't ignore)
#ignore = \.git$
# Another repository, we don't generate a tree for it because it's too big.
[linux]

View File

@ -1,17 +1,53 @@
/*
* git-arr style sheet
*/
:root {
--body-bg: white;
--text-fg: black;
--h1-bg: #ddd;
--hr-bg: #e3e3e3;
--text-lowcontrast-fg: grey;
--a-fg: #800;
--a-explicit-fg: #038;
--table-hover-bg: #eee;
--head-bg: #88ff88;
--tag-bg: #ffff88;
--age-fg0: darkgreen;
--age-fg1: green;
--age-fg2: seagreen;
--diff-added-fg: green;
--diff-deleted-fg: red;
}
@media (prefers-color-scheme: dark) {
:root {
--body-bg: #121212;
--text-fg: #c9d1d9;
--h1-bg: #2f2f2f;
--hr-bg: #e3e3e3;
--text-lowcontrast-fg: grey;
--a-fg: #d4b263;
--a-explicit-fg: #44b4ec;
--table-hover-bg: #313131;
--head-bg: #020;
--tag-bg: #333000;
--age-fg0: #51a552;
--age-fg1: #468646;
--age-fg2: #2f722f;
--diff-added-fg: #00A000;
--diff-deleted-fg: #A00000;
}
}
body {
font-family: sans-serif;
font-size: small;
padding: 0 1em 1em 1em;
color: var(--text-fg);
background: var(--body-bg);
}
h1 {
font-size: x-large;
background: #ddd;
background: var(--h1-bg);
padding: 0.3em;
}
@ -23,146 +59,279 @@ h2, h3 {
hr {
border: none;
background-color: #e3e3e3;
background-color: var(--hr-bg);
height: 1px;
}
/* By default, use implied links, more discrete for increased readability. */
a {
text-decoration: none;
color: black;
color: var(--text-fg);
}
a:hover {
text-decoration: underline;
color: #800;
color: var(--a-fg);
}
/* Explicit links */
a.explicit {
color: #038;
color: var(--a-explicit-fg);
}
a.explicit:hover, a.explicit:active {
color: #880000;
color: var(--a-fg);
}
/* Normal table, for listing things like repositories, branches, etc. */
table.nice {
text-align: left;
font-size: small;
}
table.nice td {
padding: 0.15em 0.5em;
}
table.nice td.links {
font-size: smaller;
}
table.nice td.main {
min-width: 10em;
}
table.nice tr:hover {
background: #eee;
background: var(--table-hover-bg);
}
/* Table for commits. */
table.commits td.date {
font-style: italic;
color: gray;
color: var(--text-lowcontrast-fg);
}
table.commits td.subject {
min-width: 32em;
@media (min-width: 600px) {
table.commits td.subject {
min-width: 32em;
}
}
table.commits td.author {
color: gray;
color: var(--text-lowcontrast-fg);
}
/* Table for commit information. */
table.commit-info tr:hover {
background: inherit;
}
table.commit-info td {
vertical-align: top;
}
table.commit-info span.date, span.email {
color: gray;
color: var(--text-lowcontrast-fg);
}
/* Reference annotations. */
span.refs {
margin: 0px 0.5em;
padding: 0px 0.25em;
border: solid 1px gray;
border: solid 1px var(--text-lowcontrast-fg);
}
span.head {
background-color: #88ff88;
background-color: var(--head-bg);
}
span.tag {
background-color: #ffff88;
background-color: var(--tag-bg);
}
/* Projects table */
table.projects td.name a {
color: var(--a-explicit-fg);
}
/* Age of an object.
* Note this is hidden by default as we rely on javascript to show it. */
span.age {
display: none;
color: var(--text-lowcontrast-fg);
font-size: smaller;
}
span.age-band0 {
color: var(--age-fg0);
}
span.age-band1 {
color: var(--age-fg1);
}
span.age-band2 {
color: var(--age-fg2);
}
/* Toggable titles */
div.toggable-title {
font-weight: bold;
margin-bottom: 0.3em;
}
pre {
/* Sometimes, <pre> elements (commit messages, diffs, blobs) have very
* long lines. In those case, use automatic overflow, which will
* introduce a horizontal scroll bar for this element only (more
* comfortable than stretching the page, which is the default). */
overflow: auto;
}
/* Commit message and diff. */
pre.commit-message {
font-size: large;
padding: 0.2em 2em;
padding: 0.2em 0.5em;
}
pre.diff-body {
/* Note this is only used as a fallback if pygments is not available. */
font-size: medium;
}
table.changed-files {
font-family: monospace;
}
table.changed-files span.lines-added {
color: green;
color: var(--diff-added-fg);
}
table.changed-files span.lines-deleted {
color: red;
color: var(--diff-deleted-fg);
}
/* Pagination. */
div.paginate {
padding-bottom: 1em;
}
div.paginate span.inactive {
color: gray;
color: var(--text-lowcontrast-fg);
}
/* Directory listing. */
table.ls td.name {
min-width: 20em;
@media (min-width: 600px) {
table.ls td.name {
min-width: 20em;
}
}
table.ls {
font-family: monospace;
font-size: larger;
}
table.ls tr.blob td.size {
color: gray;
color: var(--text-lowcontrast-fg);
}
/* Blob. */
pre.blob-body {
/* Note this is only used as a fallback if pygments is not available. */
font-size: medium;
}
table.blob-binary pre {
padding: 0;
margin: 0;
}
table.blob-binary .offset {
text-align: right;
font-size: x-small;
color: var(--text-lowcontrast-fg);
border-right: 1px solid var(--text-lowcontrast-fg);
}
table.blob-binary tr.etc {
text-align: center;
}
/* Pygments overrides. */
div.colorized-src {
font-size: larger;
}
div.colorized-src .source_code {
/* Ignore pygments style's background. */
background: var(--body-bg);
}
td.code > div.source_code {
/* This is a workaround, in pygments 2.11 there's a bug where the wrapper
* div is inside the table, so we need to override the descendant (because
* the style sets it on ".source_code" and the most specific value wins).
* Once we no longer support 2.11, we can remove this. */
background: var(--body-bg);
}
div.linenodiv {
padding-right: 0.5em;
color: gray;
font-size: medium;
}
div.source_code {
background: inherit;
font-size: medium;
div.linenodiv a {
color: var(--text-lowcontrast-fg);
}
/* Repository information table. */
table.repo_info tr:hover {
background: inherit;
}
table.repo_info td.category {
font-weight: bold;
/* So we can copy-paste rows and preserve spaces, useful for the row:
* git clone | url */
white-space: pre-wrap;
}
table.repo_info td {
vertical-align: top;
}
span.ctrlchr {
color: gray;
color: var(--text-lowcontrast-fg);
padding: 0 0.2ex 0 0.1ex;
margin: 0 0.2ex 0 0.1ex;
margin: 0 0.2ex 0 0.1ex;
}
/*
* Markdown overrides
*/
/* Colored links (same as explicit links above) */
div.markdown a {
color: var(--a-explicit-fg);
}
div.markdown a:hover, div.markdown a:active {
color: var(--a-fg);
}
/* Restrict max width for readability */
div.markdown {
max-width: 55em;
}

73
static/git-arr.js Normal file
View File

@ -0,0 +1,73 @@
/* Miscellaneous javascript functions for git-arr. */
/* Return the current timestamp. */
function now() {
return (new Date().getTime() / 1000);
}
/* Return a human readable string telling "how long ago" for a timestamp. */
function how_long_ago(timestamp) {
if (timestamp < 0)
return "never";
var seconds = Math.floor(now() - timestamp);
var interval = Math.floor(seconds / (365 * 24 * 60 * 60));
if (interval > 1)
return interval + " years ago";
interval = Math.floor(seconds / (30 * 24 * 60 * 60));
if (interval > 1)
return interval + " months ago";
interval = Math.floor(seconds / (24 * 60 * 60));
if (interval > 1)
return interval + " days ago";
interval = Math.floor(seconds / (60 * 60));
if (interval > 1)
return interval + " hours ago";
interval = Math.floor(seconds / 60);
if (interval > 1)
return interval + " minutes ago";
if (seconds > 1)
return Math.floor(seconds) + " seconds ago";
return "about now";
}
/* Go through the document and replace the contents of the span.age elements
* with a human-friendly variant, and then show them. */
function replace_timestamps() {
var elements = document.getElementsByClassName("age");
for (var i = 0; i < elements.length; i++) {
var e = elements[i];
var timestamp = e.innerHTML;
e.innerHTML = how_long_ago(timestamp);
e.style.display = "inline";
if (timestamp > 0) {
var age = now() - timestamp;
if (age < (2 * 60 * 60))
e.className = e.className + " age-band0";
else if (age < (3 * 24 * 60 * 60))
e.className = e.className + " age-band1";
else if (age < (30 * 24 * 60 * 60))
e.className = e.className + " age-band2";
}
}
}
function toggle(id) {
var e = document.getElementById(id);
if (e.style.display == "") {
e.style.display = "none"
} else if (e.style.display == "none") {
e.style.display = ""
}
}

View File

@ -1,30 +1,37 @@
/* CSS for syntax highlighting.
* Generated by pygments (what we use for syntax highlighting):
* Generated by pygments (what we use for syntax highlighting).
*
* $ pygmentize -S default -f html -a .source_code
* Light mode: pygmentize -S default -f html -a .source_code
*/
pre { line-height: 125%; }
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.source_code .hll { background-color: #ffffcc }
.source_code { background: #f8f8f8; }
.source_code .c { color: #408080; font-style: italic } /* Comment */
.source_code { background: #f8f8f8; }
.source_code .c { color: #3D7B7B; font-style: italic } /* Comment */
.source_code .err { border: 1px solid #FF0000 } /* Error */
.source_code .k { color: #008000; font-weight: bold } /* Keyword */
.source_code .o { color: #666666 } /* Operator */
.source_code .cm { color: #408080; font-style: italic } /* Comment.Multiline */
.source_code .cp { color: #BC7A00 } /* Comment.Preproc */
.source_code .c1 { color: #408080; font-style: italic } /* Comment.Single */
.source_code .cs { color: #408080; font-style: italic } /* Comment.Special */
.source_code .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
.source_code .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
.source_code .cp { color: #9C6500 } /* Comment.Preproc */
.source_code .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
.source_code .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
.source_code .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
.source_code .gd { color: #A00000 } /* Generic.Deleted */
.source_code .ge { font-style: italic } /* Generic.Emph */
.source_code .gr { color: #FF0000 } /* Generic.Error */
.source_code .gr { color: #E40000 } /* Generic.Error */
.source_code .gh { color: #000080; font-weight: bold } /* Generic.Heading */
.source_code .gi { color: #00A000 } /* Generic.Inserted */
.source_code .go { color: #808080 } /* Generic.Output */
.source_code .gi { color: #008400 } /* Generic.Inserted */
.source_code .go { color: #717171 } /* Generic.Output */
.source_code .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.source_code .gs { font-weight: bold } /* Generic.Strong */
.source_code .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.source_code .gt { color: #0040D0 } /* Generic.Traceback */
.source_code .gt { color: #0044DD } /* Generic.Traceback */
.source_code .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.source_code .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.source_code .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
@ -33,38 +40,139 @@
.source_code .kt { color: #B00040 } /* Keyword.Type */
.source_code .m { color: #666666 } /* Literal.Number */
.source_code .s { color: #BA2121 } /* Literal.String */
.source_code .na { color: #7D9029 } /* Name.Attribute */
.source_code .na { color: #687822 } /* Name.Attribute */
.source_code .nb { color: #008000 } /* Name.Builtin */
.source_code .nc { color: #0000FF; font-weight: bold } /* Name.Class */
.source_code .no { color: #880000 } /* Name.Constant */
.source_code .nd { color: #AA22FF } /* Name.Decorator */
.source_code .ni { color: #999999; font-weight: bold } /* Name.Entity */
.source_code .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
.source_code .ni { color: #717171; font-weight: bold } /* Name.Entity */
.source_code .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
.source_code .nf { color: #0000FF } /* Name.Function */
.source_code .nl { color: #A0A000 } /* Name.Label */
.source_code .nl { color: #767600 } /* Name.Label */
.source_code .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.source_code .nt { color: #008000; font-weight: bold } /* Name.Tag */
.source_code .nv { color: #19177C } /* Name.Variable */
.source_code .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.source_code .w { color: #bbbbbb } /* Text.Whitespace */
.source_code .mb { color: #666666 } /* Literal.Number.Bin */
.source_code .mf { color: #666666 } /* Literal.Number.Float */
.source_code .mh { color: #666666 } /* Literal.Number.Hex */
.source_code .mi { color: #666666 } /* Literal.Number.Integer */
.source_code .mo { color: #666666 } /* Literal.Number.Oct */
.source_code .sa { color: #BA2121 } /* Literal.String.Affix */
.source_code .sb { color: #BA2121 } /* Literal.String.Backtick */
.source_code .sc { color: #BA2121 } /* Literal.String.Char */
.source_code .dl { color: #BA2121 } /* Literal.String.Delimiter */
.source_code .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.source_code .s2 { color: #BA2121 } /* Literal.String.Double */
.source_code .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
.source_code .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
.source_code .sh { color: #BA2121 } /* Literal.String.Heredoc */
.source_code .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
.source_code .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
.source_code .sx { color: #008000 } /* Literal.String.Other */
.source_code .sr { color: #BB6688 } /* Literal.String.Regex */
.source_code .sr { color: #A45A77 } /* Literal.String.Regex */
.source_code .s1 { color: #BA2121 } /* Literal.String.Single */
.source_code .ss { color: #19177C } /* Literal.String.Symbol */
.source_code .bp { color: #008000 } /* Name.Builtin.Pseudo */
.source_code .fm { color: #0000FF } /* Name.Function.Magic */
.source_code .vc { color: #19177C } /* Name.Variable.Class */
.source_code .vg { color: #19177C } /* Name.Variable.Global */
.source_code .vi { color: #19177C } /* Name.Variable.Instance */
.source_code .vm { color: #19177C } /* Name.Variable.Magic */
.source_code .il { color: #666666 } /* Literal.Number.Integer.Long */
/*
* Dark mode: pygmentize -S native -f html -a .source_code
*/
@media (prefers-color-scheme: dark) {
pre { line-height: 125%; }
td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.source_code .hll { background-color: #404040 }
.source_code { background: #202020; color: #d0d0d0 }
.source_code .c { color: #ababab; font-style: italic } /* Comment */
.source_code .err { color: #a61717; background-color: #e3d2d2 } /* Error */
.source_code .esc { color: #d0d0d0 } /* Escape */
.source_code .g { color: #d0d0d0 } /* Generic */
.source_code .k { color: #6ebf26; font-weight: bold } /* Keyword */
.source_code .l { color: #d0d0d0 } /* Literal */
.source_code .n { color: #d0d0d0 } /* Name */
.source_code .o { color: #d0d0d0 } /* Operator */
.source_code .x { color: #d0d0d0 } /* Other */
.source_code .p { color: #d0d0d0 } /* Punctuation */
.source_code .ch { color: #ababab; font-style: italic } /* Comment.Hashbang */
.source_code .cm { color: #ababab; font-style: italic } /* Comment.Multiline */
.source_code .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */
.source_code .cpf { color: #ababab; font-style: italic } /* Comment.PreprocFile */
.source_code .c1 { color: #ababab; font-style: italic } /* Comment.Single */
.source_code .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */
.source_code .gd { color: #d22323 } /* Generic.Deleted */
.source_code .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */
.source_code .gr { color: #d22323 } /* Generic.Error */
.source_code .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */
.source_code .gi { color: #589819 } /* Generic.Inserted */
.source_code .go { color: #cccccc } /* Generic.Output */
.source_code .gp { color: #aaaaaa } /* Generic.Prompt */
.source_code .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */
.source_code .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */
.source_code .gt { color: #d22323 } /* Generic.Traceback */
.source_code .kc { color: #6ebf26; font-weight: bold } /* Keyword.Constant */
.source_code .kd { color: #6ebf26; font-weight: bold } /* Keyword.Declaration */
.source_code .kn { color: #6ebf26; font-weight: bold } /* Keyword.Namespace */
.source_code .kp { color: #6ebf26 } /* Keyword.Pseudo */
.source_code .kr { color: #6ebf26; font-weight: bold } /* Keyword.Reserved */
.source_code .kt { color: #6ebf26; font-weight: bold } /* Keyword.Type */
.source_code .ld { color: #d0d0d0 } /* Literal.Date */
.source_code .m { color: #51b2fd } /* Literal.Number */
.source_code .s { color: #ed9d13 } /* Literal.String */
.source_code .na { color: #bbbbbb } /* Name.Attribute */
.source_code .nb { color: #2fbccd } /* Name.Builtin */
.source_code .nc { color: #71adff; text-decoration: underline } /* Name.Class */
.source_code .no { color: #40ffff } /* Name.Constant */
.source_code .nd { color: #ffa500 } /* Name.Decorator */
.source_code .ni { color: #d0d0d0 } /* Name.Entity */
.source_code .ne { color: #bbbbbb } /* Name.Exception */
.source_code .nf { color: #71adff } /* Name.Function */
.source_code .nl { color: #d0d0d0 } /* Name.Label */
.source_code .nn { color: #71adff; text-decoration: underline } /* Name.Namespace */
.source_code .nx { color: #d0d0d0 } /* Name.Other */
.source_code .py { color: #d0d0d0 } /* Name.Property */
.source_code .nt { color: #6ebf26; font-weight: bold } /* Name.Tag */
.source_code .nv { color: #40ffff } /* Name.Variable */
.source_code .ow { color: #6ebf26; font-weight: bold } /* Operator.Word */
.source_code .w { color: #666666 } /* Text.Whitespace */
.source_code .mb { color: #51b2fd } /* Literal.Number.Bin */
.source_code .mf { color: #51b2fd } /* Literal.Number.Float */
.source_code .mh { color: #51b2fd } /* Literal.Number.Hex */
.source_code .mi { color: #51b2fd } /* Literal.Number.Integer */
.source_code .mo { color: #51b2fd } /* Literal.Number.Oct */
.source_code .sa { color: #ed9d13 } /* Literal.String.Affix */
.source_code .sb { color: #ed9d13 } /* Literal.String.Backtick */
.source_code .sc { color: #ed9d13 } /* Literal.String.Char */
.source_code .dl { color: #ed9d13 } /* Literal.String.Delimiter */
.source_code .sd { color: #ed9d13 } /* Literal.String.Doc */
.source_code .s2 { color: #ed9d13 } /* Literal.String.Double */
.source_code .se { color: #ed9d13 } /* Literal.String.Escape */
.source_code .sh { color: #ed9d13 } /* Literal.String.Heredoc */
.source_code .si { color: #ed9d13 } /* Literal.String.Interpol */
.source_code .sx { color: #ffa500 } /* Literal.String.Other */
.source_code .sr { color: #ed9d13 } /* Literal.String.Regex */
.source_code .s1 { color: #ed9d13 } /* Literal.String.Single */
.source_code .ss { color: #ed9d13 } /* Literal.String.Symbol */
.source_code .bp { color: #2fbccd } /* Name.Builtin.Pseudo */
.source_code .fm { color: #71adff } /* Name.Function.Magic */
.source_code .vc { color: #40ffff } /* Name.Variable.Class */
.source_code .vg { color: #40ffff } /* Name.Variable.Global */
.source_code .vi { color: #40ffff } /* Name.Variable.Instance */
.source_code .vm { color: #40ffff } /* Name.Variable.Magic */
.source_code .il { color: #51b2fd } /* Literal.Number.Integer.Long */
/* Dark mode - my overrides, because the defaults are too bright. */
.source_code .gh { color: rgb(189, 193, 198); }
.source_code .gu { color: rgb(189, 193, 198); }
}

166
utils.py
View File

@ -5,20 +5,44 @@ These are mostly used in templates, for presentation purposes.
"""
try:
import pygments
from pygments import highlight
from pygments import lexers
from pygments.formatters import HtmlFormatter
import pygments # type: ignore
from pygments import highlight # type: ignore
from pygments import lexers # type: ignore
from pygments.formatters import HtmlFormatter # type: ignore
_html_formatter = HtmlFormatter(
encoding="utf-8",
cssclass="source_code",
linenos="table",
anchorlinenos=True,
lineanchors="line",
)
except ImportError:
pygments = None
try:
import markdown # type: ignore
import markdown.treeprocessors # type: ignore
except ImportError:
markdown = None
def shorten(s, width = 60):
import base64
import functools
import mimetypes
import string
import os.path
import git
def shorten(s: str, width=60):
if len(s) < 60:
return s
return s[:57] + "..."
def can_colorize(s):
@functools.lru_cache
def can_colorize(s: str):
"""True if we can colorize the string, False otherwise."""
if pygments is None:
return False
@ -31,7 +55,7 @@ def can_colorize(s):
# If any of the first 5 lines is over 300 characters long, don't colorize.
start = 0
for i in range(5):
pos = s.find('\n', start)
pos = s.find("\n", start)
if pos == -1:
break
@ -41,30 +65,132 @@ def can_colorize(s):
return True
def colorize_diff(s):
lexer = lexers.DiffLexer(encoding = 'utf-8')
formatter = HtmlFormatter(encoding = 'utf-8',
cssclass = 'source_code')
def can_markdown(repo: git.Repo, fname: str):
"""True if we can process file through markdown, False otherwise."""
if markdown is None:
return False
if not repo.info.embed_markdown:
return False
return fname.endswith(".md")
def can_embed_image(repo, fname):
"""True if we can embed image file in HTML, False otherwise."""
if not repo.info.embed_images:
return False
return ("." in fname) and (
fname.split(".")[-1].lower() in ["jpg", "jpeg", "png", "gif"]
)
@functools.lru_cache
def colorize_diff(s: str) -> str:
lexer = lexers.DiffLexer(encoding="utf-8")
formatter = HtmlFormatter(encoding="utf-8", cssclass="source_code")
return highlight(s, lexer, formatter)
def colorize_blob(fname, s):
@functools.lru_cache
def colorize_blob(fname, s: str) -> str:
try:
lexer = lexers.guess_lexer_for_filename(fname, s, encoding = 'utf-8')
lexer = lexers.guess_lexer_for_filename(fname, s, encoding="utf-8")
except lexers.ClassNotFound:
# Only try to guess lexers if the file starts with a shebang,
# otherwise it's likely a text file and guess_lexer() is prone to
# make mistakes with those.
lexer = lexers.TextLexer(encoding = 'utf-8')
if s.startswith('#!'):
lexer = lexers.TextLexer(encoding="utf-8")
if s.startswith("#!"):
try:
lexer = lexers.guess_lexer(s[:80], encoding = 'utf-8')
lexer = lexers.guess_lexer(s[:80], encoding="utf-8")
except lexers.ClassNotFound:
pass
formatter = HtmlFormatter(encoding = 'utf-8',
cssclass = 'source_code',
linenos = 'table')
return highlight(s, lexer, _html_formatter)
return highlight(s, lexer, formatter)
def embed_image_blob(fname: str, image_data: bytes) -> str:
mimetype = mimetypes.guess_type(fname)[0]
b64img = base64.b64encode(image_data).decode("ascii")
return '<img style="max-width:100%;" src="data:{0};base64,{1}" />'.format(
mimetype, b64img
)
@functools.lru_cache
def is_binary(b: bytes):
# Git considers a blob binary if NUL in first ~8KB, so do the same.
return b"\0" in b[:8192]
@functools.lru_cache
def hexdump(s: bytes):
graph = string.ascii_letters + string.digits + string.punctuation + " "
b = s.decode("latin1")
offset = 0
while b:
t = b[:16]
hexvals = ["%.2x" % ord(c) for c in t]
text = "".join(c if c in graph else "." for c in t)
yield offset, " ".join(hexvals[:8]), " ".join(hexvals[8:]), text
offset += 16
b = b[16:]
if markdown:
class RewriteLocalLinks(markdown.treeprocessors.Treeprocessor):
"""Rewrites relative links to files, to match git-arr's links.
A link of "[example](a/file.md)" will be rewritten such that it links to
"a/f=file.md.html".
Note that we're already assuming a degree of sanity in the HTML, so we
don't re-check that the path is reasonable.
"""
def run(self, root):
for child in root:
if child.tag == "a":
self.rewrite_href(child)
# Continue recursively.
self.run(child)
def rewrite_href(self, tag):
"""Rewrite an <a>'s href."""
target = tag.get("href")
if not target:
return
if "://" in target or target.startswith("/"):
return
head, tail = os.path.split(target)
new_target = os.path.join(head, "f=" + tail + ".html")
tag.set("href", new_target)
class RewriteLocalLinksExtension(markdown.Extension):
def extendMarkdown(self, md):
md.treeprocessors.register(
RewriteLocalLinks(), "RewriteLocalLinks", 1000
)
_md_extensions = [
"markdown.extensions.fenced_code",
"markdown.extensions.tables",
RewriteLocalLinksExtension(),
]
@functools.lru_cache
def markdown_blob(s: str) -> str:
return markdown.markdown(s, extensions=_md_extensions)
else:
@functools.lru_cache
def markdown_blob(s: str) -> str:
raise RuntimeError("markdown_blob() called without markdown support")

View File

@ -1,46 +1,88 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE html>
<html>
<head>
% if not dirname.raw:
% relroot = './'
% reltree = './'
% else:
% relroot = '../' * (len(dirname.split('/')) - 1)
% reltree = '../' * (len(dirname.split('/')) - 1)
% end
% relroot = reltree + '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo;
{{repo.branch}} &raquo; {{dirname.unicode}}/{{fname.unicode}}</title>
{{branch}} &raquo; {{dirname.raw}}{{fname.raw}}</title>
<link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/git-arr.css"/>
<link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/syntax.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="tree">
<h1><a href="{{relroot}}../../../../../">git</a> &raquo;
<a href="{{relroot}}../../../">{{repo.name}}</a> &raquo;
<a href="{{relroot}}../">{{repo.branch}}</a> &raquo;
<a href="{{relroot}}">tree</a>
<a href="{{reltree}}../">{{branch}}</a> &raquo;
<a href="{{reltree}}">tree</a>
</h1>
<h3>
<a href="{{relroot}}">[{{repo.branch}}]</a> /
% base = smstr(relroot)
<a href="{{reltree}}">[{{branch}}]</a> /
% base = smstr(reltree)
% for c in dirname.split('/'):
% if not c.raw: continue
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> /
% if not c.raw:
% continue
% end
<a href="{{base.url}}{{c.url}}/">{{c.raw}}</a> /
% base += c + '/'
% end
<a href="">{{!fname.html}}</a>
</h3>
% if can_colorize(blob):
{{!colorize_blob(fname.unicode, blob)}}
% if len(blob.raw_content) == 0:
<table class="nice">
<tr>
<td>empty &mdash; 0 bytes</td>
</tr>
</table>
% elif can_embed_image(repo, fname.raw):
{{!embed_image_blob(fname.raw, blob.raw_content)}}
% elif is_binary(blob.raw_content):
<table class="nice blob-binary">
<tr>
<td colspan="4">
binary &mdash; {{'{:,}'.format(len(blob.raw_content))}} bytes
</td>
</tr>
% lim = 256
% for offset, hex1, hex2, text in hexdump(blob.raw_content[:lim]):
<tr>
<td class="offset">{{offset}}</td>
<td><pre>{{hex1}}</pre></td>
<td><pre>{{hex2}}</pre></td>
<td><pre>{{text}}</pre></td>
</tr>
% end
% if lim < len(blob.raw_content):
<tr class="etc">
<td></td>
<td>&hellip;</td>
<td>&hellip;</td>
<td>&hellip;</td>
</tr>
% end
</table>
% elif can_markdown(repo, fname.raw):
<div class="markdown">
{{!markdown_blob(blob.utf8_content)}}
</div>
% elif can_colorize(blob.utf8_content):
<div class="colorized-src">
{{!colorize_blob(fname.raw, blob.utf8_content)}}
</div>
% else:
<pre class="blob-body">
{{blob}}
{{blob.utf8_content}}
</pre>
% end

View File

@ -1,24 +1,27 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE html>
<html>
<head>
<title>git &raquo; {{repo.name}} &raquo; {{repo.branch}}</title>
<link rel="stylesheet" type="text/css" href="../../../../static/git-arr.css"/>
% relroot = '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo; {{branch}}</title>
<link rel="stylesheet" type="text/css" href="{{relroot}}../../../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="branch">
<h1><a href="../../../../">git</a> &raquo;
<a href="../../">{{repo.name}}</a> &raquo;
<a href="./">{{repo.branch}}</a>
<h1><a href="{{relroot}}../../../../">git</a> &raquo;
<a href="{{relroot}}../../">{{repo.name}}</a> &raquo;
<a href="./">{{branch}}</a>
</h1>
<p>
<a class="explicit" href="t/">Browse current source tree</a>
</p>
% commits = repo.commits("refs/heads/" + repo.branch,
% limit = repo.info.commits_per_page,
% commits = repo.commits("refs/heads/" + branch,
% limit = repo.info.commits_per_page + 1,
% offset = repo.info.commits_per_page * offset)
% commits = list(commits)
@ -26,16 +29,21 @@
% abort(404, "No more commits")
% end
% more = len(commits) > repo.info.commits_per_page
% if more:
% commits = commits[:-1]
% end
% more = more and offset + 1 < repo.info.max_pages
% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
% include paginate more = more, offset = offset
% kwargs = dict(repo=repo, commits=commits,
% shorten=shorten, repo_root="../..")
% shorten=shorten, repo_root=relroot + "../..")
% include commit-list **kwargs
<p/>
% include paginate nelem = len(commits), max_per_page = repo.info.commits_per_page, offset = offset
% include paginate more = more, offset = offset
</body>
</html>

View File

@ -16,7 +16,7 @@
% end
% end
<table class="nice commits">
<table class="nice commits" id="commits">
% refs = repo.refs()
% if not defined("commits"):

View File

@ -1,11 +1,11 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE html>
<html>
<head>
<title>git &raquo; {{repo.name}} &raquo; commit {{c.id[:7]}}</title>
<link rel="stylesheet" type="text/css" href="../../../../static/git-arr.css"/>
<link rel="stylesheet" type="text/css" href="../../../../static/syntax.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="commit">
@ -22,10 +22,10 @@
<span class="date" title="{{c.author_date}}">
{{c.author_date.utc}} UTC</span></td></tr>
<tr><td>committer</td>
<td><span class="name">{{c.author_name}}</span>
<span class="email">&lt;{{c.author_email}}&gt;</span><br/>
<span class="date" title="{{c.author_date}}">
{{c.author_date.utc}} UTC</span></td></tr>
<td><span class="name">{{c.committer_name}}</span>
<span class="email">&lt;{{c.committer_email}}&gt;</span><br/>
<span class="date" title="{{c.committer_date}}">
{{c.committer_date.utc}} UTC</span></td></tr>
% for p in c.parents:
<tr><td>parent</td>
@ -56,7 +56,9 @@
<hr/>
% if can_colorize(c.diff.body):
<div class="colorized-src">
{{!colorize_diff(c.diff.body)}}
</div>
% else:
<pre class="diff-body">
{{c.diff.body}}

View File

@ -1,16 +1,17 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE html>
<html>
<head>
<title>git</title>
<link rel="stylesheet" type="text/css" href="static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
<script async src="static/git-arr.js"></script>
</head>
<body class="index">
<body class="index" onload="replace_timestamps()">
<h1>git</h1>
<table class="nice">
<table class="nice projects">
<tr>
<th>project</th>
<th>description</th>
@ -18,8 +19,9 @@
% for repo in sorted(repos.values(), key = lambda r: r.name):
<tr>
<td><a href="r/{{repo.name}}/">{{repo.name}}</a></td>
<td><a href="r/{{repo.name}}/">{{repo.info.desc}}</a></td>
<td class="name"><a href="r/{{repo.name}}/">{{repo.name}}</a></td>
<td class="desc"><a href="r/{{repo.name}}/">{{repo.info.desc}}</a></td>
<td><span class="age">{{repo.last_commit_timestamp()}}</span></td>
</tr>
%end
</table>

View File

@ -6,7 +6,7 @@
<span class="inactive">&larr; prev</span>
% end
<span class="sep">|</span>
% if nelem >= max_per_page:
% if more:
<a href="{{offset + 1}}.html">next &rarr;</a>
% else:
<span class="inactive">next &rarr;</span>

8
views/patch.tpl Normal file
View File

@ -0,0 +1,8 @@
From: {{c.author_name}} <{{c.author_email}}>
Date: {{c.author_date}}
Subject: {{c.subject}}
{{c.body.strip()}}
---
{{c.diff.body}}

View File

@ -1,10 +1,11 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE html>
<html>
<head>
<title>git &raquo; {{repo.name}}</title>
<link rel="stylesheet" type="text/css" href="../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
<script async src="../../static/git-arr.js"></script>
</head>
<body class="summary">
@ -25,7 +26,7 @@
% end
% if repo.info.git_url:
<tr>
<td class="category">repository</td>
<td class="category">git clone </td>
<td>{{! '<br/>'.join(repo.info.git_url.split())}}</td>
</tr>
% end
@ -35,19 +36,25 @@
% end
% if "master" in repo.branch_names():
<div class="toggable-title" onclick="toggle('commits')">
<a href="b/master/">commits (master)</a>
</div>
% kwargs = dict(repo = repo, start_ref = "refs/heads/master",
% limit = repo.info.commits_in_summary,
% shorten = shorten, repo_root = ".", offset = 0)
% include commit-list **kwargs
<hr/>
<div class="toggable-title" onclick="toggle('ls')">
<a href="b/master/t/">tree (master)</a>
</div>
% kwargs = dict(repo = repo, tree=repo.tree("master"),
% treeroot="b/master/t", dirname=smstr.from_url(""))
% include tree-list **kwargs
<hr/>
% end
<hr/>
<table class="nice">
<tr>
<th>branches</th>
</tr>
<div class="toggable-title" onclick="toggle('branches')">branches</div>
<table class="nice toggable" id="branches">
% for b in repo.branch_names():
<tr>
<td class="main"><a href="b/{{b}}/">{{b}}</a></td>
@ -63,11 +70,8 @@
% tags = list(repo.tags())
% if tags:
<table class="nice">
<tr>
<th>tags</th>
</tr>
<div class="toggable-title" onclick="toggle('tags')">tags</div>
<table class="nice toggable" id="tags">
% for name, obj_id in tags:
<tr>
<td><a href="c/{{obj_id}}/">{{name}}</a></td>

16
views/tree-list.html Normal file
View File

@ -0,0 +1,16 @@
<table class="nice toggable ls" id="ls">
% key_func = lambda x: (x[0] != 'tree', x[1].raw)
% for type, name, size in sorted(tree.ls(dirname.raw), key = key_func):
<tr class="{{type}}">
% if type == "blob":
<td class="name"><a href="{{treeroot}}/f={{name.url}}.html">
{{!name.html}}</a></td>
<td class="size">{{size}}</td>
% elif type == "tree":
<td class="name">
<a class="explicit" href="{{treeroot}}/{{name.url}}/">
{{!name.html}}/</a></td>
% end
</tr>
% end
</table>

View File

@ -1,54 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<!DOCTYPE html>
<html>
<head>
% if not dirname.raw:
% relroot = './'
% reltree = './'
% else:
% relroot = '../' * (len(dirname.split('/')) - 1)
% reltree = '../' * (len(dirname.split('/')) - 1)
% end
% relroot = reltree + '../' * (len(branch.split('/')) - 1)
<title>git &raquo; {{repo.name}} &raquo;
{{repo.branch}} &raquo; {{dirname.unicode}}</title>
{{branch}} &raquo; {{dirname.raw if dirname.raw else '/'}}</title>
<link rel="stylesheet" type="text/css"
href="{{relroot}}../../../../../static/git-arr.css"/>
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<meta name=viewport content="width=device-width, initial-scale=1">
</head>
<body class="tree">
<h1><a href="{{relroot}}../../../../../">git</a> &raquo;
<a href="{{relroot}}../../../">{{repo.name}}</a> &raquo;
<a href="{{relroot}}../">{{repo.branch}}</a> &raquo;
<a href="{{relroot}}">tree</a>
<a href="{{reltree}}../">{{branch}}</a> &raquo;
<a href="{{reltree}}">tree</a>
</h1>
<h3>
<a href="{{relroot}}">[{{repo.branch}}]</a> /
% base = smstr(relroot)
<a href="{{reltree}}">[{{branch}}]</a> /
% base = smstr(reltree)
% for c in dirname.split('/'):
% if not c.raw: continue
<a href="{{base.url}}{{c.url}}/">{{c.unicode}}</a> /
% if not c.raw:
% continue
% end
<a href="{{base.url}}{{c.url}}/">{{c.raw}}</a> /
% base += c + '/'
% end
</h3>
<table class="nice ls">
% key_func = lambda (t, n, s): (0 if t == 'tree' else 1, n.raw)
% for type, name, size in sorted(tree.ls(dirname.raw), key = key_func):
<tr class="{{type}}">
% if type == "blob":
<td class="name"><a href="./f={{name.url}}.html">
{{!name.html}}</a></td>
<td class="size">{{size}}</td>
% elif type == "tree":
<td class="name">
<a class="explicit" href="./{{name.url}}/">
{{!name.html}}/</a></td>
% end
</tr>
% end
</table>
% kwargs = dict(repo = repo, tree=tree, treeroot=".")
% include tree-list **kwargs
</body>
</html>