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

Changeset
490cf3553558137f1b268517d93af087f98ef1de
Parents
ac1dc2ec3ebe42a73d82e40753decad61c836726

View source at this commit

Comments

No comments yet.

Log in to comment

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> &middot; <button class="link-button" type="button" id="change-password-open">Change password</button> &middot; <a class="button-link" href="/logout">Log out</a>
+      <a class="button-link" href="/settings/profile">Edit profile</a> &middot; <a class="button-link" href="/settings/preferences">Preferences</a> &middot; <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)