gitlane 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitlane/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gitlane: a free, open-source Git client that runs in your browser."""
2
+
3
+ __version__ = "0.1.0"
gitlane/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m gitlane`` (used by the app-menu launcher)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
gitlane/blobs.py ADDED
@@ -0,0 +1,77 @@
1
+ """Reading file contents at a given version, used for image previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ import git
8
+
9
+ from .git_ops import GitError, check_paths
10
+ from .graph import resolve_commit
11
+
12
+ MAX_BLOB_BYTES = 20 * 1024 * 1024
13
+
14
+ IMAGE_TYPES = {
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".avif": "image/avif",
21
+ ".bmp": "image/bmp",
22
+ ".ico": "image/x-icon",
23
+ ".svg": "image/svg+xml",
24
+ }
25
+
26
+ # Special revisions besides commit ids.
27
+ WORKTREE = "WORKTREE" # the file on disk
28
+ INDEX = "INDEX" # the staged version
29
+ HEAD = "HEAD" # the last commit
30
+
31
+
32
+ def image_type(path: str) -> str | None:
33
+ return IMAGE_TYPES.get(PurePosixPath(path).suffix.lower())
34
+
35
+
36
+ def _spec(repo: git.Repo, rev: str, path: str) -> str:
37
+ if rev == INDEX:
38
+ return f":0:{path}"
39
+ if rev == HEAD:
40
+ return f"HEAD:{path}"
41
+ return f"{resolve_commit(repo, rev)}:{path}"
42
+
43
+
44
+ def _worktree_file(repo: git.Repo, path: str) -> Path | None:
45
+ root = Path(repo.working_tree_dir).resolve()
46
+ f = (root / path).resolve()
47
+ if not f.is_relative_to(root) or not f.is_file(): # also blocks symlinks pointing outside
48
+ return None
49
+ return f
50
+
51
+
52
+ def blob_exists(repo: git.Repo, rev: str, path: str) -> bool:
53
+ (path,) = check_paths([path])
54
+ if rev == WORKTREE:
55
+ return _worktree_file(repo, path) is not None
56
+ code, _, _ = repo.git.cat_file("-e", _spec(repo, rev, path),
57
+ with_extended_output=True, with_exceptions=False)
58
+ return code == 0
59
+
60
+
61
+ def read_blob(repo: git.Repo, rev: str, path: str) -> bytes | None:
62
+ """Contents of ``path`` at ``rev``; None if the file does not exist there."""
63
+ (path,) = check_paths([path])
64
+ if rev == WORKTREE:
65
+ f = _worktree_file(repo, path)
66
+ if f is None:
67
+ return None
68
+ if f.stat().st_size > MAX_BLOB_BYTES:
69
+ raise GitError("File is too large to preview.")
70
+ return f.read_bytes()
71
+ spec = _spec(repo, rev, path)
72
+ code, size, _ = repo.git.cat_file("-s", spec, with_extended_output=True, with_exceptions=False)
73
+ if code != 0:
74
+ return None
75
+ if int(size) > MAX_BLOB_BYTES:
76
+ raise GitError("File is too large to preview.")
77
+ return repo.git.cat_file("blob", spec, stdout_as_string=False, strip_newline_in_stdout=False)
gitlane/branches.py ADDED
@@ -0,0 +1,267 @@
1
+ """Branch and commit operations (milestone 5).
2
+
3
+ Every function returns a short message for the UI or raises GitError.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import git
13
+
14
+ from .git_ops import (GitError, _has_head, _network_kwargs, _run, check_paths, get_status,
15
+ operation_in_progress)
16
+ from .graph import resolve_commit
17
+
18
+ RESET_MODES = ("soft", "mixed", "hard")
19
+
20
+
21
+ @dataclass
22
+ class Branch:
23
+ name: str
24
+ current: bool = False
25
+ upstream: str | None = None
26
+ ahead: int = 0
27
+ behind: int = 0
28
+ gone: bool = False # upstream was deleted on the remote
29
+
30
+
31
+ def list_branches(repo: git.Repo) -> tuple[list[Branch], list[str]]:
32
+ """Local branches and remote-tracking branch names (without */HEAD)."""
33
+ out = repo.git.for_each_ref(
34
+ "--format=%(refname)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)",
35
+ "refs/heads", "refs/remotes",
36
+ )
37
+ local, remote = [], []
38
+ for line in out.splitlines():
39
+ ref, head, upstream, track = (line.split("\0") + ["", "", ""])[:4]
40
+ if ref.startswith("refs/heads/"):
41
+ b = Branch(ref[len("refs/heads/"):], current=head == "*", upstream=upstream or None)
42
+ b.gone = track == "gone"
43
+ for m in re.finditer(r"(ahead|behind) (\d+)", track):
44
+ setattr(b, m.group(1), int(m.group(2)))
45
+ local.append(b)
46
+ elif ref.startswith("refs/remotes/") and not ref.endswith("/HEAD"):
47
+ remote.append(ref[len("refs/remotes/"):])
48
+ return local, remote
49
+
50
+
51
+ def check_branch_name(repo: git.Repo, name: str) -> str:
52
+ name = (name or "").strip()
53
+ if not name:
54
+ raise GitError("Type a name for the branch.")
55
+ code, _, _ = repo.git.check_ref_format("--branch", name,
56
+ with_extended_output=True, with_exceptions=False)
57
+ if code != 0 or name.startswith("-"):
58
+ raise GitError(f"'{name}' is not a valid branch name.",
59
+ hint="Branch names can't contain spaces, '..', '~', '^', ':', '?', '*', '[' "
60
+ "or start with '-'. Try something like feature/login.")
61
+ return name
62
+
63
+
64
+ def _local_names(repo: git.Repo) -> list[str]:
65
+ return [b.name for b in list_branches(repo)[0]]
66
+
67
+
68
+ def _require_local(repo: git.Repo, name: str) -> str:
69
+ if name not in _local_names(repo):
70
+ raise GitError(f"Branch '{name}' not found.")
71
+ return name
72
+
73
+
74
+ def _require_branch_or_remote(repo: git.Repo, name: str) -> str:
75
+ local, remote = list_branches(repo)
76
+ if name not in [b.name for b in local] and name not in remote:
77
+ raise GitError(f"Branch '{name}' not found.")
78
+ return name
79
+
80
+
81
+ def _current_branch_name(repo: git.Repo) -> str | None:
82
+ return None if repo.head.is_detached else repo.active_branch.name
83
+
84
+
85
+ def _no_operation(repo: git.Repo) -> None:
86
+ op = operation_in_progress(repo)
87
+ if op:
88
+ raise GitError(f"A {op} is in progress. Finish it or abort it first.")
89
+
90
+
91
+ # --- branches ----------------------------------------------------------------
92
+
93
+ def create_branch(repo: git.Repo, name: str, start: str | None = None, checkout: bool = True) -> str:
94
+ name = check_branch_name(repo, name)
95
+ if not _has_head(repo):
96
+ raise GitError("Make a first commit before creating branches.")
97
+ args = [resolve_commit(repo, start)] if start else []
98
+ if checkout:
99
+ _run(repo.git.switch, "-q", "-c", name, *args)
100
+ return f"Created branch {name} and switched to it."
101
+ _run(repo.git.branch, name, *args)
102
+ return f"Created branch {name}."
103
+
104
+
105
+ def checkout(repo: git.Repo, name: str) -> str:
106
+ """Switch to a local branch, or to a remote branch (creating a local one)."""
107
+ _require_branch_or_remote(repo, name)
108
+ local_names = _local_names(repo)
109
+ if name in local_names:
110
+ _run(repo.git.switch, "-q", name)
111
+ return f"Switched to {name}."
112
+ # Remote branch like origin/feature -> local "feature" tracking it.
113
+ remotes = sorted((r.name for r in repo.remotes), key=len, reverse=True)
114
+ remote = next(r for r in remotes if name.startswith(r + "/"))
115
+ local = name[len(remote) + 1:]
116
+ if local in local_names:
117
+ _run(repo.git.switch, "-q", local)
118
+ return f"Switched to your existing local branch {local}."
119
+ _run(repo.git.switch, "-q", "-c", local, "--track", name)
120
+ return f"Created local branch {local} tracking {name} and switched to it."
121
+
122
+
123
+ def checkout_commit(repo: git.Repo, sha: str) -> str:
124
+ sha = resolve_commit(repo, sha)
125
+ _run(repo.git.switch, "-q", "--detach", sha)
126
+ return f"Checked out {sha[:7]} (detached HEAD). Create a branch to keep new commits."
127
+
128
+
129
+ def merge(repo: git.Repo, name: str) -> str:
130
+ _require_branch_or_remote(repo, name)
131
+ current = _current_branch_name(repo)
132
+ if current is None:
133
+ raise GitError("You are not on a branch (detached HEAD). Switch to a branch first.")
134
+ if name == current:
135
+ raise GitError("A branch can't be merged into itself.")
136
+ _no_operation(repo)
137
+ before = repo.head.commit.hexsha
138
+ _run(repo.git.merge, "--no-edit", name)
139
+ if repo.head.commit.hexsha == before:
140
+ return f"{current} already contains everything in {name}."
141
+ return f"Merged {name} into {current}."
142
+
143
+
144
+ def delete_branch(repo: git.Repo, name: str, force: bool = False) -> str:
145
+ _require_local(repo, name)
146
+ if name == _current_branch_name(repo):
147
+ raise GitError(f"You can't delete {name} because it is the current branch. "
148
+ "Switch to another branch first.")
149
+ try:
150
+ _run(repo.git.branch, "-D" if force else "-d", name)
151
+ except GitError as e:
152
+ if "not fully merged" in str(e):
153
+ e.action = {
154
+ "label": "Delete anyway", "url": "/actions/branch/delete",
155
+ "vals": {"name": name, "force": "1"},
156
+ "confirm": f"Delete {name} anyway?\n\nIts unmerged commits will no longer be on any "
157
+ "branch and may be lost.",
158
+ }
159
+ raise
160
+ return f"Deleted branch {name}."
161
+
162
+
163
+ def _split_remote(repo: git.Repo, name: str) -> tuple[str, str]:
164
+ """"origin/feature/x" -> ("origin", "feature/x"), using the real remote names."""
165
+ remotes = sorted((r.name for r in repo.remotes), key=len, reverse=True)
166
+ remote = next((r for r in remotes if name.startswith(r + "/")), None)
167
+ if remote is None:
168
+ raise GitError(f"Remote branch '{name}' not found.")
169
+ return remote, name[len(remote) + 1:]
170
+
171
+
172
+ def delete_remote_branch(repo: git.Repo, name: str) -> str:
173
+ """Delete a branch on the remote server (``git push <remote> --delete <branch>``)."""
174
+ if name not in list_branches(repo)[1]:
175
+ raise GitError(f"Remote branch '{name}' not found.")
176
+ remote, branch = _split_remote(repo, name)
177
+ code, default, _ = repo.git.symbolic_ref("--short", "-q", f"refs/remotes/{remote}/HEAD",
178
+ with_extended_output=True, with_exceptions=False)
179
+ if code == 0 and default.strip() == name:
180
+ raise GitError(f"{branch} is the default branch of {remote}, so it can't be deleted here.",
181
+ hint="Pick another default branch in the repository settings on GitHub "
182
+ "(or your Git host) first.")
183
+ try:
184
+ _run(repo.git.push, remote, "--delete", branch, **_network_kwargs(repo))
185
+ except GitError as e:
186
+ if "remote ref does not exist" not in str(e):
187
+ raise
188
+ # Someone already deleted it on the server: just drop our stale copy.
189
+ _run(repo.git.branch, "-r", "-d", name)
190
+ return f"{branch} was already gone from {remote}; removed the old copy of {name}."
191
+ kept = [b.name for b in list_branches(repo)[0] if b.upstream == name]
192
+ note = f" Your local branch {kept[0]} is kept." if kept else ""
193
+ return f"Deleted {branch} on {remote}.{note}"
194
+
195
+
196
+ # --- commit operations ---------------------------------------------------------
197
+
198
+ def _single_parent(repo: git.Repo, sha: str, what: str) -> str:
199
+ sha = resolve_commit(repo, sha)
200
+ if len(repo.commit(sha).parents) > 1:
201
+ raise GitError(f"{what} merge commits isn't supported yet. Use a terminal for this one.")
202
+ return sha
203
+
204
+
205
+ def cherry_pick(repo: git.Repo, sha: str) -> str:
206
+ _no_operation(repo)
207
+ sha = _single_parent(repo, sha, "Cherry-picking")
208
+ _run(repo.git.cherry_pick, sha)
209
+ return f"Cherry-picked {sha[:7]} onto {_current_branch_name(repo) or 'HEAD'}."
210
+
211
+
212
+ def revert(repo: git.Repo, sha: str) -> str:
213
+ _no_operation(repo)
214
+ sha = _single_parent(repo, sha, "Reverting")
215
+ _run(repo.git.revert, "--no-edit", sha)
216
+ return f"Reverted {sha[:7]} with a new commit."
217
+
218
+
219
+ def reset(repo: git.Repo, sha: str, mode: str) -> str:
220
+ if mode not in RESET_MODES:
221
+ raise GitError("Unknown reset mode.")
222
+ _no_operation(repo)
223
+ sha = resolve_commit(repo, sha)
224
+ _run(repo.git.reset, "-q", f"--{mode}", sha)
225
+ where = _current_branch_name(repo) or "HEAD"
226
+ return f"Reset {where} to {sha[:7]} ({mode})."
227
+
228
+
229
+ # --- merge conflicts ------------------------------------------------------------
230
+
231
+ _MARKER = re.compile(rb"^(<{7} |>{7} |={7}$)", re.MULTILINE)
232
+
233
+
234
+ def abort_operation(repo: git.Repo) -> str:
235
+ op = operation_in_progress(repo)
236
+ if op is None:
237
+ raise GitError("There is nothing to abort.")
238
+ _run(getattr(repo.git, op.replace("-", "_")), "--abort")
239
+ return f"{op.capitalize()} aborted. Everything is back to how it was before."
240
+
241
+
242
+ def continue_operation(repo: git.Repo) -> str:
243
+ """Stage the conflicted files and finish the merge / cherry-pick / revert."""
244
+ op = operation_in_progress(repo)
245
+ if op is None:
246
+ raise GitError("There is nothing to continue.")
247
+ if op == "rebase":
248
+ raise GitError("Finishing a rebase isn't supported yet. Use `git rebase --continue` "
249
+ "in a terminal, or abort it here.")
250
+ conflicted = [f.path for f in get_status(repo).conflicted]
251
+ root = Path(repo.working_tree_dir)
252
+ still = [p for p in check_paths(conflicted) if (root / p).is_file()
253
+ and _MARKER.search((root / p).read_bytes())] if conflicted else []
254
+ if still:
255
+ raise GitError(
256
+ "These files still contain conflict markers (<<<<<<<, =======, >>>>>>>): "
257
+ + ", ".join(still),
258
+ hint="Open them in your editor, keep the right lines, delete the markers, save, "
259
+ "then try again.",
260
+ )
261
+ if conflicted:
262
+ _run(repo.git.add, "--all", "--", *conflicted)
263
+ if op == "merge":
264
+ _run(repo.git.commit, "--no-edit", "-q")
265
+ else:
266
+ _run(getattr(repo.git, op.replace("-", "_")), "--continue")
267
+ return f"{op.capitalize()} completed."
gitlane/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ """The ``gitlane`` command: start the local server and open the browser.
2
+
3
+ ``gitlane <path>`` opens that repo as a tab (next to the remembered tabs).
4
+ Plain ``gitlane`` opens the repo in the current folder, or, outside a repo,
5
+ the page where you pick one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import re
12
+ import secrets
13
+ import shutil
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import webbrowser
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+
24
+ HOST = "127.0.0.1" # never 0.0.0.0 (SPEC.md §3)
25
+ MIN_GIT = (2, 23) # first version with `git switch` and `git restore`
26
+
27
+
28
+ def check_git() -> str | None:
29
+ """Return a readable problem if Git is missing or too old, else None.
30
+
31
+ Runs before GitPython is imported: GitPython fails at import time with
32
+ a cryptic message when there is no ``git`` program.
33
+ """
34
+ exe = shutil.which("git")
35
+ if exe is None:
36
+ return ("Git is not installed (or not on your PATH). Install it from "
37
+ "https://git-scm.com/downloads and try again.")
38
+ try:
39
+ out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10).stdout
40
+ except (OSError, subprocess.SubprocessError):
41
+ return f"Could not run {exe}. Check that Git works in a terminal: git --version"
42
+ m = re.search(r"(\d+)\.(\d+)", out)
43
+ if m and (int(m[1]), int(m[2])) < MIN_GIT:
44
+ need = ".".join(map(str, MIN_GIT))
45
+ return f"Your Git is too old ({out.strip()}). gitlane needs Git {need} or newer."
46
+ return None
47
+
48
+
49
+ def find_free_port() -> int:
50
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
51
+ s.bind((HOST, 0))
52
+ return s.getsockname()[1]
53
+
54
+
55
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
56
+ p = argparse.ArgumentParser(prog="gitlane", description="Open a Git repository in your browser.")
57
+ p.add_argument("path", nargs="?", default=None,
58
+ help="folder inside a repository (default: the current folder, if it is one)")
59
+ p.add_argument("--port", type=int, default=None, help="port to listen on (default: a free port)")
60
+ p.add_argument("--no-browser", action="store_true", help="do not open the browser automatically")
61
+ p.add_argument("--install-launcher", action="store_true",
62
+ help="add gitlane to your app menu (Linux), then exit")
63
+ p.add_argument("--remove-launcher", action="store_true",
64
+ help="remove gitlane from your app menu (Linux), then exit")
65
+ p.add_argument("--version", action="version", version=f"gitlane {__version__}")
66
+ return p.parse_args(argv)
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ args = parse_args(argv)
71
+ if args.install_launcher or args.remove_launcher:
72
+ from . import launcher
73
+
74
+ try:
75
+ msg = launcher.install() if args.install_launcher else launcher.remove()
76
+ except launcher.LauncherError as e:
77
+ print(f"gitlane: {e}", file=sys.stderr)
78
+ return 1
79
+ print(msg)
80
+ return 0
81
+
82
+ problem = check_git()
83
+ if problem:
84
+ print(f"gitlane: {problem}", file=sys.stderr)
85
+ return 1
86
+
87
+ from .git_ops import NotAGitRepo, open_repo
88
+
89
+ try:
90
+ repo = open_repo(args.path or ".")
91
+ except NotAGitRepo as e:
92
+ if args.path is not None: # asked for a specific folder: say what's wrong
93
+ print(f"gitlane: {e}", file=sys.stderr)
94
+ print("Pass a folder inside a Git repository, e.g. gitlane ~/dev/myproject, "
95
+ "or run plain gitlane and pick one in the browser.", file=sys.stderr)
96
+ return 1
97
+ repo_root = None # pick a repo in the browser
98
+ else:
99
+ repo_root = Path(repo.working_tree_dir)
100
+ repo.close()
101
+
102
+ import uvicorn # imported late so `gitlane --help` stays fast
103
+
104
+ from .server import create_app
105
+
106
+ port = args.port or find_free_port()
107
+ token = secrets.token_urlsafe(32)
108
+ url = f"http://{HOST}:{port}/?token={token}"
109
+
110
+ app = create_app(repo_root, token, port)
111
+ server = uvicorn.Server(uvicorn.Config(app, host=HOST, port=port, log_level="warning"))
112
+
113
+ def announce() -> None:
114
+ while not server.started and not server.should_exit:
115
+ time.sleep(0.05)
116
+ if server.should_exit:
117
+ return
118
+ print(f"gitlane {__version__} — {repo_root or 'pick a repository in the browser'}", flush=True)
119
+ print(f"Open: {url}", flush=True)
120
+ print("Press Ctrl+C to stop.", flush=True)
121
+ if not args.no_browser:
122
+ webbrowser.open(url)
123
+
124
+ threading.Thread(target=announce, daemon=True).start()
125
+ server.run()
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())