├── .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 |
3 | % if offset > 0: 4 | ← prev 5 | % else: 6 | ← prev 7 | % end 8 | | 9 | % if more: 10 | next → 11 | % else: 12 | next → 13 | % end 14 |
15 | 16 | -------------------------------------------------------------------------------- /hooks/README: -------------------------------------------------------------------------------- 1 | 2 | You can use the post-receive hook to automatically generate the repository 3 | view after a push. 4 | 5 | To do so, configure in your target repository the following options: 6 | 7 | $ git config hooks.git-arr-config /path/to/site.conf 8 | $ git config hooks.git-arr-output /var/www/git/ 9 | 10 | # Only if the git-arr executable is not on your $PATH. 11 | $ git config hooks.git-arr-path /path/to/git-arr 12 | 13 | Then copy the post-receive file to the "hooks" directory in your repository. 14 | 15 | -------------------------------------------------------------------------------- /views/tree-list.html: -------------------------------------------------------------------------------- 1 | 2 | % key_func = lambda x: (x[0] != 'tree', x[1].raw) 3 | % for type, name, oid, size in sorted(tree.ls(dirname.raw), key = key_func): 4 | 5 | % if type == "blob": 6 | 8 | 9 | % elif type == "tree": 10 | 13 | % elif type == "commit": 14 | 15 | 16 | % end 17 | 18 | % end 19 |
7 | {{!name.html}}{{size}} 11 | 12 | {{!name.html}}/{{!name.html}}(submodule)
20 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | git 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

