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 +3 -0
- gitlane/__main__.py +7 -0
- gitlane/blobs.py +77 -0
- gitlane/branches.py +267 -0
- gitlane/cli.py +130 -0
- gitlane/git_ops.py +523 -0
- gitlane/graph.py +319 -0
- gitlane/launcher.py +71 -0
- gitlane/server.py +1039 -0
- gitlane/stash.py +181 -0
- gitlane/static/app.css +643 -0
- gitlane/static/gitlane.svg +15 -0
- gitlane/static/htmx.min.js +1 -0
- gitlane/static/index.html +822 -0
- gitlane/workspace.py +211 -0
- gitlane-0.1.0.dist-info/METADATA +156 -0
- gitlane-0.1.0.dist-info/RECORD +21 -0
- gitlane-0.1.0.dist-info/WHEEL +5 -0
- gitlane-0.1.0.dist-info/entry_points.txt +2 -0
- gitlane-0.1.0.dist-info/licenses/LICENSE +21 -0
- gitlane-0.1.0.dist-info/top_level.txt +1 -0
gitlane/__init__.py
ADDED
gitlane/__main__.py
ADDED
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())
|