patx/gitman

add an upload to copy over existing huge repos

Commit ae45d36 · patx · 2026-05-06T21:10:30-04:00

Changeset
ae45d36aeeb2f41de09bca1b2d957a88a458f9d3
Parents
9b9f822110cb3a99c97d7b22d6405e2eddb6a173

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 3328c79..86d6516 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@ All repositories are public for web browsing, clone, and fetch. Pushes go throug
 
 Creating a repository initializes an empty bare Git repo with `HEAD` pointing at `main`. The repository page shows clone instructions until the first push lands, then renders a README preview when it finds `README.md`, `README.rst`, `README.txt`, or `README`.
 
+Empty repositories can also be populated from a full Git bundle in repository settings. From the existing repository, run `git bundle create repo.bundle --all`, then upload `repo.bundle` before any commits have been pushed to the GitMan repository.
+
 The web UI reads directly from Git for source browsing, raw file downloads, commit history, branches, tags, archives, diffs, and README content. Markdown README files are rendered to sanitized HTML before display, and repository descriptions plus issue, pull request, and commit comments support sanitized links.
 
 Issues, comments, stars, contributors, forks, and pull request records are stored in SQLite. Forking creates a bare clone of the source repository. Pull requests compare a source ref against a target branch, and maintainers can close or merge them from the browser.
@@ -36,6 +38,8 @@ Pages-style static hosting is driven by the Git repository contents. A user site
 - `GITMAN_GIT_BINARY`: Git executable name or full path, default `git`
 - `GITMAN_PAGES_DOMAIN`: wildcard Pages domain, default `gitman.io`
 - `GITMAN_MAX_FORM_BYTES`: maximum browser form body size, default `65536`
+- `GITMAN_MAX_IMPORT_BYTES`: maximum Git bundle import upload size, default `5368709120`
+- `GITMAN_IMPORT_TIMEOUT_SECONDS`: maximum Git bundle verify/fetch time, default `3600`
 - `GITMAN_MAX_RENDER_BYTES`: maximum file preview size, default `262144`
 - `GITMAN_MAX_GIT_RESPONSE_BYTES`: maximum Git HTTP backend response size, default `268435456`
 - `GITMAN_RATE_LIMIT_ENABLED`: set to `0` to disable login, signup, and Git push auth rate limiting
@@ -45,6 +49,8 @@ When `GITMAN_DEBUG` is off, `SECRET_KEY` must be set to a non-default value befo
 
 Repositories and their Git data live on local disk, so keep the database and repo root on persistent storage. The app uses SQLite WAL mode and shells out to Git, so the process user needs read/write access to both paths and access to the configured Git executable.
 