git

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | % for repo in sorted(repos.values(), key = lambda r: r.name): 21 | 22 | 23 | 25 | 26 | 27 | %end 28 |
projectdescription
{{repo.name}} 24 | {{repo.info.desc}}
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | git-arr is under the MIT licence, which is reproduced below (taken from 2 | http://opensource.org/licenses/MIT). 3 | 4 | ----- 5 | 6 | Copyright (c) 2012 Alberto Bertogli 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | 26 | -------------------------------------------------------------------------------- /views/tree.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | % if not dirname.raw: 6 | % reltree = './' 7 | % else: 8 | % reltree = '../' * (len(dirname.split('/')) - 1) 9 | % end 10 | % relroot = reltree + '../' * (len(branch.split('/')) - 1) 11 | 12 | git » {{repo.name}} » 13 | {{branch}} » {{dirname.raw if dirname.raw else '/'}} 14 | 16 | 17 | 18 | 19 | 20 | 21 |

git » 22 | {{repo.name}} » 23 | {{branch}} » 24 | tree 25 |

26 | 27 |

28 | [{{branch}}] / 29 | % base = smstr(reltree) 30 | % for c in dirname.split('/'): 31 | % if not c.raw: 32 | % continue 33 | % end 34 | {{c.raw}} / 35 | % base += c + '/' 36 | % end 37 |

38 | 39 | % include("tree-list", repo=repo, tree=tree, treeroot=".") 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /views/commit-list.html: -------------------------------------------------------------------------------- 1 | 2 | % def refs_to_html(refs): 3 | % for ref in refs: 4 | % c = ref.split('/', 2) 5 | % if len(c) != 3: 6 | % return 7 | % end 8 | % if c[1] == 'heads': 9 | {{c[2]}} 10 | % elif c[1] == 'tags': 11 | % if c[2].endswith('^{}'): 12 | % c[2] = c[2][:-3] 13 | % end 14 | {{c[2]}} 15 | % end 16 | % end 17 | % end 18 | 19 | 20 | 21 | % refs = repo.refs() 22 | % if not defined("commits"): 23 | % commits = repo.commits(start_ref, limit = limit, offset = offset) 24 | % end 25 | 26 | % for c in commits: 27 | 28 | 31 | 36 | 39 | % if c.id in refs: 40 | 43 | % end 44 | 45 | % end 46 |
29 | {{c.author_date.utc.date()}} 30 | 32 | 34 | {{shorten(c.subject)}} 35 | 37 | {{shorten(c.author_name, 26)}} 38 | 41 | % refs_to_html(refs[c.id]) 42 |
47 | 48 | -------------------------------------------------------------------------------- /views/branch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | % relroot = '../' * (len(branch.split('/')) - 1) 6 | 7 | git » {{repo.name}} » {{branch}} 8 | 9 | 10 | 11 | 12 | 13 | 14 |

git » 15 | {{repo.name}} » 16 | {{branch}} 17 |

18 | 19 |

20 | Browse current source tree 21 |

22 | 23 | % commits = repo.commits("refs/heads/" + branch, 24 | % limit = repo.info.commits_per_page + 1, 25 | % offset = repo.info.commits_per_page * offset) 26 | % commits = list(commits) 27 | 28 | % if len(commits) == 0: 29 | % abort(404, "No more commits") 30 | % end 31 | 32 | % more = len(commits) > repo.info.commits_per_page 33 | % if more: 34 | % commits = commits[:-1] 35 | % end 36 | % more = more and offset + 1 < repo.info.max_pages 37 | 38 | % include("paginate", more=more, offset=offset) 39 | 40 | % include("commit-list", repo=repo, commits=commits, shorten=shorten, 41 | % repo_root=relroot + "../..") 42 | 43 |

44 | 45 | % include("paginate", more=more, offset=offset) 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /hooks/post-receive: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # git-arr post-receive hook 4 | # 5 | # This is a script intended to be used as a post-receive hook, which updates 6 | # its git-arr view. 7 | # 8 | # You should place it /path/to/your/repository.git/hooks/. 9 | 10 | # Config 11 | # -------- 12 | # 13 | # hooks.git-arr-config 14 | # The git-arr configuration file to use. Mandatory. 15 | # Example: /srv/git-arr/site.conf 16 | # 17 | # hooks.git-arr-output 18 | # Directory for the generated output. Mandatory. 19 | # Example: /srv/www/git/ 20 | # 21 | # hooks.git-arr-path 22 | # The path to the git-arr executable. Optional, defaults to "git-arr". 23 | # 24 | # hooks.git-arr-repo-name 25 | # The git-arr repository name. Optional, defaults to the path name. 26 | 27 | git_arr_config="$(git config --path hooks.git-arr-config)" 28 | git_arr_output="$(git config --path hooks.git-arr-output)" 29 | 30 | git_arr_path="$(git config --path hooks.git-arr-path 2> /dev/null)" 31 | git_arr_repo_name="$(git config hooks.git-arr-repo-name 2> /dev/null)" 32 | 33 | if [ -z "$git_arr_config" -o -z "$git_arr_output" ]; then 34 | echo "Error: missing config options." 35 | echo "Both hooks.git-arr-config and hooks.git-arr-output must be set." 36 | exit 1 37 | fi 38 | 39 | if [ -z "$git_arr_path" ]; then 40 | git_arr_path=git-arr 41 | fi 42 | 43 | if [ -z "$git_arr_repo_name" ]; then 44 | PARENT_DIR=$(cd $(dirname "$0")/..; echo "$PWD") 45 | git_arr_repo_name=$(basename "$PARENT_DIR") 46 | fi 47 | 48 | echo "Running git-arr" 49 | $git_arr_path --config "$git_arr_config" generate \ 50 | --output "$git_arr_output" \ 51 | --only "$git_arr_repo_name" > /dev/null 52 | RESULT=$? 53 | 54 | if [ $RESULT -ne 0 ]; then 55 | echo "Error running git-arr" 56 | exit $RESULT 57 | fi 58 | 59 | -------------------------------------------------------------------------------- /views/commit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | git » {{repo.name}} » commit {{c.id[:7]}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

git » 13 | {{repo.name}} » commit {{c.id[:7]}} 14 |

15 | 16 |

{{c.subject}}

17 | 18 | 19 | 20 | 24 | 25 | 29 | 30 | % for p in c.parents: 31 | 32 | 33 | % end 34 |
author{{c.author_name}} 21 |
22 | 23 | {{c.author_date.utc}} UTC
committer{{c.committer_name}} 26 |
27 | 28 | {{c.committer_date.utc}} UTC
parent{{p}}
35 | 36 |
37 | 38 |
39 | {{c.message.strip()}}
40 | 
41 | 42 |
43 | 44 | % if c.diff.changes: 45 | 46 | 47 | % for added, deleted, fname in c.diff.changes: 48 | 49 | 50 | 51 | 52 | 53 | % end 54 |
{{!fname.html}}+{{added}}-{{deleted}}
55 | 56 |
57 | 58 | % if can_colorize(c.diff.body): 59 |
60 | {{!colorize_diff(c.diff.body)}} 61 |
62 | % else: 63 |
64 | {{c.diff.body}}
65 | 
66 | % end 67 | 68 |
69 | 70 | % end 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # git-arr - A git repository browser 3 | 4 | [git-arr] is a [git] repository browser that can generate static HTML. 5 | 6 | It is smaller, with less features and a different set of tradeoffs than 7 | other similar software, so if you're looking for a robust and featureful git 8 | browser, please look at [gitweb] or [cgit] instead. 9 | 10 | However, if you want to generate static HTML at the expense of features, then 11 | it's probably going to be useful. 12 | 13 | It's open source under the MIT licence, please see the `LICENSE` file for more 14 | information. 15 | 16 | [git-arr]: https://blitiri.com.ar/p/git-arr/ 17 | [git]: https://git-scm.com/ 18 | [gitweb]: https://git-scm.com/docs/gitweb 19 | [cgit]: https://git.zx2c4.com/cgit/about/ 20 | 21 | 22 | ## Getting started 23 | 24 | You will need [Python 3], and the [bottle.py] framework (the package is usually 25 | called `python3-bottle` in most distributions). 26 | 27 | If [pygments] is available, it will be used for syntax highlighting, otherwise 28 | everything will work fine, just in black and white. 29 | 30 | 31 | First, create a configuration file for your repositories. You can start by 32 | copying `sample.conf`, which has the list of the available options. 33 | 34 | Then, to generate the output to `/var/www/git-arr/` directory, run: 35 | 36 | ```sh 37 | ./git-arr --config config.conf generate --output /var/www/git-arr/ 38 | ``` 39 | 40 | That's it! 41 | 42 | The first time you generate, depending on the size of your repositories, it 43 | can take some time. Subsequent runs should take less time, as it is smart 44 | enough to only generate what has changed. 45 | 46 | You can also use git-arr dynamically, although it's not its intended mode of 47 | use, by running: 48 | 49 | ``` 50 | ./git-arr --config config.conf serve 51 | ``` 52 | 53 | That can be useful when making changes to the software itself. 54 | 55 | 56 | [Python 3]: https://www.python.org/ 57 | [bottle.py]: https://bottlepy.org/ 58 | [pygments]: https://pygments.org/ 59 | 60 | 61 | ## Contact 62 | 63 | If you want to report bugs, send patches, or have any questions or comments, 64 | just let me know at albertito@blitiri.com.ar. 65 | 66 | -------------------------------------------------------------------------------- /views/blob.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | % if not dirname.raw: 6 | % reltree = './' 7 | % else: 8 | % reltree = '../' * (len(dirname.split('/')) - 1) 9 | % end 10 | % relroot = reltree + '../' * (len(branch.split('/')) - 1) 11 | 12 | git » {{repo.name}} » 13 | {{branch}} » {{dirname.raw}}{{fname.raw}} 14 | 16 | 18 | 19 | 20 | 21 | 22 | 23 |

git » 24 | {{repo.name}} » 25 | {{branch}} » 26 | tree 27 |

28 | 29 |

30 | [{{branch}}] / 31 | % base = smstr(reltree) 32 | % for c in dirname.split('/'): 33 | % if not c.raw: 34 | % continue 35 | % end 36 | {{c.raw}} / 37 | % base += c + '/' 38 | % end 39 | {{!fname.html}} 40 |

41 | 42 | % if len(blob.raw_content) == 0: 43 | 44 | 45 | 46 | 47 |
empty — 0 bytes
48 | % elif can_embed_image(repo, fname.raw): 49 | {{!embed_image_blob(fname.raw, blob.raw_content)}} 50 | % elif is_binary(blob.raw_content): 51 | 52 | 53 | 56 | 57 | % lim = 256 58 | % for offset, hex1, hex2, text in hexdump(blob.raw_content[:lim]): 59 | 60 | 61 | 62 | 63 | 64 | 65 | % end 66 | % if lim < len(blob.raw_content): 67 | 68 | 69 | 70 | 71 | 72 | 73 | % end 74 |
54 | binary — {{'{:,}'.format(len(blob.raw_content))}} bytes 55 |
{{offset}}
{{hex1}}
{{hex2}}
{{text}}
75 | % elif can_markdown(repo, fname.raw): 76 |
77 | {{!markdown_blob(blob.utf8_content)}} 78 |
79 | % elif can_colorize(blob.utf8_content): 80 |
81 | {{!colorize_blob(fname.raw, blob.utf8_content)}} 82 |
83 | % else: 84 |
85 | {{blob.utf8_content}}
86 | 
87 | % end 88 | 89 |
90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /views/summary.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | git » {{repo.name}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

git » {{repo.name}}

13 | 14 |

{{repo.info.desc}}

15 | 16 | 17 | % if repo.info.web_url or repo.info.git_url: 18 | 19 | 20 | % if repo.info.web_url: 21 | 22 | 23 | 25 | 26 | % end 27 | % if repo.info.git_url: 28 | 29 | 30 | 31 | 32 | % end 33 | 34 |
website 24 | {{repo.info.web_url}}
git clone {{! '
'.join(repo.info.git_url.split())}}
35 |
36 | % end 37 | 38 | % if repo.main_branch(): 39 |
40 | commits ({{repo.main_branch()}}) 41 |
42 | % include("commit-list", 43 | % repo=repo, 44 | % start_ref="refs/heads/" + repo.main_branch(), 45 | % limit=repo.info.commits_in_summary, 46 | % shorten=shorten, repo_root=".", offset=0) 47 |
48 |
49 | tree ({{repo.main_branch()}}) 50 |
51 | % include("tree-list", 52 | % repo=repo, tree=repo.tree(repo.main_branch()), 53 | % treeroot="b/" + repo.main_branch() + "/t", 54 | % dirname=smstr.from_url("")) 55 |
56 | % end 57 | 58 |
branches
59 | 60 | % for b in repo.branch_names(): 61 | 62 | 63 | 65 | 67 | 68 | %end 69 |
{{b}}
70 | 71 |
72 | 73 | % tags = list(repo.tags()) 74 | % if tags: 75 |
tags
76 | 77 | % for name, obj_id in tags: 78 | 79 | 80 | 81 | %end 82 |
{{name}}
83 | % end 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /static/git-arr.js: -------------------------------------------------------------------------------- 1 | /* Miscellaneous javascript functions for git-arr. */ 2 | 3 | /* Return the current timestamp. */ 4 | function now() { 5 | return (new Date().getTime() / 1000); 6 | } 7 | 8 | /* Return a human readable string telling "how long ago" for a timestamp. */ 9 | function how_long_ago(timestamp) { 10 | if (timestamp < 0) 11 | return "never"; 12 | 13 | var seconds = Math.floor(now() - timestamp); 14 | 15 | var interval = Math.floor(seconds / (365 * 24 * 60 * 60)); 16 | if (interval > 1) 17 | return interval + " years ago"; 18 | 19 | interval = Math.floor(seconds / (30 * 24 * 60 * 60)); 20 | if (interval > 1) 21 | return interval + " months ago"; 22 | 23 | interval = Math.floor(seconds / (24 * 60 * 60)); 24 | 25 | if (interval > 1) 26 | return interval + " days ago"; 27 | interval = Math.floor(seconds / (60 * 60)); 28 | 29 | if (interval > 1) 30 | return interval + " hours ago"; 31 | 32 | interval = Math.floor(seconds / 60); 33 | if (interval > 1) 34 | return interval + " minutes ago"; 35 | 36 | if (seconds > 1) 37 | return Math.floor(seconds) + " seconds ago"; 38 | 39 | return "about now"; 40 | } 41 | 42 | /* Load the timestamps from the modified_ts.json file, and then 43 | * insert the human-friendly representation into the corresponding span.age 44 | * elements. */ 45 | async function load_timestamps() { 46 | const response = await fetch("modified_ts.json"); 47 | if (!response.ok) { 48 | throw new Error(`fetch error, status: ${response.status}`); 49 | } 50 | 51 | const json = await response.json(); 52 | console.log("Loaded timestamps:", json); 53 | 54 | for (const [repo_name, timestamp] of Object.entries(json)) { 55 | const e = document.getElementById("age:" + repo_name); 56 | if (!e) { 57 | console.warn(`No element found for repo: ${repo_name}`); 58 | continue; 59 | } 60 | e.innerHTML = how_long_ago(timestamp); 61 | e.style.display = "inline"; 62 | 63 | if (timestamp > 0) { 64 | var age = now() - timestamp; 65 | if (age < (2 * 60 * 60)) 66 | e.className = e.className + " age-band0"; 67 | else if (age < (3 * 24 * 60 * 60)) 68 | e.className = e.className + " age-band1"; 69 | else if (age < (30 * 24 * 60 * 60)) 70 | e.className = e.className + " age-band2"; 71 | } 72 | } 73 | } 74 | 75 | function toggle(id) { 76 | var e = document.getElementById(id); 77 | 78 | if (e.style.display == "") { 79 | e.style.display = "none" 80 | } else if (e.style.display == "none") { 81 | e.style.display = "" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /sample.conf: -------------------------------------------------------------------------------- 1 | 2 | # A single repository. 3 | [repo] 4 | path = /srv/git/repo/ 5 | 6 | # Description (optional). 7 | # Default: Read from /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 | 


--------------------------------------------------------------------------------