vextoly/gitman
Fork of patx/gitman.
Add browser file editing, BSD licenses, preferences with time zones, and a password page
Commit 490cf35 · niklas · 2026-11-03T21:07:39+01:00
Comments
No comments yet.
Diff
diff --git a/app.py b/app.py
index 2c35a7c..fd88f82 100644
--- a/app.py
+++ b/app.py
@@ -18,6 +18,7 @@ import sys
import threading
import time
import tempfile
+import zoneinfo
from socketserver import ThreadingMixIn
from pathlib import Path, PurePosixPath
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlparse
@@ -151,6 +152,100 @@ NULL_REV = "null"
NULL_NODE = "0" * 40
EMPTY_TREE_NODE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
README_CANDIDATES = ("README.md", "README.rst", "README.txt", "README")
+LICENSE_FILE_CANDIDATES = ("LICENSE.md", "LICENSE.txt", "LICENSE", "LICENCE.md", "LICENCE.txt", "LICENCE")
+BSD_DISCLAIMER = (
+ "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" "
+ "AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE "
+ "IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE "
+ "DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE "
+ "FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL "
+ "DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR "
+ "SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER "
+ "CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, "
+ "OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE "
+ "OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
+)
+BSD_PERMISSION_FEE = (
+ "Permission to use, copy, modify, and/or distribute this software for any "
+ "purpose with or without fee is hereby granted."
+)
+BSD_FEE_WARRANTY = (
+ "THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH "
+ "REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY "
+ "AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, "
+ "INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM "
+ "LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR "
+ "OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR "
+ "PERFORMANCE OF THIS SOFTWARE."
+)
+BSD_REDISTRIBUTION_CONDITIONS = (
+ "Redistribution and use in source and binary forms, with or without "
+ "modification, are permitted provided that the following conditions are met:\n"
+ "\n"
+ "1. Redistributions of source code must retain the above copyright notice, "
+ "this list of conditions and the following disclaimer.\n"
+ "\n"
+ "2. Redistributions in binary form must reproduce the above copyright notice, "
+ "this list of conditions and the following disclaimer in the documentation "
+ "and/or other materials provided with the distribution."
+)
+LICENSE_TEXTS = {
+ "bsd-0": BSD_PERMISSION_FEE + "\n\n" + BSD_FEE_WARRANTY,
+ "bsd-1": (
+ "Copyright (c) {year}, {holder}\n\n"
+ + BSD_PERMISSION_FEE
+ + "\n\n"
+ + BSD_FEE_WARRANTY
+ ),
+ "bsd-2": (
+ "BSD 2-Clause License\n\n"
+ "Copyright (c) {year}, {holder}\n\n"
+ + BSD_REDISTRIBUTION_CONDITIONS
+ + "\n\n"
+ + BSD_DISCLAIMER
+ ),
+ "bsd-3": (
+ "BSD 3-Clause License\n\n"
+ "Copyright (c) {year}, {holder}\n\n"
+ + BSD_REDISTRIBUTION_CONDITIONS
+ + "\n\n"
+ "3. Neither the name of the copyright holder nor the names of its "
+ "contributors may be used to endorse or promote products derived from "
+ "this software without specific prior written permission.\n\n"
+ + BSD_DISCLAIMER
+ ),
+ "bsd-4": (
+ "BSD 4-Clause License (Original BSD License)\n\n"
+ "Copyright (c) {year}, {holder}. All rights reserved.\n\n"
+ "Redistribution and use in source and binary forms, with or without "
+ "modification, are permitted provided that the following conditions are met:\n"
+ "\n"
+ "1. Redistributions of source code must retain the above copyright notice, "
+ "this list of conditions and the following disclaimer.\n"
+ "\n"
+ "2. Redistributions in binary form must reproduce the above copyright notice, "
+ "this list of conditions and the following disclaimer in the documentation "
+ "and/or other materials provided with the distribution.\n"
+ "\n"
+ "3. All advertising materials mentioning features or use of this software "
+ "must display the following acknowledgement: This product includes software "
+ "developed by {holder}.\n"
+ "\n"
+ "4. Neither the name of the copyright holder nor the names of its "
+ "contributors may be used to endorse or promote products derived from "
+ "this software without specific prior written permission.\n"
+ "\n"
+ + BSD_DISCLAIMER
+ ),
+}
+LICENSE_LABELS = {
+ "": "None/Custom",
+ "bsd-0": "BSD 0-Clause",
+ "bsd-1": "BSD 1-Clause",
+ "bsd-2": "BSD 2-Clause",
+ "bsd-3": "BSD 3-Clause",
+ "bsd-4": "BSD 4-Clause",
+}
MARKDOWN_EXTENSIONS = ("extra", "sane_lists")
MARKDOWN_TAGS = {
"a",
@@ -355,6 +450,7 @@ def init_db():
display_name TEXT NOT NULL DEFAULT '',
bio TEXT NOT NULL DEFAULT '',
website TEXT NOT NULL DEFAULT '',
+ timezone TEXT NOT NULL DEFAULT 'UTC',
created_at TEXT NOT NULL
);
@@ -547,6 +643,7 @@ def ensure_user_profile_columns(conn):
"display_name": "ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''",
"bio": "ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT ''",
"website": "ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT ''",
+ "timezone": "ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'",
}
for name, ddl in profile_columns.items():
if name not in columns:
@@ -1019,6 +1116,7 @@ def render(template_name, **context):
context.setdefault("error", None)
context.setdefault("notice", None)
context.setdefault("csrf_field", csrf_field)
+ context.setdefault("format_datetime", format_datetime)
context.setdefault("render_markdown", render_markdown)
context.setdefault("render_markdown_links", render_markdown_links)
context.setdefault("render_mentions", render_mentions)
@@ -1093,6 +1191,49 @@ def request_per_page(default):
return parse_bounded_int(request.query.get("per_page"), default, minimum=1, maximum=MAX_PAGE_SIZE)
+def available_timezone_choices():
+ try:
+ zones = sorted(zoneinfo.available_timezones())
+ except Exception:
+ zones = []
+ if not zones:
+ zones = ["UTC"]
+ elif "UTC" not in zones:
+ zones = ["UTC", *zones]
+ return zones
+
+
+def user_timezone_name(user):
+ try:
+ name = (user["timezone"] or "").strip() if user else ""
+ except (IndexError, KeyError):
+ name = ""
+ if not name:
+ return "UTC"
+ try:
+ zoneinfo.ZoneInfo(name)
+ except (zoneinfo.ZoneInfoNotFoundError, ValueError, KeyError):
+ return "UTC"
+ return name
+
+
+def format_datetime(value):
+ """Render a stored UTC timestamp in the signed-in user's time zone."""
+ if not value:
+ return ""
+ text = str(value).strip()
+ if text.endswith("Z"):
+ text = text[:-1] + "+00:00"
+ try:
+ parsed = dt.datetime.fromisoformat(text)
+ except ValueError:
+ return str(value)
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=dt.UTC)
+ zone = zoneinfo.ZoneInfo(user_timezone_name(current_user()))
+ return parsed.astimezone(zone).strftime("%Y-%m-%d %H:%M")
+
+
def page_offset(page, per_page):
return (page - 1) * per_page
@@ -2057,6 +2198,90 @@ def fork_repository(owner, source_repo, name, description):
raise
+def default_branch_name(path):
+ completed = run_git(["symbolic-ref", "--short", "HEAD"], cwd=path, check=False)
+ name = completed.stdout.strip() if completed.returncode == 0 else ""
+ return name or "main"
+
+
+def license_text_for_choice(license_choice, holder):
+ template = LICENSE_TEXTS.get(license_choice)
+ if not template:
+ return None
+ return template.format(year=dt.datetime.now(dt.UTC).year, holder=holder)
+
+
+def repo_branch_exists(path, branch):
+ return (
+ run_git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=path, check=False).returncode == 0
+ )
+
+
+def commit_file_to_repo(repo, path, file_path, content, message, user, branch=None):
+ """Commit text file content to a bare repository through a temporary working clone."""
+ file_path = clean_repo_path(file_path)
+ if branch is None:
+ branch = default_branch_name(path)
+ branch_exists = repo_branch_exists(path, branch)
+
+ with tempfile.TemporaryDirectory(prefix="gitman-edit-") as tempdir:
+ work_path = Path(tempdir) / "work"
+ clone = run_git(["clone", str(path), str(work_path)], check=False, timeout=60)
+ if clone.returncode != 0:
+ raise GitCommandError(clone.stderr.strip() or "Unable to clone repository.", clone.returncode)
+
+ run_git(["config", "user.name", user["username"]], cwd=work_path)
+ run_git(["config", "user.email", f"{user['username']}@gitman.local"], cwd=work_path)
+
+ head_exists = run_git(["rev-parse", "--verify", "HEAD"], cwd=work_path, check=False).returncode == 0
+ if head_exists:
+ checkout = run_git(["checkout", "-B", branch, f"origin/{branch}"], cwd=work_path, check=False, timeout=30)
+ if checkout.returncode != 0:
+ checkout = run_git(["checkout", branch], cwd=work_path, check=False, timeout=30)
+ if checkout.returncode != 0:
+ raise GitCommandError(checkout.stderr.strip() or "Unable to check out the branch.", checkout.returncode)
+ else:
+ run_git(["symbolic-ref", "HEAD", f"refs/heads/{branch}"], cwd=work_path)
+
+ target = work_path / file_path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(content, encoding="utf-8")
+ run_git(["add", "--", file_path], cwd=work_path)
+ commit = run_git(["commit", "-m", message, "--", file_path], cwd=work_path, check=False, timeout=30)
+ if commit.returncode != 0:
+ raise GitCommandError(commit.stderr.strip() or "Unable to commit the file.", commit.returncode)
+ node = run_git(["rev-parse", "HEAD"], cwd=work_path).stdout.strip()
+
+ with repo_import_lock(repo):
+ prepare_repo_for_receive(path)
+ push = run_git(
+ ["push", "origin", f"HEAD:refs/heads/{branch}"],
+ cwd=work_path,
+ check=False,
+ timeout=60,
+ )
+ if push.returncode != 0:
+ raise GitCommandError(
+ push.stderr.strip() or "Unable to update the branch. It may have changed; try again.",
+ push.returncode,
+ )
+ return node
+
+
+def license_file_for_repo(path, revision="HEAD"):
+ if not revision or is_null_revision(revision):
+ return None
+ completed = run_git(["ls-tree", "-r", "--name-only", revision], cwd=path, check=False)
+ if completed.returncode != 0:
+ return None
+ by_lower = {name.lower(): name for name in completed.stdout.splitlines() if name.strip()}
+ for candidate in LICENSE_FILE_CANDIDATES:
+ actual = by_lower.get(candidate.lower())
+ if actual:
+ return actual
+ return None
+
+
def run_git_import(args, cwd=None, check=True):
git_config = {"pack.threads": GIT_IMPORT_PACK_THREADS}
try:
@@ -4631,6 +4856,7 @@ def repo_active_tab(repo):
("branches", "/branches"),
("issues", "/issues"),
("pulls", "/pulls"),
+ ("license", "/license"),
("settings", "/settings"),
):
if current_path == base_path + suffix or current_path.startswith(base_path + suffix + "/"):
@@ -5064,9 +5290,47 @@ def edit_profile():
return render("edit_profile.tpl", profile=values, error=str(exc))
[email protected]("/settings/password")
[email protected]("/settings/preferences", method=["GET", "POST"])
+def preferences():
+ user = require_login()
+ timezone_choices = available_timezone_choices()
+ if request.method == "GET":
+ return render(
+ "preferences.tpl",
+ profile=user,
+ timezone_choices=timezone_choices,
+ current_timezone=user_timezone_name(user),
+ )
+
+ timezone = (request.forms.get("timezone") or "").strip()
+ if timezone not in timezone_choices:
+ response.status = 400
+ return render(
+ "preferences.tpl",
+ profile=user,
+ timezone_choices=timezone_choices,
+ current_timezone=user_timezone_name(user),
+ error="Choose a valid time zone.",
+ )
+ with db_connect() as conn:
+ conn.execute("UPDATE users SET timezone = ? WHERE id = ?", (timezone, user["id"]))
+ refreshed = get_user_by_id(user["id"])
+ request.environ["gitman.user"] = refreshed
+ return render(
+ "preferences.tpl",
+ profile=refreshed,
+ timezone_choices=timezone_choices,
+ current_timezone=timezone,
+ notice="Preferences saved.",
+ )
+
+
[email protected]("/settings/password", method=["GET", "POST"])
def change_password():
user = require_login()
+ if request.method == "GET":
+ return render("password_page.tpl")
+
identifier = str(user["id"])
if rate_limit_blocked("password-change", identifier):
return too_many_requests_response()
@@ -5075,7 +5339,7 @@ def change_password():
confirmation = request.forms.get("confirm_password", "")
def invalid(message, status=400):
response.status = status
- return render_user_profile(user, password_error=message)
+ return render("password_page.tpl", error=message)
if len(current) > 1024 or not verify_password(current, user["password_hash"]):
record_auth_failure("password-change", identifier)
return invalid("Current password is incorrect.")
@@ -5097,23 +5361,52 @@ def change_password():
refreshed = get_user_by_id(user["id"])
request.environ["gitman.user"] = refreshed
login_user(refreshed)
- return render_user_profile(refreshed, notice="Password changed. Other browser sessions are signed out.")
+ return render("password_page.tpl", notice="Password changed. Other browser sessions are signed out.")
@app.route("/new", method=["GET", "POST"])
def new_repo():
user = require_login()
+ license_choices = list(LICENSE_LABELS.items())
if request.method == "GET":
- return render("new_repo.tpl")
+ return render("new_repo.tpl", license_choices=license_choices)
repo_name_raw = request.forms.get("name", "")
description = request.forms.get("description", "").strip()[:500]
+ license_choice = request.forms.get("license", "")
+ if license_choice not in LICENSE_LABELS:
+ license_choice = ""
try:
repo_name = normalize_slug(repo_name_raw, "Repository name")
create_repository(user, repo_name, description)
+ if license_choice in LICENSE_TEXTS:
+ repo = get_repo(user["username"], repo_name)
+ if repo:
+ try:
+ license_text = license_text_for_choice(
+ license_choice, user["display_name"] or user["username"]
+ )
+ commit_file_to_repo(
+ repo,
+ repo_path(user["username"], repo_name),
+ "LICENSE",
+ license_text,
+ "Add LICENSE",
+ user,
+ branch="main",
+ )
+ except GitCommandError:
+ pass
redirect(f"/{user['username']}/{repo_name}")
except (ValueError, GitCommandError) as exc:
- return render("new_repo.tpl", error=str(exc), name=repo_name_raw, description=description)
+ return render(
+ "new_repo.tpl",
+ error=str(exc),
+ name=repo_name_raw,
+ description=description,
+ license_choices=license_choices,
+ selected_license=license_choice,
+ )
@app.route("/<username>")
@@ -5449,6 +5742,88 @@ def repo_raw(owner, repo_name, file_path):
return HTTPResponse(content, content_type=content_type)
[email protected]("/<owner>/<repo_name>/license")
+def repo_license(owner, repo_name):
+ repo = get_repo(owner, repo_name)
+ if not repo:
+ abort(404, "Repository not found.")
+ path = repo_path(owner, repo_name)
+ selected_ref = selected_repo_ref(path)
+ revision = ref_revision(selected_ref)
+ license_name = license_file_for_repo(path, revision=revision)
+ license_text = None
+ license_html = None
+ if license_name:
+ try:
+ license_text = git_cat(path, license_name, revision=revision)
+ except GitCommandError:
+ license_text = None
+ license_text, _truncated = truncate_text_for_render(license_text or "", label="License preview")
+ if license_text and is_markdown_file(license_name):
+ license_html = render_markdown(license_text)
+ return render(
+ "license.tpl",
+ repo=repo,
+ license_name=license_name,
+ license_text=license_text,
+ license_html=license_html,
+ **repo_page_context(repo, path, selected_ref=selected_ref),
+ )
+
+
[email protected]("/<owner>/<repo_name>/edit/<file_path:path>", method=["GET", "POST"])
+def repo_edit_file(owner, repo_name, file_path):
+ user = require_login()
+ repo = get_repo(owner, repo_name)
+ if not repo:
+ abort(404, "Repository not found.")
+ if not user_can_maintain_repo(user, repo):
+ abort(403, "Only the repository owner or contributors can edit files.")
+ file_path = clean_repo_path(file_path)
+ path = repo_path(owner, repo_name)
+ selected_ref = selected_repo_ref(path)
+ if selected_ref and selected_ref.get("type") == REF_TYPE_BRANCH and selected_ref.get("name"):
+ branch = selected_ref["name"]
+ else:
+ branch = default_branch_name(path)
+
+ if request.method == "GET":
+ try:
+ content = read_file_bytes(path, file_path, revision=f"refs/heads/{branch}")
+ except GitCommandError:
+ abort(404, "File not found.")
+ if b"\0" in content[:4096]:
+ abort(400, "Binary files cannot be edited in the browser.")
+ return render(
+ "edit_file.tpl",
+ repo=repo,
+ file_path=file_path,
+ branch=branch,
+ content_value=content.decode("utf-8", "replace"),
+ message_value=f"Update {file_path}",
+ quote_path=quote_path,
+ **repo_page_context(repo, path, selected_ref=selected_ref),
+ )
+
+ content = request.forms.get("content", "")
+ message = (request.forms.get("message") or "").strip()[:200] or f"Update {file_path}"
+ try:
+ commit_file_to_repo(repo, path, file_path, content, message, user, branch=branch)
+ except GitCommandError as exc:
+ return render(
+ "edit_file.tpl",
+ repo=repo,
+ file_path=file_path,
+ branch=branch,
+ content_value=content,
+ message_value=message,
+ error=str(exc),
+ quote_path=quote_path,
+ **repo_page_context(repo, path, selected_ref=selected_ref),
+ )
+ redirect(url_with_ref(f"/{owner}/{repo_name}/src/{quote_path(file_path)}", selected_ref))
+
+
@app.route("/<owner>/<repo_name>/archive/<node>.zip")
def repo_archive(owner, repo_name, node):
repo = get_repo(owner, repo_name)
diff --git a/static/profile.js b/static/profile.js
deleted file mode 100644
index bb52a2a..0000000
--- a/static/profile.js
+++ /dev/null
@@ -1,14 +0,0 @@
-(() => {
- const dialog = document.getElementById('password-dialog');
- const open = document.getElementById('change-password-open');
- if (!dialog || !open) return;
- open.addEventListener('click', () => dialog.showModal());
- document.getElementById('change-password-cancel').addEventListener('click', () => dialog.close());
- dialog.addEventListener('close', () => {
- dialog.querySelector('form').reset();
- const error = dialog.querySelector('[role="alert"]');
- if (error) error.remove();
- open.focus();
- });
- if (dialog.dataset.reopen === 'true') dialog.showModal();
-})();
diff --git a/static/styles.css b/static/styles.css
index 72812f8..ca16915 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -135,6 +135,17 @@ pre code { padding: 0; background: transparent; border-radius: 0; font-size: inh
font: .875rem/1.55 var(--code-font);
}
.diff { max-height: 70vh; overflow: auto; }
+.edit-textarea {
+ font-family: var(--code-font);
+ font-size: .875rem;
+ line-height: 1.55;
+ white-space: pre;
+ overflow-wrap: normal;
+}
+.license-body {
+ max-height: 70vh;
+ overflow: auto;
+}
.copyable-code {
position: relative;
@@ -150,24 +161,31 @@ pre code { padding: 0; background: transparent; border-radius: 0; font-size: inh
background: transparent;
border-radius: 0;
}
+.code-actions {
+ position: absolute;
+ top: .4rem;
+ right: .5rem;
+ display: inline-flex;
+ align-items: center;
+ gap: .4rem;
+ z-index: 2;
+}
.copyable-code pre.code {
flex: 1 1 auto;
min-width: 0;
- padding: 2.4rem 1rem .75rem;
+ padding: .75rem 1rem;
}
.copyable-code pre.line-numbers {
flex: 0 0 auto;
overflow: hidden;
- padding: 2.4rem .75rem .75rem 1rem;
+ padding: .75rem .75rem .75rem 1rem;
color: var(--muted);
text-align: right;
user-select: none;
border-right: 1px solid var(--surface-border);
}
.copy-button {
- position: absolute;
- top: .55rem;
- right: .55rem;
+ position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -178,8 +196,9 @@ pre code { padding: 0; background: transparent; border-radius: 0; font-size: inh
border-radius: 999px;
background: var(--chip);
color: var(--ink);
+ text-decoration: none;
}
-.copy-button:hover { background: var(--ink); color: var(--page-bg); }
+.copy-button:hover { background: var(--ink); color: var(--page-bg); text-decoration: none; }
.copy-button svg { width: 1rem; height: 1rem; fill: currentColor; }
.copy-button.is-copied { background: var(--ink); color: var(--page-bg); }
.copy-button.copy-error { outline: 2px solid var(--ink); outline-offset: 1px; }
@@ -257,7 +276,7 @@ th { color: var(--ink); font-weight: 700; }
.panel > :last-child, .repo-header > :last-child, .auth-card > :last-child { margin-bottom: 0; }
.auth-card { max-width: 32rem; margin-bottom: 2.5rem; }
.auth-card.wide { max-width: 36rem; }
-.repo-title-row { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
+.repo-title-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.repo-title-row h1 { margin-bottom: 0; }
.repo-tabs .repo-tab { padding: .25rem 0; }
diff --git a/templates/activity_feed.tpl b/templates/activity_feed.tpl
index c2983c9..8eac3e9 100644
--- a/templates/activity_feed.tpl
+++ b/templates/activity_feed.tpl
@@ -16,7 +16,7 @@
% if show_repo_context:
in <a href="{{repo_url}}">{{action["repo_owner_username"]}}/{{action["repo_name"]}}</a> ·
% end
- {{action["occurred_at"]}}
+ {{format_datetime(action["occurred_at"])}}
</p>
% if action["detail"]:
<p class="activity-detail">{{!render_markdown_links(action["detail"])}}</p>
diff --git a/templates/branches.tpl b/templates/branches.tpl
index 679945d..c62e8c6 100644
--- a/templates/branches.tpl
+++ b/templates/branches.tpl
@@ -20,7 +20,7 @@
<code>{{branch["name"]}}</code>
<div style="margin-bottom:20px;">
<strong><a href="/{{repo['owner_username']}}/{{repo['name']}}/commits/{{branch['short_node']}}">{{branch["short_node"]}}</a></strong>
- <small>commit {{branch["short_node"]}} · {{branch["date"]}}</small>
+ <small>commit {{branch["short_node"]}} · {{format_datetime(branch["date"])}}</small>
<p>{{branch["summary"]}}
<small><a href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/src', branch, True)}}">Browse code</a> ·
<a href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/commits', branch, True)}}">Commits</a> ·
diff --git a/templates/comment.tpl b/templates/comment.tpl
index 1e886a6..d3f0886 100644
--- a/templates/comment.tpl
+++ b/templates/comment.tpl
@@ -1,7 +1,7 @@
<article class="comment">
<header class="comment-meta">
<strong><a href="/{{comment["author_username"]}}">@{{comment["author_username"]}}</a></strong>
- <small class="muted">{{comment["created_at"]}}</small>
+ <small class="muted">{{format_datetime(comment["created_at"])}}</small>
</header>
<div class="comment-body markdown-body">{{!render_markdown(comment["body"])}}</div>
</article>
diff --git a/templates/commit_detail.tpl b/templates/commit_detail.tpl
index 037f7b4..de87fcc 100644
--- a/templates/commit_detail.tpl
+++ b/templates/commit_detail.tpl
@@ -12,7 +12,7 @@
<section class="panel">
<h2>{{commit["description"].splitlines()[0] if commit["description"] else commit["short_node"]}}</h2>
- <p class="muted">Commit {{commit["short_node"]}} · {{commit["author"]}} · {{commit["date"]}}</p>
+ <p class="muted">Commit {{commit["short_node"]}} · {{commit["author"]}} · {{format_datetime(commit["date"])}}</p>
<dl class="meta-list">
<dt>Changeset</dt>
<dd>
diff --git a/templates/commits.tpl b/templates/commits.tpl
index 8d39215..a0364de 100644
--- a/templates/commits.tpl
+++ b/templates/commits.tpl
@@ -19,7 +19,7 @@
<code><a href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/commits/' + commit['node'], selected_ref)}}">{{commit["short_node"]}}</a></code>
<div>
<strong>{{commit["summary"]}}</strong>
- <small>{{commit["author"]}} · {{commit["date"]}}</small>
+ <small>{{commit["author"]}} · {{format_datetime(commit["date"])}}</small>
</div>
</li>
% end
diff --git a/templates/edit_file.tpl b/templates/edit_file.tpl
new file mode 100644
index 0000000..6d7a55c
--- /dev/null
+++ b/templates/edit_file.tpl
@@ -0,0 +1,34 @@
+% rebase("base.tpl", title="Edit " + file_path, user=user, error=error, notice=notice)
+
+<section class="repo-header slim">
+ <div>
+ % include("repo_fork_eyebrow.tpl")
+ % include("repo_title.tpl", repo=repo)
+
+ % include("repo_nav.tpl", repo=repo, commit_count=commit_count, issue_counts=issue_counts, pr_counts=pr_counts, star_count=star_count, is_starred=is_starred, is_owner=is_owner, can_maintain=can_maintain)
+
+ <p class="muted">Editing <code>{{file_path}}</code> on branch <code>{{branch}}</code></p>
+ </div>
+</section>
+
+<section class="panel">
+ <h2>Edit file</h2>
+ % if error:
+ <p class="alert">{{error}}</p>
+ % end
+ <form method="post">
+ {{!csrf_field()}}
+ <label>
+ Commit message
+ <input name="message" value="{{message_value}}" maxlength="200">
+ </label>
+ <label>
+ File content
+ <textarea class="edit-textarea" name="content" rows="22" spellcheck="false">{{content_value}}</textarea>
+ </label>
+ <div class="hero-actions">
+ <button class="button" type="submit">Save changes</button>
+ <a class="button-link" href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/src/' + quote_path(file_path), selected_ref)}}">Cancel</a>
+ </div>
+ </form>
+</section>
diff --git a/templates/file.tpl b/templates/file.tpl
index 1d5c747..f6e4022 100644
--- a/templates/file.tpl
+++ b/templates/file.tpl
@@ -30,19 +30,38 @@
<p class="empty">Binary file, {{size}} bytes. Use the raw view to download it.</p>
% else:
<div class="copyable-code" data-code-viewer>
- <button
- class="copy-button"
- type="button"
- aria-label="Copy file contents"
- title="Copy file contents"
- data-copy-raw-url="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/raw/' + quote_path(file_path), selected_ref)}}"
- >
- <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
- <path d="M10 1.5H3.5A1.5 1.5 0 0 0 2 3v8A1.5 1.5 0 0 0 3.5 12.5H5v-1H3.5A.5.5 0 0 1 3 11V3a.5.5 0 0 1 .5-.5H10z"/>
- <path d="M6.5 4A1.5 1.5 0 0 0 5 5.5v7A1.5 1.5 0 0 0 6.5 14h6A1.5 1.5 0 0 0 14 12.5v-7A1.5 1.5 0 0 0 12.5 4zm0 1h6a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5"/>
- </svg>
- <span class="sr-only">Copy file contents</span>
- </button>
+ <div class="code-actions">
+ % edit_url = "/" + repo['owner_username'] + "/" + repo['name'] + "/edit/" + quote_path(file_path)
+ % if selected_ref and selected_ref['type'] == 'branch' and selected_ref.get('name'):
+ % edit_url += "?ref_type=branch&ref=" + selected_ref['name']
+ % end
+ % if can_maintain:
+ <a
+ class="copy-button"
+ href="{{edit_url}}"
+ aria-label="Edit file"
+ title="Edit file"
+ >
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
+ <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>
+ </svg>
+ <span class="sr-only">Edit file</span>
+ </a>
+ % end
+ <button
+ class="copy-button"
+ type="button"
+ aria-label="Copy file contents"
+ title="Copy file contents"
+ data-copy-raw-url="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/raw/' + quote_path(file_path), selected_ref)}}"
+ >
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
+ <path d="M10 1.5H3.5A1.5 1.5 0 0 0 2 3v8A1.5 1.5 0 0 0 3.5 12.5H5v-1H3.5A.5.5 0 0 1 3 11V3a.5.5 0 0 1 .5-.5H10z"/>
+ <path d="M6.5 4A1.5 1.5 0 0 0 5 5.5v7A1.5 1.5 0 0 0 6.5 14h6A1.5 1.5 0 0 0 14 12.5v-7A1.5 1.5 0 0 0 12.5 4zm0 1h6a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5"/>
+ </svg>
+ <span class="sr-only">Copy file contents</span>
+ </button>
+ </div>
<pre class="code"><code class="{{language_class}}">{{content}}</code></pre>
</div>
% end
diff --git a/templates/issue_detail.tpl b/templates/issue_detail.tpl
index f6173e3..39885d7 100644
--- a/templates/issue_detail.tpl
+++ b/templates/issue_detail.tpl
@@ -14,7 +14,7 @@
<div class="panel-heading">
<div>
<h2>(#{{issue["number"]}}) {{!render_mentions(issue["title"])}}</h2>
- <p class="muted"><strong>{{issue["status"]}}</strong> by {{issue["author_username"]}} on {{issue["created_at"]}}</p>
+ <p class="muted"><strong>{{issue["status"]}}</strong> by {{issue["author_username"]}} on {{format_datetime(issue["created_at"])}}</p>
</div>
% if can_maintain:
<form method="post">
diff --git a/templates/license.tpl b/templates/license.tpl
new file mode 100644
index 0000000..cbea028
--- /dev/null
+++ b/templates/license.tpl
@@ -0,0 +1,26 @@
+% rebase("base.tpl", title=repo["owner_username"] + "/" + repo["name"] + " license", user=user, error=error, notice=notice)
+
+<section class="repo-header slim">
+ <div>
+ % include("repo_fork_eyebrow.tpl")
+ % include("repo_title.tpl", repo=repo)
+
+ % include("repo_nav.tpl", repo=repo, commit_count=commit_count, issue_counts=issue_counts, pr_counts=pr_counts, star_count=star_count, is_starred=is_starred, is_owner=is_owner, can_maintain=can_maintain)
+
+ <p class="muted">License</p>
+ </div>
+</section>
+
+<section class="panel">
+ <h2>License</h2>
+ % if license_name:
+ <p class="muted"><code>{{license_name}}</code></p>
+ % if license_html is not None:
+ <div class="markdown-body license-body">{{!license_html}}</div>
+ % elif license_text:
+ <pre class="license-body">{{license_text}}</pre>
+ % end
+ % else:
+ <p class="empty">No license file in this repository.</p>
+ % end
+</section>
diff --git a/templates/new_repo.tpl b/templates/new_repo.tpl
index 118bce8..6d72a3e 100644
--- a/templates/new_repo.tpl
+++ b/templates/new_repo.tpl
@@ -12,7 +12,15 @@
Description
<textarea name="description" rows="3">{{description if defined("description") else ""}}</textarea>
</label>
- <p class="muted">Repositories are public to clone and browse. You can add contributors after creation.</p>
+ <label>
+ License
+ <select name="license">
+ % for value, label in license_choices:
+ <option value="{{value}}" {{'selected' if value == get("selected_license", "") else ''}}>{{label}}</option>
+ % end
+ </select>
+ </label>
+ <p class="muted">Repositories are public to clone and browse. You can add contributors after creation. Choosing a BSD license commits a LICENSE file to the main branch.</p>
<button class="button" type="submit">Create repository</button>
</form>
</section>
diff --git a/templates/password_dialog.tpl b/templates/password_dialog.tpl
deleted file mode 100644
index 0b5da4c..0000000
--- a/templates/password_dialog.tpl
+++ /dev/null
@@ -1,25 +0,0 @@
-<dialog id="password-dialog" class="password-dialog" aria-labelledby="password-title" data-reopen="{{'true' if get('password_error') else 'false'}}">
- <h2 id="password-title">Change password</h2>
- <form method="post" action="/settings/password">
- {{!csrf_field()}}
- <input type="text" name="username" value="{{user['username']}}" autocomplete="username" hidden>
- % if get('password_error'):
- <p class="alert" role="alert">{{password_error}}</p>
- % end
- <label>Current password
- <input type="password" name="current_password" autocomplete="current-password" maxlength="1024" required autofocus>
- </label>
- <label>New password
- <input type="password" name="new_password" autocomplete="new-password" minlength="8" maxlength="1024" required aria-describedby="password-hint">
- </label>
- <label>Confirm new password
- <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="1024" required>
- </label>
- <p class="muted" id="password-hint">At least 8 characters. Other browser sessions will be signed out.</p>
- <div class="password-actions">
- <button type="button" id="change-password-cancel">Cancel</button>
- <button type="submit">Change password</button>
- </div>
- </form>
-</dialog>
-<script src="/static/profile.js?v=1" defer></script>
diff --git a/templates/password_page.tpl b/templates/password_page.tpl
new file mode 100644
index 0000000..88d7492
--- /dev/null
+++ b/templates/password_page.tpl
@@ -0,0 +1,23 @@
+% rebase("base.tpl", title="Change password", user=user, error=error, notice=notice)
+
+<section class="auth-card">
+ <h1>Change password</h1>
+ <form method="post" action="/settings/password">
+ {{!csrf_field()}}
+ <label>
+ Current password
+ <input type="password" name="current_password" autocomplete="current-password" maxlength="1024" required>
+ </label>
+ <label>
+ New password
+ <input type="password" name="new_password" autocomplete="new-password" minlength="8" maxlength="1024" required aria-describedby="password-hint">
+ </label>
+ <label>
+ Confirm new password
+ <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="1024" required>
+ </label>
+ <p class="muted" id="password-hint">At least 8 characters. Other browser sessions will be signed out.</p>
+ <button class="button" type="submit">Change password</button>
+ </form>
+ <p class="muted"><a href="/settings/preferences">Back to preferences</a></p>
+</section>
diff --git a/templates/preferences.tpl b/templates/preferences.tpl
new file mode 100644
index 0000000..fef8b10
--- /dev/null
+++ b/templates/preferences.tpl
@@ -0,0 +1,19 @@
+% rebase("base.tpl", title="Preferences", user=user, error=error, notice=notice)
+
+<section class="auth-card wide">
+ <h1>Preferences</h1>
+ <form method="post">
+ {{!csrf_field()}}
+ <label>
+ Time zone
+ <select name="timezone">
+ % for zone in timezone_choices:
+ <option value="{{zone}}" {{'selected' if zone == current_timezone else ''}}>{{zone}}</option>
+ % end
+ </select>
+ </label>
+ <p class="muted">Dates and times across GitMan are shown in your time zone. The default is UTC.</p>
+ <button class="button" type="submit">Save preferences</button>
+ </form>
+ <p class="muted"><a href="/settings/password">Change password</a></p>
+</section>
diff --git a/templates/profile.tpl b/templates/profile.tpl
index 25f1ef0..2198d85 100644
--- a/templates/profile.tpl
+++ b/templates/profile.tpl
@@ -10,9 +10,9 @@
% end
</h1>
<p class="muted"><small>
- Joined {{profile_user["created_at"]}}
+ Joined {{format_datetime(profile_user["created_at"])}}
% if is_self:
- <a class="button-link" href="/settings/profile">Edit profile</a> · <button class="link-button" type="button" id="change-password-open">Change password</button> · <a class="button-link" href="/logout">Log out</a>
+ <a class="button-link" href="/settings/profile">Edit profile</a> · <a class="button-link" href="/settings/preferences">Preferences</a> · <a class="button-link" href="/logout">Log out</a>
% end
</small></p>
% if profile_user["bio"]:
@@ -40,7 +40,7 @@
<br>
{{!render_markdown_links(repo["description"]) or "No description yet."}}
<br>
- <small>Updated {{repo["updated_at"]}} · {{repo["star_count"]}} stars</small>
+ <small>Updated {{format_datetime(repo["updated_at"])}} · {{repo["star_count"]}} stars</small>
</div>
% end
</div>
@@ -54,7 +54,3 @@
% include("pagination.tpl", pagination=pagination)
% end
</section>
-
-% if is_self:
- % include("password_dialog.tpl")
-% end
diff --git a/templates/pull_request_detail.tpl b/templates/pull_request_detail.tpl
index 0ecb377..d1ebc2f 100644
--- a/templates/pull_request_detail.tpl
+++ b/templates/pull_request_detail.tpl
@@ -14,7 +14,7 @@
<div class="panel-heading">
<div>
<h2>(#{{pr["number"]}}) {{!render_mentions(pr["title"])}}</h2>
- <p class="muted"><strong>{{pr["status"]}}</strong> <small>(created by <a href="/{{pr["author_username"]}}">{{pr["author_username"]}}</a> on {{pr["created_at"]}})</small></p>
+ <p class="muted"><strong>{{pr["status"]}}</strong> <small>(created by <a href="/{{pr["author_username"]}}">{{pr["author_username"]}}</a> on {{format_datetime(pr["created_at"])}})</small></p>
<p class="muted"><a href="/{{pr["source_owner_username"]}}/{{pr["source_repo_name"]}}">{{pr["source_owner_username"]}}/{{pr["source_repo_name"]}}</a> into <a href="/{{pr["target_owner_username"]}}/{{pr["target_repo_name"]}}">{{pr["target_owner_username"]}}/{{pr["target_repo_name"]}}</a></p>
<p class="muted">{{format_ref_label(pr["source_ref_type"], pr["source_ref_name"])}} into {{format_ref_label(pr["target_ref_type"], pr["target_ref_name"])}}</p>
</div>
@@ -39,7 +39,7 @@
<p class="empty">No description.</p>
% end
% if pr["status"] == "merged":
- <p class="notice">Merged by {{pr["merged_by_username"] or "unknown"}} on {{pr["merged_at"]}} as <code>{{pr["merge_node"]}}</code>.</p>
+ <p class="notice">Merged by {{pr["merged_by_username"] or "unknown"}} on {{format_datetime(pr["merged_at"])}} as <code>{{pr["merge_node"]}}</code>.</p>
% end
</section>
diff --git a/templates/repo_nav.tpl b/templates/repo_nav.tpl
index 640cf05..641884f 100644
--- a/templates/repo_nav.tpl
+++ b/templates/repo_nav.tpl
@@ -6,6 +6,7 @@
<a class="repo-tab {{'active' if active_tab == 'commits' else ''}}" href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/commits', selected_ref)}}">Commits{{" (" + str(commit_count) + ")" if commit_count else ""}}</a>
<a class="repo-tab {{'active' if active_tab == 'issues' else ''}}" href="/{{repo['owner_username']}}/{{repo['name']}}/issues">Issues{{" (" + str(issue_counts["open"]) + ")" if issue_counts["open"] else ""}}</a>
<a class="repo-tab {{'active' if active_tab == 'pulls' else ''}}" href="/{{repo['owner_username']}}/{{repo['name']}}/pulls">Pull requests{{" (" + str(pr_counts["open"]) + ")" if pr_counts["open"] else ""}}</a>
+ <a class="repo-tab {{'active' if active_tab == 'license' else ''}}" href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/license', selected_ref)}}">License</a>
% if user:
<form class="inline-form" method="post" action="/{{repo['owner_username']}}/{{repo['name']}}/star">
{{!csrf_field()}}
diff --git a/templates/repo_settings.tpl b/templates/repo_settings.tpl
index c9fbff2..28933d4 100644
--- a/templates/repo_settings.tpl
+++ b/templates/repo_settings.tpl
@@ -109,7 +109,7 @@
<ul class="clean-list">
% for contributor in contributors:
<li class="panel-heading">
- <span><a href="/{{contributor['username']}}">{{contributor["username"]}}</a> <span class="muted">added {{contributor["contributor_since"]}}</span></span>
+ <span><a href="/{{contributor['username']}}">{{contributor["username"]}}</a> <span class="muted">added {{format_datetime(contributor["contributor_since"])}}</span></span>
<form class="inline-form" method="post">
{{!csrf_field()}}
<input type="hidden" name="action" value="remove_contributor">
diff --git a/templates/tags.tpl b/templates/tags.tpl
index f1baf9f..35a91ec 100644
--- a/templates/tags.tpl
+++ b/templates/tags.tpl
@@ -20,7 +20,7 @@
<code>{{tag["name"]}}</code>
<div style="margin-bottom:20px;">
<strong><a href="/{{repo['owner_username']}}/{{repo['name']}}/commits/{{tag['short_node']}}">{{tag["short_node"]}}</a></strong>
- <small>commit {{tag["short_node"]}} · {{tag["date"]}}</small>
+ <small>commit {{tag["short_node"]}} · {{format_datetime(tag["date"])}}</small>
<p>{{tag["summary"]}}
<small><a href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/src', tag, True)}}">Browse code</a> ·
<a href="{{url_with_ref('/' + repo['owner_username'] + '/' + repo['name'] + '/commits', tag, True)}}">Commits</a> ·
diff --git a/tests/test_app.py b/tests/test_app.py
index a9e7191..e93626a 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2704,14 +2704,18 @@ def test_change_password_revokes_other_sessions_and_git_credentials(isolated_app
legacy.cookies['session'] = cookie_encode(('session', str(owner['id'])), gitman.SECRET_KEY).decode()
assert legacy.get('/settings/profile').status_code == 200
profile = client.get('/alice').text
- assert 'id="change-password-open"' in profile
+ assert '/settings/preferences' in profile
+ assert 'password-dialog' not in profile
assert profile.index('aria-label="Notifications"') < profile.index('aria-label="New repository"') < profile.index('aria-label="Profile"')
- assert 'id="change-password-open"' not in WsgiClient(isolated_app.app).get('/alice').text
- assert 'id="change-password-open"' not in client.get('/settings/profile').text
+ password_page = client.get('/settings/password')
+ assert password_page.status_code == 200
+ assert 'name="current_password"' in password_page.text
+ assert '/settings/password' not in WsgiClient(isolated_app.app).get('/alice').text
+ assert client.get('/settings/preferences').status_code == 200
result = client.post('/settings/password', {'current_password': 'correct horse battery staple',
'new_password': 'a different secure password', 'confirm_password': 'a different secure password'})
assert result.status_code == 200 and 'Password changed.' in result.text
- assert 'id="change-password-open"' in result.text
+ assert '<h1>Change password</h1>' in result.text
assert '<h1>Edit profile</h1>' not in result.text
assert client.get('/settings/profile').status_code == 200
for browser in (other, legacy):
@@ -2738,8 +2742,7 @@ def test_change_password_rejects_invalid_input_without_changing_credentials(isol
client.get('/settings/profile')
result = client.post('/settings/password', {'current_password':current, 'new_password':new, 'confirm_password':confirmation})
assert result.status_code == 400
- assert 'data-reopen="true"' in result.text
- assert 'id="change-password-open"' in result.text
+ assert '<h1>Change password</h1>' in result.text
assert '<h1>Edit profile</h1>' not in result.text
assert 'value="'+new+'"' not in result.text
assert isolated_app.get_user_by_id(owner['id'])['password_hash'] == owner['password_hash']
@@ -2758,6 +2761,99 @@ def test_change_password_requires_login_csrf_and_throttles_guessing(isolated_app
assert client.post('/settings/password', {'current_password':'correct horse battery staple', 'new_password':'new-password', 'confirm_password':'new-password'}).status_code == 429
+def test_new_repository_with_bsd_license_commits_license_file(isolated_app):
+ owner = create_user('alice')
+ client = WsgiClient(isolated_app.app)
+ login_client(client, 'alice')
+ new_page = client.get('/new')
+ assert new_page.status_code == 200
+ assert 'None/Custom' in new_page.text
+ for label in ('BSD 0-Clause', 'BSD 1-Clause', 'BSD 2-Clause', 'BSD 3-Clause', 'BSD 4-Clause'):
+ assert label in new_page.text
+
+ result = client.post('/new', {'name': 'licensed', 'description': '', 'license': 'bsd-3'})
+ assert result.status_code == 303
+ path = isolated_app.repo_path('alice', 'licensed')
+ license_text = isolated_app.git_cat(path, 'LICENSE')
+ assert 'BSD 3-Clause License' in license_text
+ assert 'alice' in license_text
+ assert 'Add LICENSE' in client.get('/alice/licensed/commits').text
+ license_page = client.get('/alice/licensed/license')
+ assert license_page.status_code == 200
+ assert 'BSD 3-Clause License' in license_page.text
+ assert 'LICENSE' in client.get('/alice/licensed/src').text
+
+
+def test_new_repository_without_license_skips_license_file(isolated_app):
+ create_user('alice')
+ client = WsgiClient(isolated_app.app)
+ login_client(client, 'alice')
+ assert client.post('/new', {'name': 'bare', 'description': '', 'license': ''}).status_code == 303
+ license_page = client.get('/alice/bare/license')
+ assert license_page.status_code == 200
+ assert 'No license file in this repository.' in license_page.text
+ assert '/alice/bare/license' in client.get('/alice/bare').text
+
+
+def test_repository_files_can_be_edited_from_the_browser(isolated_app):
+ create_user('bob')
+ owner = create_user('alice')
+ isolated_app.create_repository(owner, 'editable', '')
+ path = isolated_app.repo_path('alice', 'editable')
+ commit_file(path, 'notes.txt', 'first version\n', message='initial')
+
+ anonymous = WsgiClient(isolated_app.app)
+ assert anonymous.get('/alice/editable/edit/notes.txt').status_code == 303
+
+ client = WsgiClient(isolated_app.app)
+ login_client(client, 'alice')
+ edit_page = client.get('/alice/editable/edit/notes.txt')
+ assert edit_page.status_code == 200
+ assert 'first version' in edit_page.text
+ assert 'aria-label="Edit file"' in client.get('/alice/editable/src/notes.txt').text
+
+ result = client.post('/alice/editable/edit/notes.txt', {'message': 'Update notes', 'content': 'second version\n'})
+ assert result.status_code == 303
+ assert isolated_app.git_cat(path, 'notes.txt') == 'second version\n'
+ assert 'second version' in client.get('/alice/editable/src/notes.txt').text
+ assert 'Update notes' in client.get('/alice/editable/commits').text
+
+ stranger = WsgiClient(isolated_app.app)
+ login_client(stranger, 'bob')
+ assert stranger.get('/alice/editable/edit/notes.txt').status_code == 403
+ stranger_file = stranger.get('/alice/editable/src/notes.txt')
+ assert stranger_file.status_code == 200
+ assert 'aria-label="Edit file"' not in stranger_file.text
+
+
+def test_preferences_timezone_adapts_displayed_times(isolated_app):
+ owner = create_user('alice')
+ isolated_app.create_repository(owner, 'tzdemo', '')
+ path = isolated_app.repo_path('alice', 'tzdemo')
+ commit_file(path, 'README.md', '# tz\n', message='initial')
+ client = WsgiClient(isolated_app.app)
+ login_client(client, 'alice')
+
+ page = client.get('/settings/preferences')
+ assert page.status_code == 200
+ assert '<option value="Europe/Berlin"' in page.text
+ assert '<option value="UTC" selected' in page.text
+
+ saved = client.post('/settings/preferences', {'timezone': 'Europe/Berlin'})
+ assert saved.status_code == 200
+ assert 'Preferences saved.' in saved.text
+ assert '<option value="Europe/Berlin" selected' in saved.text
+ assert isolated_app.get_user_by_id(owner['id'])['timezone'] == 'Europe/Berlin'
+ assert client.post('/settings/preferences', {'timezone': 'Mars/Olympus'}).status_code == 400
+
+ pattern = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}")
+ anonymous_view = WsgiClient(isolated_app.app).get('/alice/tzdemo/commits').text
+ berlin_view = client.get('/alice/tzdemo/commits').text
+ anonymous_time = pattern.search(anonymous_view).group(0)
+ berlin_time = pattern.search(berlin_view).group(0)
+ assert anonymous_time != berlin_time
+
+
def test_session_persists_fourteen_days_but_expires_on_server(isolated_app, monkeypatch):
create_user('alice')
client = WsgiClient(isolated_app.app)