+For large bundle imports behind nginx and gunicorn, set nginx `client_max_body_size` above `GITMAN_MAX_IMPORT_BYTES`, raise nginx proxy read/send timeouts for long imports, and set gunicorn's worker timeout above `GITMAN_IMPORT_TIMEOUT_SECONDS`. The server also needs enough temporary disk space for the uploaded bundle plus the staged bare repository during import.
+
 ## License
 
 GitMan is licensed under the BSD 3-Clause License. See [LICENSE](https://gitman.io/patx/gitman/src/LICENSE).
diff --git a/app.py b/app.py
index 55ab332..0063f87 100644
--- a/app.py
+++ b/app.py
@@ -64,6 +64,8 @@ DEBUG = env_bool("GITMAN_DEBUG")
 PASSWORD_ITERATIONS = 260_000
 SQLITE_BUSY_TIMEOUT_MS = 30_000
 MAX_FORM_BYTES = env_int("GITMAN_MAX_FORM_BYTES", 64 * 1024)
+MAX_IMPORT_BYTES = env_int("GITMAN_MAX_IMPORT_BYTES", 5 * 1024 * 1024 * 1024)
+GIT_IMPORT_TIMEOUT_SECONDS = env_int("GITMAN_IMPORT_TIMEOUT_SECONDS", 3600, minimum=1)
 MAX_RENDER_BYTES = env_int("GITMAN_MAX_RENDER_BYTES", 256 * 1024)
 MAX_GIT_RESPONSE_BYTES = env_int("GITMAN_MAX_GIT_RESPONSE_BYTES", 256 * 1024 * 1024)
 GIT_BINARY = os.environ.get("GITMAN_GIT_BINARY", "git")
@@ -246,6 +248,10 @@ class GitResponseTooLarge(RuntimeError):
     pass
 
 
+class UploadTooLarge(ValueError):
+    pass
+
+
 def validate_startup_config():
     if not DEBUG and SECRET_KEY == DEFAULT_SECRET_KEY:
         raise RuntimeError("SECRET_KEY must be set to a non-default value when GITMAN_DEBUG is disabled.")
@@ -588,6 +594,18 @@ def request_content_length():
         return 0
 
 
+def is_repo_settings_multipart_request():
+    path = request.environ.get("PATH_INFO", request.path) or ""
+    content_type = (request.environ.get("CONTENT_TYPE") or request.get_header("Content-Type") or "").lower()
+    return path.endswith("/settings") and content_type.startswith("multipart/form-data")
+
+
+def browser_post_size_limit():
+    if is_repo_settings_multipart_request():
+        return MAX_IMPORT_BYTES
+    return MAX_FORM_BYTES
+
+
 def auth_rate_key(kind, identifier=""):
     identifier = (identifier or "").strip().lower()[:100]
     remote_addr = request.environ.get("REMOTE_ADDR", "")
@@ -656,7 +674,8 @@ def dispatch_pages_host():
 def enforce_browser_post_security():
     if request.method != "POST" or is_git_request_path():
         return
-    if MAX_FORM_BYTES and request_content_length() > MAX_FORM_BYTES:
+    size_limit = browser_post_size_limit()
+    if size_limit and request_content_length() > size_limit:
         abort(413, "Request body too large.")
     validate_csrf_token()
 
@@ -1449,6 +1468,143 @@ def fork_repository(owner, source_repo, name, description):
         raise
 
 
+def run_git_import(args, cwd=None, check=True):
+    try:
+        return run_git(args, cwd=cwd, timeout=GIT_IMPORT_TIMEOUT_SECONDS, check=check)
+    except subprocess.TimeoutExpired as exc:
+        raise GitCommandError("Git import timed out.", 124) from exc
+
+
+def repo_is_empty(path):
+    return commit_count(path) == 0
+
+
+def save_bundle_upload(upload, destination):
+    if not upload or not getattr(upload, "filename", ""):
+        raise ValueError("Choose a Git bundle to import.")
+
+    size = 0
+    source = upload.file
+    try:
+        source.seek(0)
+    except (AttributeError, OSError):
+        pass
+
+    with destination.open("wb") as target:
+        while True:
+            chunk = source.read(1024 * 1024)
+            if not chunk:
+                break
+            size += len(chunk)
+            if MAX_IMPORT_BYTES and size > MAX_IMPORT_BYTES:
+                raise UploadTooLarge("Request body too large.")
+            target.write(chunk)
+
+    if size == 0:
+        raise ValueError("Uploaded bundle is empty.")
+
+
+def bundle_ref_names(bundle_path):
+    completed = run_git_import(["bundle", "list-heads", str(bundle_path)])
+    refs = []
+    for line in completed.stdout.splitlines():
+        parts = line.strip().split(maxsplit=1)
+        if len(parts) == 2:
+            refs.append(parts[1])
+    return refs
+
+
+def origin_branch_refspecs_for_bundle(bundle_path):
+    refs = bundle_ref_names(bundle_path)
+    branch_names = {ref.removeprefix("refs/heads/") for ref in refs if ref.startswith("refs/heads/")}
+    refspecs = []
+    prefix = "refs/remotes/origin/"
+    for ref in refs:
+        if not ref.startswith(prefix):
+            continue
+        branch_name = ref[len(prefix) :]
+        if not branch_name or branch_name == "HEAD" or branch_name in branch_names:
+            continue
+        refspecs.append(f"+{ref}:refs/heads/{branch_name}")
+        branch_names.add(branch_name)
+    return refspecs
+
+
+def fetch_bundle_refspecs(bundle_path, staging_path, refspecs):
+    for index in range(0, len(refspecs), 100):
+        run_git_import(["fetch", str(bundle_path), *refspecs[index : index + 100]], cwd=staging_path)
+
+
+def import_git_bundle(repo, path, upload):
+    if not path.exists():
+        raise ValueError("Repository directory does not exist.")
+    if not repo_is_empty(path):
+        raise ValueError("Git bundles can only be imported into empty repositories.")
+
+    owner = repo["owner_username"]
+    name = repo["name"]
+    bundle_path = None
+    staging_path = None
+    backup_path = None
+    moved_target_to_backup = False
+    installed_staging = False
+    success = False
+
+    try:
+        with tempfile.NamedTemporaryFile(prefix="gitman-import-", suffix=".bundle", delete=False) as bundle_file:
+            bundle_path = Path(bundle_file.name)
+        save_bundle_upload(upload, bundle_path)
+
+        verification = run_git_import(["bundle", "verify", str(bundle_path)], cwd=path, check=False)
+        if verification.returncode != 0:
+            raise ValueError("Uploaded file is not a valid Git bundle.")
+
+        staging_path = Path(tempfile.mkdtemp(prefix=f".{name}-import-", dir=path.parent))
+        shutil.rmtree(staging_path)
+        run_git_import(["init", "--bare", str(staging_path)])
+        run_git_import(
+            [
+                "fetch",
+                str(bundle_path),
+                "+refs/heads/*:refs/heads/*",
+                "+refs/tags/*:refs/tags/*",
+            ],
+            cwd=staging_path,
+        )
+        fetch_bundle_refspecs(bundle_path, staging_path, origin_branch_refspecs_for_bundle(bundle_path))
+        if not list_repo_branches(staging_path):
+            raise ValueError("Uploaded Git bundle does not contain any branches.")
+        default_code_ref(staging_path)
+        write_git_metadata(staging_path, owner, name, repo["description"])
+
+        if not repo_is_empty(path):
+            raise ValueError("Repository is no longer empty.")
+
+        backup_path = path.with_name(f".{name}-import-backup-{secrets.token_hex(8)}")
+        path.rename(backup_path)
+        moved_target_to_backup = True
+        staging_path.rename(path)
+        installed_staging = True
+        staging_path = None
+
+        with db_connect() as conn:
+            conn.execute("UPDATE repositories SET updated_at = ? WHERE id = ?", (utcnow(), repo["id"]))
+        success = True
+    except Exception:
+        if installed_staging and path.exists():
+            shutil.rmtree(path)
+        if moved_target_to_backup and backup_path and backup_path.exists() and not path.exists():
+            backup_path.rename(path)
+        raise
+    finally:
+        if bundle_path and bundle_path.exists():
+            bundle_path.unlink()
+        if staging_path and staging_path.exists():
+            shutil.rmtree(staging_path)
+        if success and backup_path and backup_path.exists():
+            shutil.rmtree(backup_path)
+
+
 def delete_repository(repo, path):
     with db_connect() as conn:
         conn.execute("DELETE FROM repositories WHERE id = ?", (repo["id"],))
@@ -3529,6 +3685,17 @@ def repo_settings(owner, repo_name):
         except ValueError as exc:
             return render_repo_settings_page(repo, path, error=str(exc))
 
+    if action == "import_bundle":
+        try:
+            import_git_bundle(repo, path, request.files.get("bundle"))
+            updated_repo = get_repo(owner, repo_name)
+            return render_repo_settings_page(updated_repo, path, notice="Git bundle imported.")
+        except UploadTooLarge as exc:
+            abort(413, str(exc))
+        except (ValueError, GitCommandError) as exc:
+            repo = get_repo(owner, repo_name) or repo
+            return render_repo_settings_page(repo, path, error=str(exc))
+
     description = request.forms.get("description", "").strip()[:500]
     with db_connect() as conn:
         conn.execute(
diff --git a/templates/repo_settings.tpl b/templates/repo_settings.tpl
index 3fa10f0..f74ec04 100644
--- a/templates/repo_settings.tpl
+++ b/templates/repo_settings.tpl
@@ -23,6 +23,23 @@
   </form>
 </section>
 
+% if commit_count == 0:
+<section class="panel">
+  <h2>Import Git bundle</h2>
+  <p class="muted">Create a bundle from the source repository, then upload it here.</p>
+  <pre>git bundle create repo.bundle --all</pre>
+  <form method="post" enctype="multipart/form-data">
+    {{!csrf_field()}}
+    <input type="hidden" name="action" value="import_bundle">
+    <label>
+      Git bundle
+      <input name="bundle" type="file" accept=".bundle,application/octet-stream" required>
+    </label>
+    <button class="button" type="submit">Import bundle</button>
+  </form>
+</section>
+% end
+
 <section class="panel">
   <h2>Pages</h2>
   % if pages_settings["docs_publishable"]:
diff --git a/tests/test_app.py b/tests/test_app.py
index d4edc8e..4cf6d0d 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -69,22 +69,55 @@ class WsgiClient:
     def post(self, path, data=None, headers=None):
         return self.request("POST", path, data=data, headers=headers)
 
-    def request(self, method, path, data=None, headers=None):
+    def post_multipart(self, path, fields=None, files=None, headers=None):
+        fields = dict(fields or {})
+        files = files or {}
+        headers = headers or {}
+        if gitman.CSRF_FORM_FIELD not in fields and self.csrf_token:
+            fields[gitman.CSRF_FORM_FIELD] = self.csrf_token
+
+        boundary = "----gitman-test-boundary"
+        body = bytearray()
+        for name, value in fields.items():
+            body.extend(f"--{boundary}\r\n".encode("ascii"))
+            body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8"))
+            body.extend(str(value).encode("utf-8"))
+            body.extend(b"\r\n")
+        for name, file_info in files.items():
+            filename, content, content_type = file_info
+            if isinstance(content, str):
+                content = content.encode("utf-8")
+            body.extend(f"--{boundary}\r\n".encode("ascii"))
+            body.extend(
+                (
+                    f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
+                    f"Content-Type: {content_type}\r\n\r\n"
+                ).encode("utf-8")
+            )
+            body.extend(content)
+            body.extend(b"\r\n")
+        body.extend(f"--{boundary}--\r\n".encode("ascii"))
+
+        multipart_headers = {"Content-Type": f"multipart/form-data; boundary={boundary}", **headers}
+        return self.request("POST", path, headers=multipart_headers, raw_body=bytes(body))
+
+    def request(self, method, path, data=None, headers=None, raw_body=None):
         headers = headers or {}
         split = urlsplit(path)
-        body = b""
-        if method == "POST" and data is None and not split.path.startswith("/git/") and self.csrf_token:
-            data = {}
-        if data is not None:
-            if (
-                method == "POST"
-                and not split.path.startswith("/git/")
-                and gitman.CSRF_FORM_FIELD not in data
-                and self.csrf_token
-            ):
-                data = {**data, gitman.CSRF_FORM_FIELD: self.csrf_token}
-            body = urlencode(data, doseq=True).encode("utf-8")
-            headers = {"Content-Type": "application/x-www-form-urlencoded", **headers}
+        body = raw_body or b""
+        if raw_body is None:
+            if method == "POST" and data is None and not split.path.startswith("/git/") and self.csrf_token:
+                data = {}
+            if data is not None:
+                if (
+                    method == "POST"
+                    and not split.path.startswith("/git/")
+                    and gitman.CSRF_FORM_FIELD not in data
+                    and self.csrf_token
+                ):
+                    data = {**data, gitman.CSRF_FORM_FIELD: self.csrf_token}
+                body = urlencode(data, doseq=True).encode("utf-8")
+                headers = {"Content-Type": "application/x-www-form-urlencoded", **headers}
         environ = {
             "REQUEST_METHOD": method,
             "SCRIPT_NAME": "",
@@ -197,6 +230,53 @@ def commit_file(repo_path, relative_path, content, message="initial commit", use
         return node
 
 
+def create_bundle_with_refs(bundle_path):
+    source_path = bundle_path.parent / "source"
+    gitman.run_git(["init", str(source_path)])
+    gitman.run_git(["checkout", "-b", "main"], cwd=source_path)
+    gitman.run_git(["config", "user.name", "Alice"], cwd=source_path)
+    gitman.run_git(["config", "user.email", "[email protected]"], cwd=source_path)
+    source_path.joinpath("README.md").write_text("# Imported\n", encoding="utf-8")
+    gitman.run_git(["add", "README.md"], cwd=source_path)
+    gitman.run_git(["commit", "-m", "initial import"], cwd=source_path)
+    main_node = gitman.run_git(["rev-parse", "HEAD"], cwd=source_path).stdout.strip()
+    gitman.run_git(["tag", "v1.0"], cwd=source_path)
+    gitman.run_git(["checkout", "-b", "feature"], cwd=source_path)
+    source_path.joinpath("feature.txt").write_text("feature work\n", encoding="utf-8")
+    gitman.run_git(["add", "feature.txt"], cwd=source_path)
+    gitman.run_git(["commit", "-m", "feature work"], cwd=source_path)
+    feature_node = gitman.run_git(["rev-parse", "HEAD"], cwd=source_path).stdout.strip()
+    gitman.run_git(["bundle", "create", str(bundle_path), "--all"], cwd=source_path, timeout=60)
+    return {"main": main_node, "feature": feature_node}
+
+
+def create_origin_bundle_with_refs(bundle_path):
+    remote_path = bundle_path.parent / "remote.git"
+    seed_path = bundle_path.parent / "seed"
+    clone_path = bundle_path.parent / "clone"
+    gitman.run_git(["init", "--bare", str(remote_path)])
+    gitman.run_git(["symbolic-ref", "HEAD", "refs/heads/main"], cwd=remote_path)
+    gitman.run_git(["clone", str(remote_path), str(seed_path)])
+    gitman.run_git(["config", "user.name", "Alice"], cwd=seed_path)
+    gitman.run_git(["config", "user.email", "[email protected]"], cwd=seed_path)
+    seed_path.joinpath("README.md").write_text("# Imported\n", encoding="utf-8")
+    gitman.run_git(["add", "README.md"], cwd=seed_path)
+    gitman.run_git(["commit", "-m", "initial import"], cwd=seed_path)
+    main_node = gitman.run_git(["rev-parse", "HEAD"], cwd=seed_path).stdout.strip()
+    gitman.run_git(["tag", "v1.0"], cwd=seed_path)
+    gitman.run_git(["push", "-u", "origin", "main"], cwd=seed_path)
+    gitman.run_git(["push", "origin", "v1.0"], cwd=seed_path)
+    gitman.run_git(["checkout", "-b", "feature"], cwd=seed_path)
+    seed_path.joinpath("feature.txt").write_text("feature work\n", encoding="utf-8")
+    gitman.run_git(["add", "feature.txt"], cwd=seed_path)
+    gitman.run_git(["commit", "-m", "feature work"], cwd=seed_path)
+    feature_node = gitman.run_git(["rev-parse", "HEAD"], cwd=seed_path).stdout.strip()
+    gitman.run_git(["push", "-u", "origin", "feature"], cwd=seed_path)
+    gitman.run_git(["clone", str(remote_path), str(clone_path)])
+    gitman.run_git(["bundle", "create", str(bundle_path), "--all"], cwd=clone_path, timeout=60)
+    return {"main": main_node, "feature": feature_node}
+
+
 def basic_auth(username, password):
     token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
     return {"Authorization": f"Basic {token}"}
@@ -677,6 +757,132 @@ def test_create_repository_rolls_back_database_and_files_when_git_init_fails(iso
     assert not isolated_app.repo_path("alice", "broken").exists()
 
 
+def test_import_git_bundle_into_empty_repository_preserves_refs_and_metadata(isolated_app, tmp_path):
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "imported", "Imported repository")
+    bundle_path = tmp_path / "repo.bundle"
+    nodes = create_bundle_with_refs(bundle_path)
+
+    client = WsgiClient(isolated_app.app)
+    login_client(client, "alice")
+    settings_response = client.get("/alice/imported/settings")
+    assert settings_response.status_code == 200
+    assert "Import Git bundle" in settings_response.text
+    assert 'name="bundle"' in settings_response.text
+
+    response = client.post_multipart(
+        "/alice/imported/settings",
+        fields={"action": "import_bundle"},
+        files={"bundle": ("repo.bundle", bundle_path.read_bytes(), "application/octet-stream")},
+    )
+
+    assert response.status_code == 200
+    assert "Git bundle imported." in response.text
+    assert "Import Git bundle" not in response.text
+
+    imported_path = isolated_app.repo_path("alice", "imported")
+    assert isolated_app.repo_head_branch(imported_path) == "main"
+    assert isolated_app.run_git(["config", "gitman.owner"], cwd=imported_path).stdout.strip() == "alice"
+    assert isolated_app.run_git(["config", "gitman.name"], cwd=imported_path).stdout.strip() == "imported"
+    assert isolated_app.run_git(["config", "http.receivepack"], cwd=imported_path).stdout.strip() == "true"
+    assert isolated_app.repo_has_revision(imported_path, nodes["main"])
+    assert isolated_app.repo_has_revision(imported_path, nodes["feature"])
+    assert isolated_app.git_files(imported_path) == ["README.md"]
+    assert "feature.txt" in isolated_app.git_files(imported_path, "refs/heads/feature")
+    assert {branch["name"] for branch in isolated_app.list_repo_branches(imported_path)} == {"main", "feature"}
+    assert {tag["name"] for tag in isolated_app.list_repo_tags(imported_path)} == {"v1.0"}
+
+
+def test_import_git_bundle_maps_origin_remote_branches(isolated_app, tmp_path):
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "github-import", "")
+    bundle_path = tmp_path / "origin.bundle"
+    nodes = create_origin_bundle_with_refs(bundle_path)
+
+    client = WsgiClient(isolated_app.app)
+    login_client(client, "alice")
+    client.get("/alice/github-import/settings")
+
+    response = client.post_multipart(
+        "/alice/github-import/settings",
+        fields={"action": "import_bundle"},
+        files={"bundle": ("origin.bundle", bundle_path.read_bytes(), "application/octet-stream")},
+    )
+
+    assert response.status_code == 200
+    imported_path = isolated_app.repo_path("alice", "github-import")
+    branch_names = {branch["name"] for branch in isolated_app.list_repo_branches(imported_path)}
+    assert branch_names == {"main", "feature"}
+    assert "HEAD" not in branch_names
+    assert isolated_app.repo_has_revision(imported_path, nodes["main"])
+    assert isolated_app.repo_has_revision(imported_path, nodes["feature"])
+    assert "feature.txt" in isolated_app.git_files(imported_path, "refs/heads/feature")
+
+
+def test_import_git_bundle_rejects_invalid_upload_without_replacing_repo(isolated_app):
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "empty", "")
+    path = isolated_app.repo_path("alice", "empty")
+
+    client = WsgiClient(isolated_app.app)
+    login_client(client, "alice")
+    client.get("/alice/empty/settings")
+
+    response = client.post_multipart(
+        "/alice/empty/settings",
+        fields={"action": "import_bundle"},
+        files={"bundle": ("repo.bundle", b"not a git bundle", "application/octet-stream")},
+    )
+
+    assert response.status_code == 200
+    assert "Uploaded file is not a valid Git bundle." in response.text
+    assert isolated_app.commit_count(path) == 0
+    assert path.joinpath("objects").is_dir()
+    assert path.joinpath("HEAD").read_text(encoding="utf-8").strip() == "ref: refs/heads/main"
+
+
+def test_import_git_bundle_size_limit_returns_413(isolated_app, monkeypatch):
+    monkeypatch.setattr(gitman, "MAX_IMPORT_BYTES", 100)
+    owner = create_user("alice")
+    isolated_app.create_repository(owner, "empty", "")
+
+    client = WsgiClient(isolated_app.app)
+    login_client(client, "alice")
+    client.get("/alice/empty/settings")
+
+    response = client.post_multipart(
+        "/alice/empty/settings",
+        fields={"action": "import_bundle"},
+        files={"bundle": ("repo.bundle", b"x" * 200, "application/octet-stream")},
+    )
+
+    assert response.status_code == 413
+    assert "Request body too large." in response.text
+
+
+def test_import_git_bundle_form_is_owner_only_and_empty_repo_only(isolated_app):
+    owner = create_user("alice")
+    create_user("bob")
+    isolated_app.create_repository(owner, "demo", "")
+
+    owner_client = WsgiClient(isolated_app.app)
+    login_client(owner_client, "alice")
+    empty_response = owner_client.get("/alice/demo/settings")
+    assert empty_response.status_code == 200
+    assert "Import Git bundle" in empty_response.text
+
+    commit_file(isolated_app.repo_path("alice", "demo"), "README.md", "# Demo\n", user="alice")
+    non_empty_response = owner_client.get("/alice/demo/settings")
+    assert non_empty_response.status_code == 200
+    assert "Import Git bundle" not in non_empty_response.text
+
+    bob_client = WsgiClient(isolated_app.app)
+    login_client(bob_client, "bob")
+    bob_response = bob_client.get("/alice/demo/settings")
+    assert bob_response.status_code == 403
+    assert "Only the owner can update repository settings." in bob_response.text
+
+
 def test_star_and_contributor_helpers_update_database_and_git_config(isolated_app):
     owner = create_user("alice")
     contributor = create_user("bob")