├── .gitignore
├── views
├── patch.tpl
├── paginate.html
├── tree-list.html
├── index.html
├── tree.html
├── commit-list.html
├── branch.html
├── commit.html
├── blob.html
└── summary.html
├── pyproject.toml
├── hooks
├── README
└── post-receive
├── LICENSE
├── README.md
├── static
├── git-arr.js
├── git-arr.css
└── syntax.css
├── sample.conf
├── utils.py
├── git.py
└── git-arr
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | __pycache__
3 | .*
4 | !.gitignore
5 |
--------------------------------------------------------------------------------
/views/patch.tpl:
--------------------------------------------------------------------------------
1 | From: {{c.author_name}} <{{c.author_email}}>
2 | Date: {{c.author_date}}
3 | Subject: {{c.subject}}
4 |
5 | {{c.body.strip()}}
6 | ---
7 |
8 | {{c.diff.body}}
9 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 79
3 | include = "(git-arr|git.py|utils.py)$"
4 |
5 | [[tool.mypy.overrides]]
6 | module = ["xattr.*"]
7 | follow_untyped_imports = true
8 |
--------------------------------------------------------------------------------
/views/paginate.html:
--------------------------------------------------------------------------------
1 |
2 |
/description, or "" if there is no such file.
8 | #desc = My lovely repository
9 |
10 | # Do we allow browsing the file tree for each branch? (optional).
11 | # Useful to disable an expensive operation in very large repositories.
12 | #tree = yes
13 |
14 | # Show a "creation event" diff for a root commit? (optional)
15 | # For projects placed under revision control from inception, the root commit
16 | # diff is often meaningful. However, in cases when a well established, large
17 | # project is placed under revision control belatedly, the root commit may
18 | # represent a lump import of the entire project, in which case such a
19 | # "creation event" diff would likely be considered meaningless noise.
20 | # Default: yes
21 | #rootdiff = yes
22 |
23 | # How many commits to show in the summary page (optional).
24 | #commits_in_summary = 10
25 |
26 | # How many commits to show in each page when viewing a branch (optional).
27 | #commits_per_page = 50
28 |
29 | # Maximum number of per-branch pages for static generation (optional).
30 | # When generating static html, this is the maximum number of pages we will
31 | # generate for each branch's commit listings. Zero (0) means unlimited.
32 | # Default: 250
33 | #max_pages = 250
34 |
35 | # Project website (optional).
36 | # URL to the project's website. %(name)s will be replaced with the current
37 | # section name (here and everywhere).
38 | #web_url = http://example.org/%(name)s
39 |
40 | # File name to get the project website from (optional).
41 | # If web_url is not set, attempt to get its value from this file.
42 | # Default: "web_url".
43 | #web_url_file = web_url
44 |
45 | # Git repository URLs (optional).
46 | # URLs to the project's git repository.
47 | #git_url = git://example.org/%(name)s http://example.org/git/%(name)s
48 |
49 | # File name to get the git URLs from (optional).
50 | # If git_url is not set, attempt to get its value from this file.
51 | # Default: "cloneurl" (same as gitweb).
52 | #git_url_file = cloneurl
53 |
54 | # Do we look for repositories within this path? (optional).
55 | # This option enables a recursive, 1 level search for repositories within the
56 | # given path. They will inherit their options from this section.
57 | # Note that repositories that contain a file named "disable_gitweb" will be
58 | # excluded.
59 | #recursive = no
60 |
61 | # Render Markdown blobs (*.md) formatted rather than as raw text? (optional)
62 | # Requires 'markdown' module.
63 | # Default: yes
64 | #embed_markdown = yes
65 |
66 | # Render image blobs graphically rather than as raw binary data? (optional)
67 | # Default: no
68 | #embed_images = no
69 |
70 | # Ignore repositories that match this regular expression.
71 | # Generally used with recursive = yes, to ignore repeated repositories (for
72 | # example, if using symlinks).
73 | # For ignoring specific repositories, putting a "disable_gitweb" is a much
74 | # better alternative.
75 | # Default: empty (don't ignore)
76 | #ignore = \.git$
77 |
78 | # Another repository, we don't generate a tree for it because it's too big.
79 | [linux]
80 | path = /srv/git/linux/
81 | desc = Linux kernel
82 | tree = no
83 |
84 | # Look for repositories within this directory.
85 | [projects]
86 | path = /srv/projects/
87 | recursive = yes
88 |
--------------------------------------------------------------------------------
/static/git-arr.css:
--------------------------------------------------------------------------------
1 | /*
2 | * git-arr style sheet
3 | */
4 | :root {
5 | --body-bg: white;
6 | --text-fg: black;
7 | --h1-bg: #ddd;
8 | --hr-bg: #e3e3e3;
9 | --text-lowcontrast-fg: grey;
10 | --a-fg: #800;
11 | --a-explicit-fg: #038;
12 | --table-hover-bg: #eee;
13 | --head-bg: #88ff88;
14 | --tag-bg: #ffff88;
15 | --age-fg0: darkgreen;
16 | --age-fg1: green;
17 | --age-fg2: seagreen;
18 | --diff-added-fg: green;
19 | --diff-deleted-fg: red;
20 | }
21 |
22 | @media (prefers-color-scheme: dark) {
23 | :root {
24 | --body-bg: #121212;
25 | --text-fg: #c9d1d9;
26 | --h1-bg: #2f2f2f;
27 | --hr-bg: #e3e3e3;
28 | --text-lowcontrast-fg: grey;
29 | --a-fg: #d4b263;
30 | --a-explicit-fg: #44b4ec;
31 | --table-hover-bg: #313131;
32 | --head-bg: #020;
33 | --tag-bg: #333000;
34 | --age-fg0: #51a552;
35 | --age-fg1: #468646;
36 | --age-fg2: #2f722f;
37 | --diff-added-fg: #00A000;
38 | --diff-deleted-fg: #A00000;
39 | }
40 | }
41 |
42 | body {
43 | font-family: sans-serif;
44 | padding: 0 1em 1em 1em;
45 | color: var(--text-fg);
46 | background: var(--body-bg);
47 | }
48 |
49 | h1 {
50 | background: var(--h1-bg);
51 | padding: 0.3em;
52 | }
53 |
54 | h2, h3 {
55 | border-bottom: 1px solid #ccc;
56 | padding-bottom: 0.3em;
57 | margin-bottom: 0.5em;
58 | }
59 |
60 | hr {
61 | border: none;
62 | background-color: var(--hr-bg);
63 | height: 1px;
64 | }
65 |
66 |
67 | /* By default, use implied links, more discrete for increased readability. */
68 | a {
69 | text-decoration: none;
70 | color: var(--text-fg);
71 | }
72 |
73 | a:hover {
74 | color: var(--a-fg);
75 | }
76 |
77 |
78 | /* Explicit links */
79 | a.explicit {
80 | color: var(--a-explicit-fg);
81 | }
82 |
83 | a.explicit:hover, a.explicit:active {
84 | color: var(--a-fg);
85 | }
86 |
87 |
88 | /* Normal table, for listing things like repositories, branches, etc. */
89 | table.nice {
90 | text-align: left;
91 | }
92 |
93 | table.nice td {
94 | padding: 0.15em 0.5em;
95 | }
96 |
97 | table.nice td.links {
98 | }
99 |
100 | table.nice td.main {
101 | min-width: 10em;
102 | }
103 |
104 | table.nice tr:hover {
105 | background: var(--table-hover-bg);
106 | }
107 |
108 |
109 | /* Table for commits. */
110 | table.commits td.date {
111 | font-style: italic;
112 | color: var(--text-lowcontrast-fg);
113 | }
114 |
115 | @media (min-width: 600px) {
116 | table.commits td.subject {
117 | min-width: 32em;
118 | }
119 | }
120 |
121 | table.commits td.author {
122 | color: var(--text-lowcontrast-fg);
123 | }
124 |
125 |
126 | /* Table for commit information. */
127 | table.commit-info tr:hover {
128 | background: inherit;
129 | }
130 |
131 | table.commit-info td {
132 | vertical-align: top;
133 | }
134 |
135 | table.commit-info span.date, span.email {
136 | color: var(--text-lowcontrast-fg);
137 | }
138 |
139 |
140 | /* Reference annotations. */
141 | span.refs {
142 | margin: 0px 0.5em;
143 | padding: 0px 0.25em;
144 | border: solid 1px var(--text-lowcontrast-fg);
145 | }
146 |
147 | span.head {
148 | background-color: var(--head-bg);
149 | }
150 |
151 | span.tag {
152 | background-color: var(--tag-bg);
153 | }
154 |
155 |
156 | /* Projects table */
157 | table.projects td.name a {
158 | color: var(--a-explicit-fg);
159 | }
160 |
161 |
162 | /* Truncate long descriptions based on the viewport width. */
163 | table.projects td.desc {
164 | max-width: 50vw;
165 | text-overflow: ellipsis;
166 | overflow: hidden;
167 | white-space: nowrap;
168 | }
169 |
170 |
171 | /* Age of an object.
172 | * Note this is hidden by default as we rely on javascript to show it. */
173 | span.age {
174 | display: none;
175 | color: var(--text-lowcontrast-fg);
176 | font-size: smaller;
177 | }
178 |
179 | span.age-band0 {
180 | color: var(--age-fg0);
181 | }
182 |
183 | span.age-band1 {
184 | color: var(--age-fg1);
185 | }
186 |
187 | span.age-band2 {
188 | color: var(--age-fg2);
189 | }
190 |
191 |
192 | /* Toggable titles */
193 | div.toggable-title {
194 | font-weight: bold;
195 | margin-bottom: 0.3em;
196 | }
197 |
198 | pre {
199 | /* Sometimes, elements (commit messages, diffs, blobs) have very
200 | * long lines. In those case, use automatic overflow, which will
201 | * introduce a horizontal scroll bar for this element only (more
202 | * comfortable than stretching the page, which is the default). */
203 | overflow: auto;
204 | }
205 |
206 |
207 | /* Commit message and diff. */
208 | pre.commit-message {
209 | font-size: large;
210 | padding: 0.2em 0.5em;
211 | }
212 |
213 | pre.diff-body {
214 | /* Note this is only used as a fallback if pygments is not available. */
215 | }
216 |
217 | table.changed-files {
218 | font-family: monospace;
219 | }
220 |
221 | table.changed-files span.lines-added {
222 | color: var(--diff-added-fg);
223 | }
224 |
225 | table.changed-files span.lines-deleted {
226 | color: var(--diff-deleted-fg);
227 | }
228 |
229 |
230 | /* Pagination. */
231 | div.paginate {
232 | padding-bottom: 1em;
233 | }
234 |
235 | div.paginate span.inactive {
236 | color: var(--text-lowcontrast-fg);
237 | }
238 |
239 |
240 | /* Directory listing. */
241 | @media (min-width: 600px) {
242 | table.ls td.name {
243 | min-width: 20em;
244 | }
245 | }
246 |
247 | table.ls {
248 | font-family: monospace;
249 | font-size: larger;
250 | }
251 |
252 | table.ls tr.blob td.size {
253 | color: var(--text-lowcontrast-fg);
254 | }
255 |
256 |
257 | /* Blob. */
258 | pre.blob-body {
259 | /* Note this is only used as a fallback if pygments is not available. */
260 | }
261 |
262 | table.blob-binary pre {
263 | padding: 0;
264 | margin: 0;
265 | }
266 |
267 | table.blob-binary .offset {
268 | text-align: right;
269 | font-size: x-small;
270 | color: var(--text-lowcontrast-fg);
271 | border-right: 1px solid var(--text-lowcontrast-fg);
272 | }
273 |
274 | table.blob-binary tr.etc {
275 | text-align: center;
276 | }
277 |
278 |
279 | /* Pygments overrides. */
280 | div.colorized-src {
281 | font-size: larger;
282 | }
283 |
284 | div.colorized-src .source_code {
285 | /* Ignore pygments style's background. */
286 | background: var(--body-bg);
287 | }
288 |
289 | td.code > div.source_code {
290 | /* This is a workaround, in pygments 2.11 there's a bug where the wrapper
291 | * div is inside the table, so we need to override the descendant (because
292 | * the style sets it on ".source_code" and the most specific value wins).
293 | * Once we no longer support 2.11, we can remove this. */
294 | background: var(--body-bg);
295 | }
296 |
297 | div.linenodiv {
298 | padding-right: 0.5em;
299 | }
300 |
301 | div.linenodiv a {
302 | color: var(--text-lowcontrast-fg);
303 | }
304 |
305 |
306 | /* Repository information table. */
307 | table.repo_info tr:hover {
308 | background: inherit;
309 | }
310 |
311 | table.repo_info td.category {
312 | font-weight: bold;
313 | /* So we can copy-paste rows and preserve spaces, useful for the row:
314 | * git clone | url */
315 | white-space: pre-wrap;
316 | }
317 |
318 | table.repo_info td {
319 | vertical-align: top;
320 | }
321 |
322 | span.ctrlchr {
323 | color: var(--text-lowcontrast-fg);
324 | padding: 0 0.2ex 0 0.1ex;
325 | margin: 0 0.2ex 0 0.1ex;
326 | }
327 |
328 |
329 | /*
330 | * Markdown overrides
331 | */
332 |
333 | /* Colored links (same as explicit links above) */
334 | div.markdown a {
335 | color: var(--a-explicit-fg);
336 | }
337 |
338 | div.markdown a:hover, div.markdown a:active {
339 | color: var(--a-fg);
340 | }
341 |
342 |
343 | /* Restrict max width for readability */
344 | div.markdown {
345 | max-width: 55em;
346 | }
347 |
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Miscellaneous utilities.
3 |
4 | These are mostly used in templates, for presentation purposes.
5 | """
6 |
7 | from typing import Sequence
8 | from types import ModuleType
9 | import base64
10 | import functools
11 | import mimetypes
12 | import string
13 | import inspect
14 | import sys
15 | import time
16 | import os
17 | import os.path
18 |
19 | import git
20 |
21 |
22 | try:
23 | import pygments
24 | from pygments import highlight
25 | from pygments import lexers
26 | from pygments.formatters import HtmlFormatter
27 |
28 | _html_formatter = HtmlFormatter(
29 | encoding="utf-8",
30 | cssclass="source_code",
31 | linenos="table",
32 | anchorlinenos=True,
33 | lineanchors="line",
34 | )
35 |
36 | @functools.lru_cache
37 | def can_colorize(s: str) -> bool:
38 | """True if we can colorize the string, False otherwise."""
39 | # Pygments can take a huge amount of time with long files, or with
40 | # very long lines; these are heuristics to try to avoid those
41 | # situations.
42 | if len(s) > (512 * 1024):
43 | return False
44 |
45 | # If any of the first 5 lines is over 300 characters long, don't
46 | # colorize.
47 | start = 0
48 | for i in range(5):
49 | pos = s.find("\n", start)
50 | if pos == -1:
51 | break
52 |
53 | if pos - start > 300:
54 | return False
55 | start = pos + 1
56 |
57 | return True
58 |
59 | @functools.lru_cache
60 | def colorize_diff(s: str) -> str:
61 | lexer = lexers.DiffLexer(encoding="utf-8")
62 | formatter = HtmlFormatter(encoding="utf-8", cssclass="source_code")
63 |
64 | return highlight(s, lexer, formatter)
65 |
66 | @functools.lru_cache
67 | def colorize_blob(fname, s: str) -> str:
68 | # Explicit import to enable type checking, otherwise mypy gets confused
69 | # because pygments is defined as a generic module | None.
70 | import pygments.lexer
71 |
72 | lexer: pygments.lexer.Lexer | pygments.lexer.LexerMeta
73 | try:
74 | lexer = lexers.guess_lexer_for_filename(fname, s, encoding="utf-8")
75 | except lexers.ClassNotFound:
76 | # Only try to guess lexers if the file starts with a shebang,
77 | # otherwise it's likely a text file and guess_lexer() is prone to
78 | # make mistakes with those.
79 | if s.startswith("#!"):
80 | try:
81 | lexer = lexers.guess_lexer(s[:80], encoding="utf-8")
82 | except lexers.ClassNotFound:
83 | pass
84 | else:
85 | lexer = lexers.TextLexer(encoding="utf-8")
86 |
87 | return highlight(s, lexer, _html_formatter)
88 |
89 | except ImportError:
90 |
91 | @functools.lru_cache
92 | def can_colorize(s: str) -> bool:
93 | """True if we can colorize the string, False otherwise."""
94 | return False
95 |
96 | @functools.lru_cache
97 | def colorize_diff(s: str) -> str:
98 | raise RuntimeError("colorize_diff() called without pygments support")
99 |
100 | @functools.lru_cache
101 | def colorize_blob(fname, s: str) -> str:
102 | raise RuntimeError("colorize_blob() called without pygments support")
103 |
104 |
105 | try:
106 | import markdown
107 |
108 | def can_markdown(repo: git.Repo, fname: str) -> bool:
109 | """True if we can process file through markdown, False otherwise."""
110 | if not repo.info.embed_markdown:
111 | return False
112 |
113 | return fname.endswith(".md")
114 |
115 | class RewriteLocalLinks(markdown.treeprocessors.Treeprocessor):
116 | """Rewrites relative links to files, to match git-arr's links.
117 |
118 | A link of "[example](a/file.md)" will be rewritten such that it links to
119 | "a/f=file.md.html".
120 |
121 | Note that we're already assuming a degree of sanity in the HTML, so we
122 | don't re-check that the path is reasonable.
123 | """
124 |
125 | def run(self, root):
126 | for child in root:
127 | if child.tag == "a":
128 | self.rewrite_href(child)
129 |
130 | # Continue recursively.
131 | self.run(child)
132 |
133 | def rewrite_href(self, tag):
134 | """Rewrite an 's href."""
135 | target = tag.get("href")
136 | if not target:
137 | return
138 | if "://" in target or target.startswith("/"):
139 | return
140 |
141 | head, tail = os.path.split(target)
142 | new_target = os.path.join(head, "f=" + tail + ".html")
143 | tag.set("href", new_target)
144 |
145 | class RewriteLocalLinksExtension(markdown.Extension):
146 | def extendMarkdown(self, md):
147 | md.treeprocessors.register(
148 | RewriteLocalLinks(), "RewriteLocalLinks", 1000
149 | )
150 |
151 | _md_extensions: Sequence[str | markdown.Extension] = [
152 | "markdown.extensions.fenced_code",
153 | "markdown.extensions.tables",
154 | RewriteLocalLinksExtension(),
155 | ]
156 |
157 | @functools.lru_cache
158 | def markdown_blob(s: str) -> str:
159 | return markdown.markdown(s, extensions=_md_extensions)
160 |
161 | except ImportError:
162 |
163 | def can_markdown(repo: git.Repo, fname: str) -> bool:
164 | """True if we can process file through markdown, False otherwise."""
165 | return False
166 |
167 | @functools.lru_cache
168 | def markdown_blob(s: str) -> str:
169 | raise RuntimeError("markdown_blob() called without markdown support")
170 |
171 |
172 | def shorten(s: str, width=60):
173 | if len(s) < 60:
174 | return s
175 | return s[:57] + "..."
176 |
177 |
178 | def can_embed_image(repo: git.Repo, fname: str) -> bool:
179 | """True if we can embed image file in HTML, False otherwise."""
180 | if not repo.info.embed_images:
181 | return False
182 |
183 | return ("." in fname) and (
184 | fname.split(".")[-1].lower() in ["jpg", "jpeg", "png", "gif"]
185 | )
186 |
187 |
188 | def embed_image_blob(fname: str, image_data: bytes) -> str:
189 | mimetype = mimetypes.guess_type(fname)[0]
190 | b64img = base64.b64encode(image_data).decode("ascii")
191 | return '
'.format(
192 | mimetype, b64img
193 | )
194 |
195 |
196 | @functools.lru_cache
197 | def is_binary(b: bytes):
198 | # Git considers a blob binary if NUL in first ~8KB, so do the same.
199 | return b"\0" in b[:8192]
200 |
201 |
202 | @functools.lru_cache
203 | def hexdump(s: bytes):
204 | graph = string.ascii_letters + string.digits + string.punctuation + " "
205 | b = s.decode("latin1")
206 | offset = 0
207 | while b:
208 | t = b[:16]
209 | hexvals = ["%.2x" % ord(c) for c in t]
210 | text = "".join(c if c in graph else "." for c in t)
211 | yield offset, " ".join(hexvals[:8]), " ".join(hexvals[8:]), text
212 | offset += 16
213 | b = b[16:]
214 |
215 |
216 | def log_timing(*log_args):
217 | "Decorator to log how long a function call took."
218 | if not os.environ.get("GIT_ARR_DEBUG"):
219 | return lambda f: f
220 |
221 | def log_timing_decorator(f):
222 | argspec = inspect.getfullargspec(f)
223 | idxs = [argspec.args.index(arg) for arg in log_args]
224 |
225 | @functools.wraps(f)
226 | def wrapper(*args, **kwargs):
227 | start = time.time()
228 | result = f(*args, **kwargs)
229 | end = time.time()
230 |
231 | f_args = [args[i] for i in idxs]
232 | sys.stderr.write(
233 | "%.4fs %s %s\n" % (end - start, f.__name__, " ".join(f_args))
234 | )
235 | return result
236 |
237 | return wrapper
238 |
239 | return log_timing_decorator
240 |
241 |
242 | try:
243 | import xattr
244 |
245 | def set_xattr_oid(path: str, oid: str):
246 | """Set the xattr 'user.git-arr.oid' on the given path."""
247 | try:
248 | xattr.setxattr(path, "user.git-arr.oid", oid.encode("utf-8"))
249 | except OSError as e:
250 | print(f"{path}: error writing xattr: {e}")
251 |
252 | def get_xattr_oid(path: str) -> str:
253 | """Get the xattr 'user.git-arr.oid' from the given path."""
254 | try:
255 | return xattr.getxattr(path, "user.git-arr.oid").decode("utf-8")
256 | except OSError as e:
257 | return ""
258 |
259 | except ImportError:
260 |
261 | def set_xattr_oid(path: str, oid: str):
262 | """Set the xattr 'user.git-arr.oid' on the given path."""
263 | pass
264 |
265 | def get_xattr_oid(path: str) -> str:
266 | """Get the xattr 'user.git-arr.oid' from the given path."""
267 | return ""
268 |
--------------------------------------------------------------------------------
/static/syntax.css:
--------------------------------------------------------------------------------
1 |
2 | /* CSS for syntax highlighting.
3 | * Generated by pygments (what we use for syntax highlighting).
4 | *
5 | * Light mode: pygmentize -S default -f html -a .source_code
6 | */
7 |
8 | pre { line-height: 125%; }
9 | td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
10 | span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
11 | td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
12 | span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
13 | .source_code .hll { background-color: #ffffcc }
14 | .source_code { background: #f8f8f8; }
15 | .source_code .c { color: #3D7B7B; font-style: italic } /* Comment */
16 | .source_code .err { border: 1px solid #FF0000 } /* Error */
17 | .source_code .k { color: #008000; font-weight: bold } /* Keyword */
18 | .source_code .o { color: #666666 } /* Operator */
19 | .source_code .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
20 | .source_code .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
21 | .source_code .cp { color: #9C6500 } /* Comment.Preproc */
22 | .source_code .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
23 | .source_code .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
24 | .source_code .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
25 | .source_code .gd { color: #A00000 } /* Generic.Deleted */
26 | .source_code .ge { font-style: italic } /* Generic.Emph */
27 | .source_code .gr { color: #E40000 } /* Generic.Error */
28 | .source_code .gh { color: #000080; font-weight: bold } /* Generic.Heading */
29 | .source_code .gi { color: #008400 } /* Generic.Inserted */
30 | .source_code .go { color: #717171 } /* Generic.Output */
31 | .source_code .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
32 | .source_code .gs { font-weight: bold } /* Generic.Strong */
33 | .source_code .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
34 | .source_code .gt { color: #0044DD } /* Generic.Traceback */
35 | .source_code .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
36 | .source_code .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
37 | .source_code .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
38 | .source_code .kp { color: #008000 } /* Keyword.Pseudo */
39 | .source_code .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
40 | .source_code .kt { color: #B00040 } /* Keyword.Type */
41 | .source_code .m { color: #666666 } /* Literal.Number */
42 | .source_code .s { color: #BA2121 } /* Literal.String */
43 | .source_code .na { color: #687822 } /* Name.Attribute */
44 | .source_code .nb { color: #008000 } /* Name.Builtin */
45 | .source_code .nc { color: #0000FF; font-weight: bold } /* Name.Class */
46 | .source_code .no { color: #880000 } /* Name.Constant */
47 | .source_code .nd { color: #AA22FF } /* Name.Decorator */
48 | .source_code .ni { color: #717171; font-weight: bold } /* Name.Entity */
49 | .source_code .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
50 | .source_code .nf { color: #0000FF } /* Name.Function */
51 | .source_code .nl { color: #767600 } /* Name.Label */
52 | .source_code .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
53 | .source_code .nt { color: #008000; font-weight: bold } /* Name.Tag */
54 | .source_code .nv { color: #19177C } /* Name.Variable */
55 | .source_code .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
56 | .source_code .w { color: #bbbbbb } /* Text.Whitespace */
57 | .source_code .mb { color: #666666 } /* Literal.Number.Bin */
58 | .source_code .mf { color: #666666 } /* Literal.Number.Float */
59 | .source_code .mh { color: #666666 } /* Literal.Number.Hex */
60 | .source_code .mi { color: #666666 } /* Literal.Number.Integer */
61 | .source_code .mo { color: #666666 } /* Literal.Number.Oct */
62 | .source_code .sa { color: #BA2121 } /* Literal.String.Affix */
63 | .source_code .sb { color: #BA2121 } /* Literal.String.Backtick */
64 | .source_code .sc { color: #BA2121 } /* Literal.String.Char */
65 | .source_code .dl { color: #BA2121 } /* Literal.String.Delimiter */
66 | .source_code .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
67 | .source_code .s2 { color: #BA2121 } /* Literal.String.Double */
68 | .source_code .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
69 | .source_code .sh { color: #BA2121 } /* Literal.String.Heredoc */
70 | .source_code .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
71 | .source_code .sx { color: #008000 } /* Literal.String.Other */
72 | .source_code .sr { color: #A45A77 } /* Literal.String.Regex */
73 | .source_code .s1 { color: #BA2121 } /* Literal.String.Single */
74 | .source_code .ss { color: #19177C } /* Literal.String.Symbol */
75 | .source_code .bp { color: #008000 } /* Name.Builtin.Pseudo */
76 | .source_code .fm { color: #0000FF } /* Name.Function.Magic */
77 | .source_code .vc { color: #19177C } /* Name.Variable.Class */
78 | .source_code .vg { color: #19177C } /* Name.Variable.Global */
79 | .source_code .vi { color: #19177C } /* Name.Variable.Instance */
80 | .source_code .vm { color: #19177C } /* Name.Variable.Magic */
81 | .source_code .il { color: #666666 } /* Literal.Number.Integer.Long */
82 |
83 | /*
84 | * Dark mode: pygmentize -S native -f html -a .source_code
85 | */
86 |
87 | @media (prefers-color-scheme: dark) {
88 |
89 | pre { line-height: 125%; }
90 | td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; }
91 | span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; }
92 | td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
93 | span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
94 | .source_code .hll { background-color: #404040 }
95 | .source_code { background: #202020; color: #d0d0d0 }
96 | .source_code .c { color: #ababab; font-style: italic } /* Comment */
97 | .source_code .err { color: #a61717; background-color: #e3d2d2 } /* Error */
98 | .source_code .esc { color: #d0d0d0 } /* Escape */
99 | .source_code .g { color: #d0d0d0 } /* Generic */
100 | .source_code .k { color: #6ebf26; font-weight: bold } /* Keyword */
101 | .source_code .l { color: #d0d0d0 } /* Literal */
102 | .source_code .n { color: #d0d0d0 } /* Name */
103 | .source_code .o { color: #d0d0d0 } /* Operator */
104 | .source_code .x { color: #d0d0d0 } /* Other */
105 | .source_code .p { color: #d0d0d0 } /* Punctuation */
106 | .source_code .ch { color: #ababab; font-style: italic } /* Comment.Hashbang */
107 | .source_code .cm { color: #ababab; font-style: italic } /* Comment.Multiline */
108 | .source_code .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */
109 | .source_code .cpf { color: #ababab; font-style: italic } /* Comment.PreprocFile */
110 | .source_code .c1 { color: #ababab; font-style: italic } /* Comment.Single */
111 | .source_code .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */
112 | .source_code .gd { color: #d22323 } /* Generic.Deleted */
113 | .source_code .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */
114 | .source_code .gr { color: #d22323 } /* Generic.Error */
115 | .source_code .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */
116 | .source_code .gi { color: #589819 } /* Generic.Inserted */
117 | .source_code .go { color: #cccccc } /* Generic.Output */
118 | .source_code .gp { color: #aaaaaa } /* Generic.Prompt */
119 | .source_code .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */
120 | .source_code .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */
121 | .source_code .gt { color: #d22323 } /* Generic.Traceback */
122 | .source_code .kc { color: #6ebf26; font-weight: bold } /* Keyword.Constant */
123 | .source_code .kd { color: #6ebf26; font-weight: bold } /* Keyword.Declaration */
124 | .source_code .kn { color: #6ebf26; font-weight: bold } /* Keyword.Namespace */
125 | .source_code .kp { color: #6ebf26 } /* Keyword.Pseudo */
126 | .source_code .kr { color: #6ebf26; font-weight: bold } /* Keyword.Reserved */
127 | .source_code .kt { color: #6ebf26; font-weight: bold } /* Keyword.Type */
128 | .source_code .ld { color: #d0d0d0 } /* Literal.Date */
129 | .source_code .m { color: #51b2fd } /* Literal.Number */
130 | .source_code .s { color: #ed9d13 } /* Literal.String */
131 | .source_code .na { color: #bbbbbb } /* Name.Attribute */
132 | .source_code .nb { color: #2fbccd } /* Name.Builtin */
133 | .source_code .nc { color: #71adff; text-decoration: underline } /* Name.Class */
134 | .source_code .no { color: #40ffff } /* Name.Constant */
135 | .source_code .nd { color: #ffa500 } /* Name.Decorator */
136 | .source_code .ni { color: #d0d0d0 } /* Name.Entity */
137 | .source_code .ne { color: #bbbbbb } /* Name.Exception */
138 | .source_code .nf { color: #71adff } /* Name.Function */
139 | .source_code .nl { color: #d0d0d0 } /* Name.Label */
140 | .source_code .nn { color: #71adff; text-decoration: underline } /* Name.Namespace */
141 | .source_code .nx { color: #d0d0d0 } /* Name.Other */
142 | .source_code .py { color: #d0d0d0 } /* Name.Property */
143 | .source_code .nt { color: #6ebf26; font-weight: bold } /* Name.Tag */
144 | .source_code .nv { color: #40ffff } /* Name.Variable */
145 | .source_code .ow { color: #6ebf26; font-weight: bold } /* Operator.Word */
146 | .source_code .w { color: #666666 } /* Text.Whitespace */
147 | .source_code .mb { color: #51b2fd } /* Literal.Number.Bin */
148 | .source_code .mf { color: #51b2fd } /* Literal.Number.Float */
149 | .source_code .mh { color: #51b2fd } /* Literal.Number.Hex */
150 | .source_code .mi { color: #51b2fd } /* Literal.Number.Integer */
151 | .source_code .mo { color: #51b2fd } /* Literal.Number.Oct */
152 | .source_code .sa { color: #ed9d13 } /* Literal.String.Affix */
153 | .source_code .sb { color: #ed9d13 } /* Literal.String.Backtick */
154 | .source_code .sc { color: #ed9d13 } /* Literal.String.Char */
155 | .source_code .dl { color: #ed9d13 } /* Literal.String.Delimiter */
156 | .source_code .sd { color: #ed9d13 } /* Literal.String.Doc */
157 | .source_code .s2 { color: #ed9d13 } /* Literal.String.Double */
158 | .source_code .se { color: #ed9d13 } /* Literal.String.Escape */
159 | .source_code .sh { color: #ed9d13 } /* Literal.String.Heredoc */
160 | .source_code .si { color: #ed9d13 } /* Literal.String.Interpol */
161 | .source_code .sx { color: #ffa500 } /* Literal.String.Other */
162 | .source_code .sr { color: #ed9d13 } /* Literal.String.Regex */
163 | .source_code .s1 { color: #ed9d13 } /* Literal.String.Single */
164 | .source_code .ss { color: #ed9d13 } /* Literal.String.Symbol */
165 | .source_code .bp { color: #2fbccd } /* Name.Builtin.Pseudo */
166 | .source_code .fm { color: #71adff } /* Name.Function.Magic */
167 | .source_code .vc { color: #40ffff } /* Name.Variable.Class */
168 | .source_code .vg { color: #40ffff } /* Name.Variable.Global */
169 | .source_code .vi { color: #40ffff } /* Name.Variable.Instance */
170 | .source_code .vm { color: #40ffff } /* Name.Variable.Magic */
171 | .source_code .il { color: #51b2fd } /* Literal.Number.Integer.Long */
172 |
173 | /* Dark mode - my overrides, because the defaults are too bright. */
174 |
175 | .source_code .gh { color: rgb(189, 193, 198); }
176 | .source_code .gu { color: rgb(189, 193, 198); }
177 | }
178 |
179 |
--------------------------------------------------------------------------------
/git.py:
--------------------------------------------------------------------------------
1 | """
2 | Python wrapper for git.
3 |
4 | This module is a light Python API for interfacing with it. It calls the git
5 | command line tool directly, so please be careful with using untrusted
6 | parameters.
7 | """
8 |
9 | import functools
10 | import sys
11 | import io
12 | import time
13 | import os
14 | import subprocess
15 | from collections import defaultdict
16 | import email.utils
17 | import datetime
18 | import urllib.request, urllib.parse, urllib.error
19 | from html import escape
20 | from typing import Any, Dict, IO, Iterable, List, Tuple, Union
21 |
22 |
23 | # Path to the git binary.
24 | GIT_BIN = "git"
25 |
26 |
27 | def run_git(
28 | repo_path: str,
29 | params,
30 | stdin: bytes | None = None,
31 | silent_stderr=False,
32 | raw=False,
33 | ) -> Union[IO[str], IO[bytes]]:
34 | """Invokes git with the given parameters.
35 |
36 | This function invokes git with the given parameters, and returns a
37 | file-like object with the output (from a pipe).
38 | """
39 | start = time.time()
40 | out = _run_git(
41 | repo_path, params, stdin, silent_stderr=silent_stderr, raw=raw
42 | )
43 | end = time.time()
44 |
45 | if os.environ.get("GIT_ARR_DEBUG"):
46 | sys.stderr.write(
47 | "%.4fs %s %s\n"
48 | % (end - start, repo_path[-30:], " ".join(params))
49 | )
50 |
51 | return out
52 |
53 |
54 | def _run_git(
55 | repo_path: str,
56 | params,
57 | stdin: bytes | None = None,
58 | silent_stderr=False,
59 | raw=False,
60 | ) -> Union[IO[str], IO[bytes]]:
61 | """Invokes git with the given parameters.
62 |
63 | This is the real run_git function, which is called by run_git().
64 | """
65 | params = [GIT_BIN, "--git-dir=%s" % repo_path] + list(params)
66 |
67 | stderr = None
68 | if silent_stderr:
69 | stderr = subprocess.PIPE
70 |
71 | if not stdin:
72 | p = subprocess.Popen(
73 | params, stdin=None, stdout=subprocess.PIPE, stderr=stderr
74 | )
75 | else:
76 | p = subprocess.Popen(
77 | params,
78 | stdin=subprocess.PIPE,
79 | stdout=subprocess.PIPE,
80 | stderr=stderr,
81 | )
82 |
83 | assert p.stdin is not None
84 | p.stdin.write(stdin)
85 | p.stdin.close()
86 |
87 | assert p.stdout is not None
88 |
89 | if raw:
90 | return p.stdout
91 |
92 | return io.TextIOWrapper(
93 | p.stdout, encoding="utf8", errors="backslashreplace"
94 | )
95 |
96 |
97 | class GitCommand(object):
98 | """Convenient way of invoking git."""
99 |
100 | def __init__(self, path: str, cmd: str):
101 | self._override = True
102 | self._path = path
103 | self._cmd = cmd
104 | self._args: List[str] = []
105 | self._kwargs: Dict[str, str] = {}
106 | self._stdin_buf: bytes | None = None
107 | self._raw = False
108 | self._override = False
109 |
110 | def __setattr__(self, k, v):
111 | if k == "_override" or self._override:
112 | self.__dict__[k] = v
113 | return
114 | k = k.replace("_", "-")
115 | self._kwargs[k] = v
116 |
117 | def arg(self, a: str):
118 | """Adds an argument."""
119 | self._args.append(a)
120 |
121 | def raw(self, b: bool):
122 | """Request raw rather than utf8-encoded command output."""
123 | self._override = True
124 | self._raw = b
125 | self._override = False
126 |
127 | def stdin(self, s: bytes):
128 | """Sets the contents we will send in stdin."""
129 | self._override = True
130 | self._stdin_buf = s
131 | self._override = False
132 |
133 | def run(self):
134 | """Runs the git command."""
135 | params = [self._cmd]
136 |
137 | for k, v in list(self._kwargs.items()):
138 | dash = "--" if len(k) > 1 else "-"
139 | if v is None:
140 | params.append("%s%s" % (dash, k))
141 | else:
142 | params.append("%s%s=%s" % (dash, k, str(v)))
143 |
144 | params.extend(self._args)
145 |
146 | return run_git(self._path, params, self._stdin_buf, raw=self._raw)
147 |
148 |
149 | class SimpleNamespace(object):
150 | """An entirely flexible object, which provides a convenient namespace."""
151 |
152 | def __init__(self, **kwargs):
153 | self.__dict__.update(kwargs)
154 |
155 |
156 | class smstr:
157 | """A "smart" string, containing many representations for ease of use."""
158 |
159 | raw: str # string, probably utf8-encoded, good enough to show.
160 |
161 | def __init__(self, s: str):
162 | self.raw = s
163 |
164 | # Note we don't define __repr__() or __str__() to prevent accidental
165 | # misuse. It does mean that some uses become more annoying, so it's a
166 | # tradeoff that may change in the future.
167 |
168 | @staticmethod
169 | def from_url(url):
170 | """Returns an smstr() instance from an url-encoded string."""
171 | return smstr(urllib.request.url2pathname(url))
172 |
173 | def split(self, sep):
174 | """Like str.split()."""
175 | return [smstr(s) for s in self.raw.split(sep)]
176 |
177 | def __add__(self, other):
178 | if isinstance(other, smstr):
179 | other = other.raw
180 | return smstr(self.raw + other)
181 |
182 | @functools.cached_property
183 | def url(self) -> str:
184 | """Escaped for safe embedding in URLs (not human-readable)."""
185 | return urllib.request.pathname2url(self.raw)
186 |
187 | @functools.cached_property
188 | def html(self) -> str:
189 | """Returns an html representation of the unicode string."""
190 | html = ""
191 | for c in escape(self.raw):
192 | if c in "\t\r\n\r\f\a\b\v\0":
193 | esc_c = c.encode("unicode-escape").decode("utf8")
194 | html += '%s' % esc_c
195 | else:
196 | html += c
197 |
198 | return html
199 |
200 |
201 | def unquote(s: str):
202 | """Git can return quoted file names, unquote them. Always return a str."""
203 | if not (s[0] == '"' and s[-1] == '"'):
204 | # Unquoted strings are always safe, no need to mess with them
205 | return s
206 |
207 | # The string will be of the form `""`, where is a
208 | # backslash-escaped representation of the name of the file.
209 | # Examples: "with\ttwo\ttabs" , "\303\261aca-utf8", "\361aca-latin1"
210 |
211 | # Get rid of the quotes, we never want them in the output.
212 | s = s[1:-1]
213 |
214 | # Un-escape the backslashes.
215 | # latin1 is ok to use here because in Python it just maps the code points
216 | # 0-255 to the bytes 0x-0xff, which is what we expect.
217 | s = s.encode("latin1").decode("unicode-escape")
218 |
219 | # Convert to utf8.
220 | s = s.encode("latin1").decode("utf8", errors="backslashreplace")
221 |
222 | return s
223 |
224 |
225 | class Repo:
226 | """A git repository."""
227 |
228 | def __init__(self, path: str, name=None, info=None):
229 | self.path = path
230 | self.name = name
231 | self.info: Any = info or SimpleNamespace()
232 |
233 | def cmd(self, cmd):
234 | """Returns a GitCommand() on our path."""
235 | return GitCommand(self.path, cmd)
236 |
237 | @functools.lru_cache
238 | def _for_each_ref(self, pattern=None, sort=None, count=None):
239 | """Returns a list of references."""
240 | cmd = self.cmd("for-each-ref")
241 | if sort:
242 | cmd.sort = sort
243 | if count:
244 | cmd.count = count
245 | if pattern:
246 | cmd.arg(pattern)
247 |
248 | refs = []
249 | for l in cmd.run():
250 | obj_id, obj_type, ref = l.split()
251 | refs.append((obj_id, obj_type, ref))
252 | return refs
253 |
254 | @functools.cache
255 | def branch_names(self):
256 | """Get the names of the branches."""
257 | refs = self._for_each_ref(pattern="refs/heads/", sort="-authordate")
258 | return [ref[len("refs/heads/") :] for _, _, ref in refs]
259 |
260 | @functools.cache
261 | def main_branch(self):
262 | """Get the name of the main branch."""
263 | bs = self.branch_names()
264 | for branch in ["master", "main"]:
265 | if branch in bs:
266 | return branch
267 | if bs:
268 | return bs[0]
269 | return None
270 |
271 | @functools.cache
272 | def tags(self, sort="-taggerdate"):
273 | """Get the (name, obj_id) of the tags."""
274 | refs = self._for_each_ref(pattern="refs/tags/", sort=sort)
275 | return [(ref[len("refs/tags/") :], obj_id) for obj_id, _, ref in refs]
276 |
277 | @functools.lru_cache
278 | def commit_ids(self, ref, limit=None):
279 | """Generate commit ids."""
280 | cmd = self.cmd("rev-list")
281 | if limit:
282 | cmd.max_count = limit
283 |
284 | cmd.arg(ref)
285 | cmd.arg("--")
286 |
287 | return [l.rstrip("\n") for l in cmd.run()]
288 |
289 | @functools.lru_cache
290 | def commit(self, commit_id):
291 | """Return a single commit."""
292 | cs = list(self.commits(commit_id, limit=1))
293 | if len(cs) != 1:
294 | return None
295 | return cs[0]
296 |
297 | @functools.lru_cache
298 | def commits(self, ref, limit, offset=0):
299 | """Generate commit objects for the ref."""
300 | cmd = self.cmd("rev-list")
301 | cmd.max_count = limit + offset
302 |
303 | cmd.header = None
304 |
305 | cmd.arg(ref)
306 | cmd.arg("--")
307 |
308 | info_buffer = ""
309 | count = 0
310 | commits = []
311 | for l in cmd.run():
312 | if "\0" in l:
313 | pre, post = l.split("\0", 1)
314 | info_buffer += pre
315 |
316 | count += 1
317 | if count > offset:
318 | commits.append(Commit.from_str(self, info_buffer))
319 |
320 | # Start over.
321 | info_buffer = post
322 | else:
323 | info_buffer += l
324 |
325 | if info_buffer:
326 | count += 1
327 | if count > offset:
328 | commits.append(Commit.from_str(self, info_buffer))
329 |
330 | return commits
331 |
332 | @functools.lru_cache
333 | def diff(self, ref):
334 | """Return a Diff object for the ref."""
335 | cmd = self.cmd("diff-tree")
336 | cmd.patch = None
337 | cmd.numstat = None
338 | cmd.find_renames = None
339 | if self.info.root_diff:
340 | cmd.root = None
341 | # Note we intentionally do not use -z, as the filename is just for
342 | # reference, and it is safer to let git do the escaping.
343 |
344 | cmd.arg(ref)
345 |
346 | return Diff.from_str(cmd.run())
347 |
348 | @functools.lru_cache
349 | def refs(self):
350 | """Return a dict of obj_id -> ref."""
351 | cmd = self.cmd("show-ref")
352 | cmd.dereference = None
353 |
354 | r = defaultdict(list)
355 | for l in cmd.run():
356 | l = l.strip()
357 | obj_id, ref = l.split(" ", 1)
358 | r[obj_id].append(ref)
359 |
360 | return r
361 |
362 | @functools.lru_cache
363 | def tree(self, ref):
364 | """Returns a Tree instance for the given ref."""
365 | return Tree(self, ref)
366 |
367 | @functools.lru_cache
368 | def blob(self, path, ref):
369 | """Returns a Blob instance for the given path."""
370 | cmd = self.cmd("cat-file")
371 | cmd.raw(True)
372 | cmd.batch = "%(objectsize)"
373 |
374 | # Format: [:
375 | # Construct it in binary since the path might not be utf8.
376 | cmd.stdin(ref.encode("utf8") + b":" + path)
377 |
378 | out = cmd.run()
379 | head = out.readline()
380 | if not head or head.strip().endswith(b"missing"):
381 | return None
382 |
383 | return Blob(out.read()[: int(head)])
384 |
385 | @functools.cache
386 | def last_commit_timestamp(self):
387 | """Return the timestamp of the last commit."""
388 | refs = self._for_each_ref(
389 | pattern="refs/heads/", sort="-committerdate", count=1
390 | )
391 | for obj_id, _, _ in refs:
392 | commit = self.commit(obj_id)
393 | return commit.committer_epoch
394 | return -1
395 |
396 |
397 | class Commit(object):
398 | """A git commit."""
399 |
400 | def __init__(
401 | self,
402 | repo,
403 | commit_id,
404 | parents,
405 | tree,
406 | author,
407 | author_epoch,
408 | author_tz,
409 | committer,
410 | committer_epoch,
411 | committer_tz,
412 | message,
413 | ):
414 | self._repo = repo
415 | self.id = commit_id
416 | self.parents = parents
417 | self.tree = tree
418 | self.author = author
419 | self.author_epoch = author_epoch
420 | self.author_tz = author_tz
421 | self.committer = committer
422 | self.committer_epoch = committer_epoch
423 | self.committer_tz = committer_tz
424 | self.message = message
425 |
426 | self.author_name, self.author_email = email.utils.parseaddr(
427 | self.author
428 | )
429 |
430 | self.committer_name, self.committer_email = email.utils.parseaddr(
431 | self.committer
432 | )
433 |
434 | self.subject, self.body = self.message.split("\n", 1)
435 |
436 | self.author_date = Date(self.author_epoch, self.author_tz)
437 | self.committer_date = Date(self.committer_epoch, self.committer_tz)
438 |
439 | # Only get this lazily when we need it; most of the time it's not
440 | # required by the caller.
441 | self._diff = None
442 |
443 | def __repr__(self):
444 | return "" % (
445 | self.id[:7],
446 | ",".join(p[:7] for p in self.parents),
447 | self.author_email,
448 | self.subject[:20],
449 | )
450 |
451 | @property
452 | def diff(self):
453 | """Return the diff for this commit, in unified format."""
454 | if not self._diff:
455 | self._diff = self._repo.diff(self.id)
456 | return self._diff
457 |
458 | @staticmethod
459 | def from_str(repo, buf):
460 | """Parses git rev-list output, returns a commit object."""
461 | if "\n\n" in buf:
462 | # Header, commit message
463 | header, raw_message = buf.split("\n\n", 1)
464 | else:
465 | # Header only, no commit message
466 | header, raw_message = buf.rstrip(), " "
467 |
468 | header_lines = header.split("\n")
469 | commit_id = header_lines.pop(0)
470 |
471 | header_dict = defaultdict(list)
472 | for line in header_lines:
473 | k, v = line.split(" ", 1)
474 | header_dict[k].append(v)
475 |
476 | tree = header_dict["tree"][0]
477 | parents = set(header_dict["parent"])
478 |
479 | authorhdr = header_dict["author"][0]
480 | author, author_epoch, author_tz = authorhdr.rsplit(" ", 2)
481 |
482 | committerhdr = header_dict["committer"][0]
483 | committer, committer_epoch, committer_tz = committerhdr.rsplit(" ", 2)
484 |
485 | # Remove the first four spaces from the message's lines.
486 | message = ""
487 | for line in raw_message.split("\n"):
488 | message += line[4:] + "\n"
489 |
490 | return Commit(
491 | repo,
492 | commit_id=commit_id,
493 | tree=tree,
494 | parents=parents,
495 | author=author,
496 | author_epoch=author_epoch,
497 | author_tz=author_tz,
498 | committer=committer,
499 | committer_epoch=committer_epoch,
500 | committer_tz=committer_tz,
501 | message=message,
502 | )
503 |
504 |
505 | class Date:
506 | """Handy representation for a datetime from git."""
507 |
508 | def __init__(self, epoch, tz):
509 | self.epoch = int(epoch)
510 | self.tz = tz
511 | self.utc = datetime.datetime.utcfromtimestamp(self.epoch)
512 |
513 | self.tz_sec_offset_min = int(tz[1:3]) * 60 + int(tz[4:])
514 | if tz[0] == "-":
515 | self.tz_sec_offset_min = -self.tz_sec_offset_min
516 |
517 | self.local = self.utc + datetime.timedelta(
518 | minutes=self.tz_sec_offset_min
519 | )
520 |
521 | self.str = self.utc.strftime("%a, %d %b %Y %H:%M:%S +0000 ")
522 | self.str += "(%s %s)" % (self.local.strftime("%H:%M"), self.tz)
523 |
524 | def __str__(self):
525 | return self.str
526 |
527 |
528 | class Diff:
529 | """A diff between two trees."""
530 |
531 | def __init__(self, ref, changes, body):
532 | """Constructor.
533 |
534 | - ref: reference id the diff refers to.
535 | - changes: [ (added, deleted, filename), ... ]
536 | - body: diff body, as text, verbatim.
537 | """
538 | self.ref = ref
539 | self.changes = changes
540 | self.body = body
541 |
542 | @staticmethod
543 | def from_str(buf):
544 | """Parses git diff-tree output, returns a Diff object."""
545 | lines = iter(buf)
546 | try:
547 | ref_id = next(lines)
548 | except StopIteration:
549 | # No diff; this can happen in merges without conflicts.
550 | return Diff(None, [], "")
551 |
552 | # First, --numstat information.
553 | changes = []
554 | l = next(lines)
555 | while l != "\n":
556 | l = l.rstrip("\n")
557 | added, deleted, fname = l.split("\t", 2)
558 | added = added.replace("-", "0")
559 | deleted = deleted.replace("-", "0")
560 | fname = smstr(unquote(fname))
561 | changes.append((int(added), int(deleted), fname))
562 | l = next(lines)
563 |
564 | # And now the diff body. We just store as-is, we don't really care for
565 | # the contents.
566 | body = "".join(lines)
567 |
568 | return Diff(ref_id, changes, body)
569 |
570 |
571 | class Tree:
572 | """A git tree."""
573 |
574 | def __init__(self, repo: Repo, ref: str):
575 | self.repo = repo
576 | self.ref = ref
577 |
578 | @functools.lru_cache
579 | def ls(
580 | self, path, recursive=False
581 | ) -> Iterable[Tuple[str, smstr, str, int | None]]:
582 | """Generates (type, name, oid, size) for each file in path."""
583 | cmd = self.repo.cmd("ls-tree")
584 | cmd.long = None
585 | if recursive:
586 | cmd.r = None
587 | cmd.t = None
588 |
589 | cmd.arg(self.ref)
590 | if not path:
591 | cmd.arg(".")
592 | else:
593 | cmd.arg(path)
594 |
595 | files = []
596 | for l in cmd.run():
597 | _mode, otype, oid, size, name = l.split(None, 4)
598 | if size == "-":
599 | size = None
600 | else:
601 | size = int(size)
602 |
603 | # Remove the quoting (if any); will always give us a str.
604 | name = unquote(name.strip("\n"))
605 |
606 | # Strip the leading path, the caller knows it and it's often
607 | # easier to work with this way.
608 | name = name[len(path) :]
609 |
610 | # We use a smart string for the name, as it's often tricky to
611 | # manipulate otherwise.
612 | files.append((otype, smstr(name), oid, size))
613 |
614 | return files
615 |
616 |
617 | class Blob:
618 | """A git blob."""
619 |
620 | def __init__(self, raw_content: bytes):
621 | self.raw_content = raw_content
622 | self._utf8_content = None
623 |
624 | @property
625 | def utf8_content(self):
626 | if not self._utf8_content:
627 | self._utf8_content = self.raw_content.decode("utf8", "replace")
628 | return self._utf8_content
629 |
--------------------------------------------------------------------------------
/git-arr:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | git-arr: A git web html generator.
4 | """
5 |
6 | import configparser
7 | import math
8 | import optparse
9 | import functools
10 | import json
11 | import os
12 | import time
13 | import re
14 | import sys
15 | from typing import Union
16 |
17 | import bottle # type: ignore
18 |
19 | import git
20 | import utils
21 |
22 |
23 | # Tell bottle where to find the views.
24 | # Note this assumes they live next to the executable, and that is not a good
25 | # assumption; but it's good enough for now.
26 | bottle.TEMPLATE_PATH.insert(
27 | 0, os.path.abspath(os.path.dirname(sys.argv[0])) + "/views/"
28 | )
29 |
30 | # The path to our static files.
31 | # Note this assumes they live next to the executable, and that is not a good
32 | # assumption; but it's good enough for now.
33 | static_path = os.path.abspath(os.path.dirname(sys.argv[0])) + "/static/"
34 |
35 |
36 | # The list of repositories is a global variable for convenience. It will be
37 | # populated by load_config().
38 | repos = {}
39 |
40 |
41 | def load_config(path):
42 | """Load the configuration from the given file.
43 |
44 | The "repos" global variable will be filled with the repositories
45 | as configured.
46 | """
47 | defaults = {
48 | "tree": "yes",
49 | "rootdiff": "yes",
50 | "desc": "",
51 | "recursive": "no",
52 | "prefix": "",
53 | "commits_in_summary": "10",
54 | "commits_per_page": "50",
55 | "max_pages": "250",
56 | "web_url": "",
57 | "web_url_file": "web_url",
58 | "git_url": "",
59 | "git_url_file": "cloneurl",
60 | "embed_markdown": "yes",
61 | "embed_images": "no",
62 | "ignore": "",
63 | "generate_patch": "yes",
64 | }
65 |
66 | config = configparser.ConfigParser(defaults)
67 | config.read(path)
68 |
69 | # Do a first pass for general sanity checking and recursive expansion.
70 | for s in config.sections():
71 | if config.getboolean(s, "recursive"):
72 | root = config.get(s, "path")
73 | prefix = config.get(s, "prefix")
74 |
75 | for path in os.listdir(root):
76 | fullpath = find_git_dir(root + "/" + path)
77 | if not fullpath:
78 | continue
79 |
80 | if os.path.exists(fullpath + "/disable_gitweb"):
81 | continue
82 |
83 | section = prefix + path
84 | if config.has_section(section):
85 | continue
86 |
87 | config.add_section(section)
88 | for opt, value in config.items(s, raw=True):
89 | config.set(section, opt, value)
90 |
91 | config.set(section, "path", fullpath)
92 | config.set(section, "recursive", "no")
93 |
94 | # This recursive section is no longer useful.
95 | config.remove_section(s)
96 |
97 | for s in config.sections():
98 | if config.get(s, "ignore") and re.search(config.get(s, "ignore"), s):
99 | continue
100 |
101 | fullpath = find_git_dir(config.get(s, "path"))
102 | if not fullpath:
103 | raise ValueError(
104 | "%s: path %s is not a valid git repository"
105 | % (s, config.get(s, "path"))
106 | )
107 |
108 | config.set(s, "path", fullpath)
109 | config.set(s, "name", s)
110 |
111 | desc = config.get(s, "desc")
112 | if not desc and os.path.exists(fullpath + "/description"):
113 | desc = open(fullpath + "/description").read().strip()
114 |
115 | r = git.Repo(fullpath, name=s)
116 | r.info.desc = desc
117 | r.info.commits_in_summary = config.getint(s, "commits_in_summary")
118 | r.info.commits_per_page = config.getint(s, "commits_per_page")
119 | r.info.max_pages = config.getint(s, "max_pages")
120 | if r.info.max_pages <= 0:
121 | r.info.max_pages = sys.maxsize
122 | r.info.generate_tree = config.getboolean(s, "tree")
123 | r.info.root_diff = config.getboolean(s, "rootdiff")
124 | r.info.generate_patch = config.getboolean(s, "generate_patch")
125 |
126 | r.info.web_url = config.get(s, "web_url")
127 | web_url_file = fullpath + "/" + config.get(s, "web_url_file")
128 | if not r.info.web_url and os.path.isfile(web_url_file):
129 | r.info.web_url = open(web_url_file).read()
130 |
131 | r.info.git_url = config.get(s, "git_url")
132 | git_url_file = fullpath + "/" + config.get(s, "git_url_file")
133 | if not r.info.git_url and os.path.isfile(git_url_file):
134 | r.info.git_url = open(git_url_file).read()
135 |
136 | r.info.embed_markdown = config.getboolean(s, "embed_markdown")
137 | r.info.embed_images = config.getboolean(s, "embed_images")
138 |
139 | repos[r.name] = r
140 |
141 |
142 | def find_git_dir(path):
143 | """Returns the path to the git directory for the given repository.
144 |
145 | This function takes a path to a git repository, and returns the path to
146 | its git directory. If the repo is bare, it will be the same path;
147 | otherwise it will be path + '.git/'.
148 |
149 | An empty string is returned if the given path is not a valid repository.
150 | """
151 |
152 | def check(p):
153 | "True if p is a git directory, False otherwise."
154 | # This is a very crude heuristic, but works well enough for our needs,
155 | # since we expect the directories to be given to us to be git repos.
156 | # We used to do this by calling `git rev-parse --git-dir`, but it ends
157 | # up taking a (relatively) significant amount of time, as we have to
158 | # do it for all repos even if we just want to (re-)generate a single
159 | # one.
160 | if os.path.isdir(p + "/objects") and os.path.isdir(p + "/refs"):
161 | return True
162 | return False
163 |
164 | for p in [path, path + "/.git"]:
165 | if check(p):
166 | return p
167 |
168 | return ""
169 |
170 |
171 | def repo_filter(unused_conf):
172 | """Bottle route filter for repos."""
173 | # TODO: consider allowing /, which is tricky.
174 | regexp = r"[\w\.~-]+"
175 |
176 | def to_python(s):
177 | """Return the corresponding Python object."""
178 | if s in repos:
179 | return repos[s]
180 | bottle.abort(404, "Unknown repository")
181 |
182 | def to_url(r):
183 | """Return the corresponding URL string."""
184 | return r.name
185 |
186 | return regexp, to_python, to_url
187 |
188 |
189 | app = bottle.Bottle()
190 | app.router.add_filter("repo", repo_filter)
191 | bottle.app.push(app)
192 |
193 |
194 | def with_utils(f):
195 | """Decorator to add the utilities to the return value.
196 |
197 | Used to wrap functions that return dictionaries which are then passed to
198 | templates.
199 | """
200 | utilities = {
201 | "shorten": utils.shorten,
202 | "can_colorize": utils.can_colorize,
203 | "colorize_diff": utils.colorize_diff,
204 | "colorize_blob": utils.colorize_blob,
205 | "can_markdown": utils.can_markdown,
206 | "markdown_blob": utils.markdown_blob,
207 | "can_embed_image": utils.can_embed_image,
208 | "embed_image_blob": utils.embed_image_blob,
209 | "is_binary": utils.is_binary,
210 | "hexdump": utils.hexdump,
211 | "abort": bottle.abort,
212 | "smstr": git.smstr,
213 | }
214 |
215 | def wrapped(*args, **kwargs):
216 | """Wrapped function we will return."""
217 | d = f(*args, **kwargs)
218 | d.update(utilities)
219 | return d
220 |
221 | wrapped.__name__ = f.__name__
222 | wrapped.__doc__ = f.__doc__
223 |
224 | return wrapped
225 |
226 |
227 | @utils.log_timing()
228 | @bottle.route("/")
229 | @bottle.view("index")
230 | @with_utils
231 | def index():
232 | return dict(repos=repos)
233 |
234 |
235 | @utils.log_timing()
236 | @bottle.route("/modified_ts.json")
237 | def modified_ts(only=None):
238 | ts = {}
239 | for r in repos.values():
240 | if only and r.name not in only:
241 | continue
242 | ts[r.name] = r.last_commit_timestamp()
243 | return dict(ts)
244 |
245 |
246 | @utils.log_timing()
247 | @bottle.route("/r//")
248 | @bottle.view("summary")
249 | @with_utils
250 | def summary(repo):
251 | return dict(repo=repo)
252 |
253 |
254 | @bottle.route("/r//c//")
255 | @bottle.view("commit")
256 | @with_utils
257 | def commit(repo, cid):
258 | c = repo.commit(cid)
259 | if not c:
260 | bottle.abort(404, "Commit not found")
261 |
262 | return dict(repo=repo, c=c)
263 |
264 |
265 | @bottle.route("/r//c/.patch")
266 | @bottle.view(
267 | "patch",
268 | # Output is text/plain, don't do HTML escaping.
269 | template_settings={"noescape": True},
270 | )
271 | def patch(repo, cid):
272 | c = repo.commit(cid)
273 | if not c:
274 | bottle.abort(404, "Commit not found")
275 |
276 | bottle.response.content_type = "text/plain; charset=utf8"
277 |
278 | return dict(repo=repo, c=c)
279 |
280 |
281 | @bottle.route("/r//b//t/f=.html")
282 | @bottle.route(
283 | "/r//b//t//f=.html"
284 | )
285 | @bottle.view("blob")
286 | @with_utils
287 | def blob(repo, bname, fname, dirname=""):
288 | if dirname and not dirname.endswith("/"):
289 | dirname = dirname + "/"
290 |
291 | dirname = git.smstr.from_url(dirname)
292 | fname = git.smstr.from_url(fname)
293 | path = dirname.raw + fname.raw
294 |
295 | # Handle backslash-escaped characters, which are not utf8.
296 | # This matches the generated links from git.unquote().
297 | path = path.encode("utf8").decode("unicode-escape").encode("latin1")
298 |
299 | content = repo.blob(path, bname)
300 | if content is None:
301 | bottle.abort(404, "File %r not found in branch %s" % (path, bname))
302 |
303 | return dict(
304 | repo=repo, branch=bname, dirname=dirname, fname=fname, blob=content
305 | )
306 |
307 |
308 | @bottle.route("/r//b//t/")
309 | @bottle.route("/r//b//t//")
310 | @bottle.view("tree")
311 | @with_utils
312 | def tree(repo, bname, dirname=""):
313 | if dirname and not dirname.endswith("/"):
314 | dirname = dirname + "/"
315 |
316 | dirname = git.smstr.from_url(dirname)
317 |
318 | return dict(
319 | repo=repo, branch=bname, tree=repo.tree(bname), dirname=dirname
320 | )
321 |
322 |
323 | @bottle.route("/r//b//")
324 | @bottle.route("/r//b//.html")
325 | @bottle.view("branch")
326 | @with_utils
327 | def branch(repo, bname, offset=0):
328 | return dict(repo=repo, branch=bname, offset=offset)
329 |
330 |
331 | @bottle.route("/static/")
332 | def static(path):
333 | return bottle.static_file(path, root=static_path)
334 |
335 |
336 | #
337 | # Static HTML generation
338 | #
339 |
340 |
341 | def is_404(e):
342 | """True if e is an HTTPError with status 404, False otherwise."""
343 | # We need this because older bottle.py versions put the status code in
344 | # e.status as an integer, and newer versions make that a string, and using
345 | # e.status_code for the code.
346 | if isinstance(e.status, int):
347 | return e.status == 404
348 | else:
349 | return e.status_code == 404
350 |
351 |
352 | def generate(output: str, only=None):
353 | """Generate static html to the output directory."""
354 |
355 | @utils.log_timing("path")
356 | def write_to(path: str, func_or_str, args=(), mtime=None, oid=""):
357 | path = output + "/" + path
358 |
359 | if oid:
360 | # If we were given an oid, try to use xattrs to check if the file
361 | # we wrote is still the same, in which case we can skip writing
362 | # it again.
363 | path_oid = utils.get_xattr_oid(path)
364 | if path_oid and path_oid == oid:
365 | return
366 |
367 | if mtime:
368 | path_mtime: Union[float, int] = 0
369 | if os.path.exists(path):
370 | path_mtime = os.stat(path).st_mtime
371 |
372 | # Make sure they're both float or int, to avoid failing
373 | # comparisons later on because of this.
374 | if isinstance(path_mtime, int):
375 | mtime = int(mtime)
376 |
377 | # If we were given mtime, we compare against it to see if we
378 | # should write the file or not. Compare with almost-equality
379 | # because otherwise floating point equality gets in the way, and
380 | # we rather write a bit more, than generate the wrong output.
381 | if abs(path_mtime - mtime) < 0.000001:
382 | return
383 | print(path)
384 | s = func_or_str(*args)
385 | else:
386 | # Otherwise, be lazy if we were given a function to run, or write
387 | # always if they gave us a string.
388 | if isinstance(func_or_str, str):
389 | print(path)
390 | s = func_or_str
391 | else:
392 | if os.path.exists(path):
393 | return
394 | print(path)
395 | s = func_or_str(*args)
396 |
397 | dirname = os.path.dirname(path)
398 | if not os.path.exists(dirname):
399 | os.makedirs(dirname)
400 |
401 | open(path, "w").write(s)
402 | if mtime:
403 | os.utime(path, (mtime, mtime))
404 | if oid:
405 | utils.set_xattr_oid(path, oid)
406 |
407 | def link(from_path, to_path):
408 | from_path = output + "/" + from_path
409 |
410 | if os.path.lexists(from_path):
411 | return
412 | print(from_path, "->", to_path)
413 | os.symlink(to_path, from_path)
414 |
415 | def write_tree(r: git.Repo, bn: str, mtime):
416 | t: git.Tree = r.tree(bn)
417 |
418 | write_to("r/%s/b/%s/t/index.html" % (r.name, bn), tree, (r, bn), mtime)
419 |
420 | for otype, oname, oid, _ in t.ls("", recursive=True):
421 | # FIXME: bottle cannot route paths with '\n' so those are sadly
422 | # expected to fail for now; we skip them.
423 | if "\n" in oname.raw:
424 | print("skipping file with \\n: %r" % (oname.raw))
425 | continue
426 |
427 | if otype == "blob":
428 | dirname = git.smstr(os.path.dirname(oname.raw))
429 | fname = git.smstr(os.path.basename(oname.raw))
430 | write_to(
431 | "r/%s/b/%s/t/%s%sf=%s.html"
432 | % (
433 | str(r.name),
434 | str(bn),
435 | dirname.raw,
436 | "/" if dirname.raw else "",
437 | fname.raw,
438 | ),
439 | blob,
440 | (r, bn, fname.url, dirname.url),
441 | mtime,
442 | oid,
443 | )
444 | else:
445 | write_to(
446 | "r/%s/b/%s/t/%s/index.html"
447 | % (str(r.name), str(bn), oname.raw),
448 | tree,
449 | (r, bn, oname.url),
450 | mtime,
451 | oid,
452 | )
453 |
454 | @utils.log_timing()
455 | def update_modified_ts_json(last_commit_timestamp):
456 | """Update /modified_ts.json with the given last timestamps."""
457 | # Note that the original file may have more repositories than our new
458 | # dict, if --only was used. The point of updating it instead of doing
459 | # a full regeneration is that it is much faster when --only is used.
460 | path = output + "/modified_ts.json"
461 | print(path)
462 | if only and os.path.exists(path):
463 | ts = json.load(open(path))
464 | else:
465 | ts = {}
466 | ts.update(last_commit_timestamp)
467 | s = json.dumps(ts, indent=4, sort_keys=True)
468 | open(path, "w").write(s)
469 |
470 | # Don't generate the top level index if we are generating a single
471 | # repository.
472 | if not only:
473 | write_to("index.html", index())
474 |
475 | # We can't call static() because it relies on HTTP headers.
476 | read_f = lambda f: open(f).read()
477 | write_to(
478 | "static/git-arr.css",
479 | read_f,
480 | [static_path + "/git-arr.css"],
481 | os.stat(static_path + "/git-arr.css").st_mtime,
482 | )
483 | write_to(
484 | "static/git-arr.js",
485 | read_f,
486 | [static_path + "/git-arr.js"],
487 | os.stat(static_path + "/git-arr.js").st_mtime,
488 | )
489 | write_to(
490 | "static/syntax.css",
491 | read_f,
492 | [static_path + "/syntax.css"],
493 | os.stat(static_path + "/syntax.css").st_mtime,
494 | )
495 |
496 | rs = sorted(list(repos.values()), key=lambda r: r.name)
497 | if only:
498 | rs = [r for r in rs if r.name in only]
499 |
500 | # We will keep track of the last commit timestamp for each repository,
501 | # so we can write it to the top level index.
502 | # This is an optimization, because computing the last commit timestamp
503 | # for a repository when we are not generating it is expensive.
504 | last_commit_timestamp = {}
505 |
506 | for r in rs:
507 | write_to("r/%s/index.html" % r.name, summary(r))
508 |
509 | # It's very common that branches share the same commits. While we
510 | # only write commits once (because write_to() will skip writing if the
511 | # file already exists), doing that call and file existence check
512 | # repeatedly takes a significant amount of time.
513 | # To reduce that, we keep track of which commits we've already
514 | # written, and skip writing them again.
515 | commits_written = set()
516 |
517 | last_commit_timestamp[r.name] = -1
518 | for bn in r.branch_names():
519 | commit_count = 0
520 | commit_ids = r.commit_ids(
521 | "refs/heads/" + bn,
522 | limit=r.info.commits_per_page * r.info.max_pages,
523 | )
524 | for cid in commit_ids:
525 | commit_count += 1
526 | if cid in commits_written:
527 | continue
528 | commits_written.add(cid)
529 |
530 | write_to(
531 | "r/%s/c/%s/index.html" % (r.name, cid), commit, (r, cid)
532 | )
533 | if r.info.generate_patch:
534 | write_to(
535 | "r/%s/c/%s.patch" % (r.name, cid), patch, (r, cid)
536 | )
537 |
538 | # To avoid regenerating files that have not changed, we will
539 | # instruct write_to() to set their mtime to the branch's committer
540 | # date, and then compare against it to decide whether or not to
541 | # write.
542 | branch_mtime = r.commit(bn).committer_date.epoch
543 | if branch_mtime > last_commit_timestamp[r.name]:
544 | last_commit_timestamp[r.name] = branch_mtime
545 |
546 | nr_pages = int(
547 | math.ceil(float(commit_count) / r.info.commits_per_page)
548 | )
549 | nr_pages = min(nr_pages, r.info.max_pages)
550 |
551 | for page in range(nr_pages):
552 | write_to(
553 | "r/%s/b/%s/%d.html" % (r.name, bn, page),
554 | branch,
555 | (r, bn, page),
556 | branch_mtime,
557 | )
558 |
559 | link(
560 | from_path="r/%s/b/%s/index.html" % (r.name, bn),
561 | to_path="0.html",
562 | )
563 |
564 | if r.info.generate_tree:
565 | write_tree(r, bn, branch_mtime)
566 |
567 | for tag_name, obj_id in r.tags():
568 | try:
569 | write_to(
570 | "r/%s/c/%s/index.html" % (r.name, obj_id),
571 | commit,
572 | (r, obj_id),
573 | )
574 | except bottle.HTTPError as e:
575 | # Some repos can have tags pointing to non-commits. This
576 | # happens in the Linux Kernel's v2.6.11, which points directly
577 | # to a tree. Ignore them.
578 | if is_404(e):
579 | print("404 in tag %s (%s)" % (tag_name, obj_id))
580 | else:
581 | raise
582 |
583 | update_modified_ts_json(last_commit_timestamp)
584 |
585 |
586 | def main():
587 | parser = optparse.OptionParser("usage: %prog [options] serve|generate")
588 | parser.add_option(
589 | "-c", "--config", metavar="FILE", help="configuration file"
590 | )
591 | parser.add_option(
592 | "-o", "--output", metavar="DIR", help="output directory (for generate)"
593 | )
594 | parser.add_option(
595 | "",
596 | "--only",
597 | metavar="REPO",
598 | action="append",
599 | default=[],
600 | help="generate/serve only this repository",
601 | )
602 | opts, args = parser.parse_args()
603 |
604 | if not opts.config:
605 | parser.error("--config is mandatory")
606 |
607 | try:
608 | load_config(opts.config)
609 | except (configparser.NoOptionError, ValueError) as e:
610 | print("Error parsing config:", e)
611 | return
612 |
613 | if not args:
614 | parser.error("Must specify an action (serve|generate)")
615 |
616 | if args[0] == "serve":
617 | if os.environ.get("GIT_ARR_DEBUG"):
618 | bottle.debug(True)
619 | bottle.run(host="localhost", port=8008, reloader=True)
620 | elif args[0] == "generate":
621 | if not opts.output:
622 | parser.error("Must specify --output")
623 | generate(output=opts.output, only=opts.only)
624 | else:
625 | parser.error("Unknown action %s" % args[0])
626 |
627 |
628 | if __name__ == "__main__":
629 | main()
630 |
--------------------------------------------------------------------------------
]