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/git_ops.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""All Git operations used by gitlane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import git
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotAGitRepo(Exception):
|
|
14
|
+
"""Raised when a folder is not inside a usable Git working tree."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def open_repo(path: str | Path) -> git.Repo:
|
|
18
|
+
"""Open the repository that contains ``path`` (searching parent folders)."""
|
|
19
|
+
path = Path(path).resolve()
|
|
20
|
+
if not path.exists():
|
|
21
|
+
raise NotAGitRepo(f"Folder does not exist: {path}")
|
|
22
|
+
try:
|
|
23
|
+
repo = git.Repo(path, search_parent_directories=True)
|
|
24
|
+
except (git.InvalidGitRepositoryError, git.NoSuchPathError):
|
|
25
|
+
raise NotAGitRepo(f"Not a Git repository: {path}") from None
|
|
26
|
+
if repo.bare:
|
|
27
|
+
raise NotAGitRepo(f"Bare repositories are not supported: {path}")
|
|
28
|
+
# Treat every path we pass as a literal file name, never as a pattern:
|
|
29
|
+
# a file called "*" must not mean "all files".
|
|
30
|
+
# Never let Git wait for a password on a terminal nobody is watching.
|
|
31
|
+
# ...and never open an editor (merge/revert/cherry-pick would wait forever).
|
|
32
|
+
repo.git.update_environment(
|
|
33
|
+
GIT_LITERAL_PATHSPECS="1", GIT_TERMINAL_PROMPT="0",
|
|
34
|
+
GIT_EDITOR="true", GIT_SEQUENCE_EDITOR="true", GIT_MERGE_AUTOEDIT="no",
|
|
35
|
+
)
|
|
36
|
+
return repo
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class FileChange:
|
|
41
|
+
path: str
|
|
42
|
+
status: str # human label: modified, added, deleted, renamed, ...
|
|
43
|
+
orig_path: str | None = None # for renames/copies
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RepoStatus:
|
|
48
|
+
root: str
|
|
49
|
+
name: str
|
|
50
|
+
branch: str | None # None when HEAD is detached
|
|
51
|
+
head_commit: str | None # None when the repo has no commits yet
|
|
52
|
+
head_summary: str | None
|
|
53
|
+
upstream: str | None
|
|
54
|
+
ahead: int = 0
|
|
55
|
+
behind: int = 0
|
|
56
|
+
staged: list[FileChange] = field(default_factory=list)
|
|
57
|
+
unstaged: list[FileChange] = field(default_factory=list)
|
|
58
|
+
untracked: list[FileChange] = field(default_factory=list)
|
|
59
|
+
conflicted: list[FileChange] = field(default_factory=list)
|
|
60
|
+
operation: str | None = None # "merge", "cherry-pick", "revert", "rebase"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def detached(self) -> bool:
|
|
64
|
+
return self.branch is None and self.head_commit is not None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def clean(self) -> bool:
|
|
68
|
+
return not (self.staged or self.unstaged or self.untracked or self.conflicted)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_CODES = {
|
|
72
|
+
"M": "modified",
|
|
73
|
+
"T": "type changed",
|
|
74
|
+
"A": "added",
|
|
75
|
+
"D": "deleted",
|
|
76
|
+
"R": "renamed",
|
|
77
|
+
"C": "copied",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_status(repo: git.Repo) -> RepoStatus:
|
|
82
|
+
"""Read branch info and file changes using ``git status --porcelain=v2``."""
|
|
83
|
+
raw = repo.git.status(
|
|
84
|
+
"--porcelain=v2", "--branch", "-z", "--untracked-files=all",
|
|
85
|
+
strip_newline_in_stdout=False,
|
|
86
|
+
)
|
|
87
|
+
root = Path(repo.working_tree_dir)
|
|
88
|
+
status = RepoStatus(
|
|
89
|
+
root=str(root), name=root.name, branch=None, head_commit=None,
|
|
90
|
+
head_summary=None, upstream=None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
entries = raw.split("\0")
|
|
94
|
+
i = 0
|
|
95
|
+
while i < len(entries):
|
|
96
|
+
entry = entries[i]
|
|
97
|
+
i += 1
|
|
98
|
+
if not entry:
|
|
99
|
+
continue
|
|
100
|
+
kind = entry[0]
|
|
101
|
+
if kind == "#":
|
|
102
|
+
_parse_header(entry, status)
|
|
103
|
+
elif kind == "1":
|
|
104
|
+
# 1 XY sub mH mI mW hH hI path
|
|
105
|
+
parts = entry.split(" ", 8)
|
|
106
|
+
_add_change(status, parts[1], parts[8])
|
|
107
|
+
elif kind == "2":
|
|
108
|
+
# 2 XY sub mH mI mW hH hI Xscore path (orig path is the next NUL entry)
|
|
109
|
+
parts = entry.split(" ", 9)
|
|
110
|
+
orig = entries[i] if i < len(entries) else None
|
|
111
|
+
i += 1
|
|
112
|
+
_add_change(status, parts[1], parts[9], orig)
|
|
113
|
+
elif kind == "u":
|
|
114
|
+
# u XY sub m1 m2 m3 mW h1 h2 h3 path
|
|
115
|
+
parts = entry.split(" ", 10)
|
|
116
|
+
status.conflicted.append(FileChange(parts[10], "conflict"))
|
|
117
|
+
elif kind == "?":
|
|
118
|
+
status.untracked.append(FileChange(entry[2:], "untracked"))
|
|
119
|
+
# "!" (ignored) is not requested, so nothing else to handle.
|
|
120
|
+
|
|
121
|
+
if status.head_commit is not None:
|
|
122
|
+
status.head_summary = repo.head.commit.summary
|
|
123
|
+
status.operation = operation_in_progress(repo)
|
|
124
|
+
return status
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def operation_in_progress(repo: git.Repo) -> str | None:
|
|
128
|
+
git_dir = Path(repo.git_dir)
|
|
129
|
+
if (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists():
|
|
130
|
+
return "rebase"
|
|
131
|
+
for name, op in (("MERGE_HEAD", "merge"), ("CHERRY_PICK_HEAD", "cherry-pick"),
|
|
132
|
+
("REVERT_HEAD", "revert")):
|
|
133
|
+
if (git_dir / name).exists():
|
|
134
|
+
return op
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_header(entry: str, status: RepoStatus) -> None:
|
|
139
|
+
_, key, *rest = entry.split(" ", 2)
|
|
140
|
+
value = rest[0] if rest else ""
|
|
141
|
+
if key == "branch.oid":
|
|
142
|
+
status.head_commit = None if value == "(initial)" else value
|
|
143
|
+
elif key == "branch.head":
|
|
144
|
+
status.branch = None if value == "(detached)" else value
|
|
145
|
+
elif key == "branch.upstream":
|
|
146
|
+
status.upstream = value
|
|
147
|
+
elif key == "branch.ab":
|
|
148
|
+
ahead, behind = value.split()
|
|
149
|
+
status.ahead = int(ahead.lstrip("+"))
|
|
150
|
+
status.behind = int(behind.lstrip("-"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _add_change(status: RepoStatus, xy: str, path: str, orig: str | None = None) -> None:
|
|
154
|
+
x, y = xy[0], xy[1]
|
|
155
|
+
if x != ".":
|
|
156
|
+
status.staged.append(FileChange(path, _CODES.get(x, x), orig if x in "RC" else None))
|
|
157
|
+
if y != ".":
|
|
158
|
+
status.unstaged.append(FileChange(path, _CODES.get(y, y)))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- Changes (milestone 2) ------------------------------------------------
|
|
162
|
+
|
|
163
|
+
class GitError(Exception):
|
|
164
|
+
"""A Git operation failed; the message is safe to show to the user.
|
|
165
|
+
|
|
166
|
+
``hint`` is an optional plain-language explanation shown above Git's
|
|
167
|
+
own message.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, message: str, hint: str | None = None, action: dict | None = None):
|
|
171
|
+
super().__init__(message)
|
|
172
|
+
self.hint = hint
|
|
173
|
+
# Optional follow-up button: {"label", "url", "vals", "confirm"}
|
|
174
|
+
self.action = action
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
MAX_DIFF_LINES = 3000
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_paths(paths: list[str]) -> list[str]:
|
|
181
|
+
"""Reject anything that is not a plain relative path inside the repo."""
|
|
182
|
+
if not paths:
|
|
183
|
+
raise GitError("No file selected.")
|
|
184
|
+
clean = []
|
|
185
|
+
for p in paths:
|
|
186
|
+
if not p or "\0" in p:
|
|
187
|
+
raise GitError("Invalid file name.")
|
|
188
|
+
norm = os.path.normpath(p)
|
|
189
|
+
if os.path.isabs(p) or norm == ".." or norm.startswith(".." + os.sep):
|
|
190
|
+
raise GitError(f"Path is outside the repository: {p}")
|
|
191
|
+
clean.append(p)
|
|
192
|
+
return clean
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _has_head(repo: git.Repo) -> bool:
|
|
196
|
+
try:
|
|
197
|
+
repo.head.commit
|
|
198
|
+
return True
|
|
199
|
+
except ValueError:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _run(fn, *args, **kwargs):
|
|
204
|
+
try:
|
|
205
|
+
return fn(*args, **kwargs)
|
|
206
|
+
except git.GitCommandError as e:
|
|
207
|
+
msg = "\n".join(
|
|
208
|
+
part.strip().removeprefix("stderr:").removeprefix("stdout:").strip().strip("'").strip()
|
|
209
|
+
for part in (e.stderr, e.stdout) if part and str(part).strip()
|
|
210
|
+
) or str(e)
|
|
211
|
+
raise GitError(msg, hint=explain(msg)) from None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Common Git failures -> plain-language hints. First match wins.
|
|
215
|
+
_HINTS = [
|
|
216
|
+
("Permission denied (publickey)",
|
|
217
|
+
"The remote did not accept your SSH key. Check it works with "
|
|
218
|
+
"`ssh -T git@github.com` (or your host) in a terminal."),
|
|
219
|
+
("Host key verification failed",
|
|
220
|
+
"This SSH host is not known yet. Connect once from a terminal "
|
|
221
|
+
"(for example `ssh -T git@github.com`) to accept its key, then try again."),
|
|
222
|
+
("terminal prompts disabled",
|
|
223
|
+
"This HTTPS remote needs a username/password and gitlane cannot ask for it. "
|
|
224
|
+
"Set up a Git credential helper or switch the remote to SSH."),
|
|
225
|
+
("could not read Username",
|
|
226
|
+
"This HTTPS remote needs a username/password and gitlane cannot ask for it. "
|
|
227
|
+
"Set up a Git credential helper or switch the remote to SSH."),
|
|
228
|
+
("Could not resolve host",
|
|
229
|
+
"Cannot reach the remote host. Check your internet connection."),
|
|
230
|
+
("Couldn't connect to server",
|
|
231
|
+
"Cannot connect to the remote server. Check the remote URL and your network."),
|
|
232
|
+
("Could not connect to server", # curl 8 wording
|
|
233
|
+
"Cannot connect to the remote server. Check the remote URL and your network."),
|
|
234
|
+
("Failed to connect to",
|
|
235
|
+
"Cannot connect to the remote server. Check the remote URL and your network."),
|
|
236
|
+
("Connection refused",
|
|
237
|
+
"The remote refused the connection. Check the remote URL and your network."),
|
|
238
|
+
("Connection timed out",
|
|
239
|
+
"The connection to the remote timed out. Check your network."),
|
|
240
|
+
("did not complete in",
|
|
241
|
+
"The operation took too long and was stopped. Check your network and try again."),
|
|
242
|
+
("stale info",
|
|
243
|
+
"The remote branch changed since your last fetch, so force push was refused "
|
|
244
|
+
"to protect that work. Fetch, look at the new commits, then decide."),
|
|
245
|
+
("fetch first",
|
|
246
|
+
"The remote has commits you don't have yet. Pull first, then push."),
|
|
247
|
+
("non-fast-forward",
|
|
248
|
+
"The remote has commits you don't have yet. Pull first, then push."),
|
|
249
|
+
("Not possible to fast-forward",
|
|
250
|
+
"Your branch and the remote have both changed, and your Git settings only allow "
|
|
251
|
+
"fast-forward pulls."),
|
|
252
|
+
("divergent branches",
|
|
253
|
+
"Your branch and the remote have both changed. Your Git settings decide how to "
|
|
254
|
+
"combine them: set `git config pull.rebase false` to merge."),
|
|
255
|
+
("would be overwritten by merge",
|
|
256
|
+
"You have uncommitted changes to files the pull wants to update. "
|
|
257
|
+
"Commit or stash them first."),
|
|
258
|
+
("untracked working tree files would be overwritten",
|
|
259
|
+
"Untracked files would be overwritten by the pull. Move, commit or delete them first."),
|
|
260
|
+
("would be overwritten by checkout",
|
|
261
|
+
"You have uncommitted changes that conflict with that branch. Commit or stash them first."),
|
|
262
|
+
("not fully merged",
|
|
263
|
+
"This branch has commits that are not merged into the current branch. "
|
|
264
|
+
"Deleting it may lose them."),
|
|
265
|
+
("Cannot delete branch",
|
|
266
|
+
"You can't delete the branch you are on. Switch to another branch first."),
|
|
267
|
+
("already exists",
|
|
268
|
+
"A branch with that name already exists."),
|
|
269
|
+
("previous cherry-pick is now empty",
|
|
270
|
+
"The changes from this commit are already in your branch, so there is nothing to apply."),
|
|
271
|
+
("CONFLICT",
|
|
272
|
+
"Git stopped because of conflicts. Fix the conflicted files in your editor, then click "
|
|
273
|
+
"\"Mark resolved and commit\" — or click \"Abort\" to go back to how things were."),
|
|
274
|
+
("Automatic merge failed",
|
|
275
|
+
"Git stopped because of conflicts. Fix the conflicted files in your editor, then click "
|
|
276
|
+
"\"Mark resolved and commit\" — or click \"Abort\" to go back to how things were."),
|
|
277
|
+
("after resolving the conflicts",
|
|
278
|
+
"Git stopped because of conflicts. Fix the conflicted files in your editor, then click "
|
|
279
|
+
"\"Mark resolved and commit\" — or click \"Abort\" to go back to how things were."),
|
|
280
|
+
("does not appear to be a git repository",
|
|
281
|
+
"The remote URL is wrong or you don't have access to it."),
|
|
282
|
+
("Repository not found",
|
|
283
|
+
"The remote repository was not found, or you don't have access to it."),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def explain(message: str) -> str | None:
|
|
288
|
+
for needle, hint in _HINTS:
|
|
289
|
+
if needle in message:
|
|
290
|
+
return hint
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def stage(repo: git.Repo, paths: list[str]) -> None:
|
|
295
|
+
"""Stage whole files (also stages deletions)."""
|
|
296
|
+
_run(repo.git.add, "--all", "--", *check_paths(paths))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def stage_all(repo: git.Repo) -> None:
|
|
300
|
+
_run(repo.git.add, "--all")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def unstage(repo: git.Repo, paths: list[str]) -> None:
|
|
304
|
+
paths = check_paths(paths)
|
|
305
|
+
if _has_head(repo):
|
|
306
|
+
_run(repo.git.restore, "--staged", "--", *paths)
|
|
307
|
+
else: # no commits yet: there is nothing to restore from
|
|
308
|
+
_run(repo.git.rm, "--cached", "-q", "-r", "--", *paths)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def unstage_all(repo: git.Repo) -> None:
|
|
312
|
+
if _has_head(repo):
|
|
313
|
+
_run(repo.git.reset, "-q")
|
|
314
|
+
else:
|
|
315
|
+
_run(repo.git.rm, "--cached", "-q", "-r", "--ignore-unmatch", ".")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def discard(repo: git.Repo, path: str) -> None:
|
|
319
|
+
"""Throw away unstaged changes to one file. Untracked files are deleted.
|
|
320
|
+
|
|
321
|
+
Staged changes are kept: the file goes back to its staged version.
|
|
322
|
+
"""
|
|
323
|
+
(path,) = check_paths([path])
|
|
324
|
+
if path in repo.untracked_files:
|
|
325
|
+
_run(repo.git.clean, "-f", "-q", "--", path)
|
|
326
|
+
else:
|
|
327
|
+
_run(repo.git.restore, "--worktree", "--", path)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def commit(repo: git.Repo, message: str) -> str:
|
|
331
|
+
"""Commit the staged changes; returns the new commit's short hash."""
|
|
332
|
+
message = message.strip()
|
|
333
|
+
if not message:
|
|
334
|
+
raise GitError("Write a summary for the commit message first.")
|
|
335
|
+
code, _, _ = repo.git.diff("--cached", "--quiet", with_extended_output=True, with_exceptions=False)
|
|
336
|
+
if code == 0:
|
|
337
|
+
raise GitError("Nothing staged to commit. Stage some files first.")
|
|
338
|
+
_run(repo.git.commit, "-q", "--cleanup=strip", "-m", message)
|
|
339
|
+
return repo.head.commit.hexsha[:7]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass
|
|
343
|
+
class DiffLine:
|
|
344
|
+
kind: str # "add", "del", "ctx", "hunk", "meta"
|
|
345
|
+
text: str
|
|
346
|
+
old: int | None = None
|
|
347
|
+
new: int | None = None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass
|
|
351
|
+
class FileDiff:
|
|
352
|
+
path: str
|
|
353
|
+
staged: bool
|
|
354
|
+
lines: list[DiffLine] = field(default_factory=list)
|
|
355
|
+
binary: bool = False
|
|
356
|
+
truncated: bool = False
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def empty(self) -> bool:
|
|
360
|
+
return not self.binary and not any(l.kind in ("add", "del") for l in self.lines)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def diff_file(repo: git.Repo, path: str, staged: bool = False) -> FileDiff:
|
|
364
|
+
"""Unified diff of one file: staged (index vs HEAD) or unstaged (worktree vs index).
|
|
365
|
+
|
|
366
|
+
Untracked files are shown as entirely added.
|
|
367
|
+
"""
|
|
368
|
+
(path,) = check_paths([path])
|
|
369
|
+
opts = ["--no-color", "--no-ext-diff", "--no-textconv"]
|
|
370
|
+
if not staged and path in repo.untracked_files:
|
|
371
|
+
raw = repo.git.diff(*opts, "--no-index", "--", os.devnull, path,
|
|
372
|
+
with_exceptions=False, stdout_as_string=False)
|
|
373
|
+
elif staged:
|
|
374
|
+
raw = _run(repo.git.diff, *opts, "--cached", "--", path, stdout_as_string=False)
|
|
375
|
+
else:
|
|
376
|
+
raw = _run(repo.git.diff, *opts, "--", path, stdout_as_string=False)
|
|
377
|
+
return parse_diff(raw.decode("utf-8", errors="replace"), path, staged)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
_HUNK = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def parse_diff(text: str, path: str, staged: bool) -> FileDiff:
|
|
384
|
+
result = FileDiff(path=path, staged=staged)
|
|
385
|
+
old = new = 0
|
|
386
|
+
in_hunk = False
|
|
387
|
+
for line in text.splitlines():
|
|
388
|
+
if len(result.lines) >= MAX_DIFF_LINES:
|
|
389
|
+
result.truncated = True
|
|
390
|
+
break
|
|
391
|
+
m = _HUNK.match(line)
|
|
392
|
+
if m:
|
|
393
|
+
old, new = int(m.group(1)), int(m.group(2))
|
|
394
|
+
in_hunk = True
|
|
395
|
+
result.lines.append(DiffLine("hunk", line))
|
|
396
|
+
elif not in_hunk:
|
|
397
|
+
if line.startswith("Binary files") or line == "GIT binary patch":
|
|
398
|
+
result.binary = True
|
|
399
|
+
# File headers (diff --git, index, ---/+++) are not shown.
|
|
400
|
+
elif line.startswith("+"):
|
|
401
|
+
result.lines.append(DiffLine("add", line[1:], new=new))
|
|
402
|
+
new += 1
|
|
403
|
+
elif line.startswith("-"):
|
|
404
|
+
result.lines.append(DiffLine("del", line[1:], old=old))
|
|
405
|
+
old += 1
|
|
406
|
+
elif line.startswith(" ") or line == "":
|
|
407
|
+
result.lines.append(DiffLine("ctx", line[1:], old=old, new=new))
|
|
408
|
+
old += 1
|
|
409
|
+
new += 1
|
|
410
|
+
elif line.startswith("\\"):
|
|
411
|
+
result.lines.append(DiffLine("meta", line[2:]))
|
|
412
|
+
else: # next file header (should not happen for a single path)
|
|
413
|
+
in_hunk = False
|
|
414
|
+
if line.startswith("Binary files"):
|
|
415
|
+
result.binary = True
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# --- Sync (milestone 3) ---------------------------------------------------
|
|
420
|
+
|
|
421
|
+
NETWORK_TIMEOUT = 120 # seconds
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _network_kwargs(repo: git.Repo) -> dict:
|
|
425
|
+
"""Extra options for commands that talk to a remote.
|
|
426
|
+
|
|
427
|
+
SSH is put in batch mode so it fails instead of waiting for a passphrase
|
|
428
|
+
or a host-key question, unless the user configured SSH themselves.
|
|
429
|
+
"""
|
|
430
|
+
env = {}
|
|
431
|
+
user_ssh = os.environ.get("GIT_SSH_COMMAND") or os.environ.get("GIT_SSH")
|
|
432
|
+
if not user_ssh:
|
|
433
|
+
user_ssh = _config(repo, "core.sshCommand")
|
|
434
|
+
if not user_ssh:
|
|
435
|
+
env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"
|
|
436
|
+
kwargs = {"env": env}
|
|
437
|
+
if os.name != "nt": # GitPython can't stop a stuck command on Windows
|
|
438
|
+
kwargs["kill_after_timeout"] = NETWORK_TIMEOUT
|
|
439
|
+
return kwargs
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _config(repo: git.Repo, key: str) -> str | None:
|
|
443
|
+
"""Read a Git setting the way Git does (all levels, includes); None if unset."""
|
|
444
|
+
code, out, _ = repo.git.config("--get", key, with_extended_output=True, with_exceptions=False)
|
|
445
|
+
return out.strip() if code == 0 else None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _current_branch(repo: git.Repo) -> str:
|
|
449
|
+
if repo.head.is_detached:
|
|
450
|
+
raise GitError("You are not on a branch (detached HEAD). Switch to a branch first.")
|
|
451
|
+
return repo.active_branch.name
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _upstream(repo: git.Repo, branch: str) -> str | None:
|
|
455
|
+
code, out, _ = repo.git.rev_parse(
|
|
456
|
+
"--abbrev-ref", "--symbolic-full-name", f"{branch}@{{upstream}}",
|
|
457
|
+
with_extended_output=True, with_exceptions=False,
|
|
458
|
+
)
|
|
459
|
+
return out.strip() if code == 0 and out.strip() else None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _head_sha(repo: git.Repo) -> str | None:
|
|
463
|
+
return repo.head.commit.hexsha if _has_head(repo) else None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _require_remote(repo: git.Repo) -> None:
|
|
467
|
+
if not repo.remotes:
|
|
468
|
+
raise GitError("This repository has no remote. Add one with "
|
|
469
|
+
"`git remote add origin <url>`.")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def fetch(repo: git.Repo) -> str:
|
|
473
|
+
_require_remote(repo)
|
|
474
|
+
_run(repo.git.fetch, "--all", "--prune", **_network_kwargs(repo))
|
|
475
|
+
return "Fetched from all remotes."
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def pull(repo: git.Repo) -> str:
|
|
479
|
+
"""Pull the current branch from its upstream.
|
|
480
|
+
|
|
481
|
+
If the user has set ``pull.rebase`` or ``pull.ff`` we respect it;
|
|
482
|
+
otherwise we fast-forward when possible and merge when the branches
|
|
483
|
+
have diverged (like GitKraken's default).
|
|
484
|
+
"""
|
|
485
|
+
_require_remote(repo)
|
|
486
|
+
branch = _current_branch(repo)
|
|
487
|
+
upstream = _upstream(repo, branch)
|
|
488
|
+
if upstream is None:
|
|
489
|
+
raise GitError(f"Branch '{branch}' has no upstream yet, so there is nothing to pull. "
|
|
490
|
+
"Push it first to create it on the remote.")
|
|
491
|
+
configured = _config(repo, "pull.rebase") is not None or _config(repo, "pull.ff") is not None
|
|
492
|
+
args = [] if configured else ["--no-rebase"]
|
|
493
|
+
before = _head_sha(repo)
|
|
494
|
+
_run(repo.git.pull, *args, **_network_kwargs(repo))
|
|
495
|
+
after = _head_sha(repo)
|
|
496
|
+
if before == after:
|
|
497
|
+
return f"Already up to date with {upstream}."
|
|
498
|
+
count = int(repo.git.rev_list("--count", f"{before}..{after}")) if before else 0
|
|
499
|
+
return f"Pulled {count} new commit{'s' if count != 1 else ''} from {upstream}."
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def push(repo: git.Repo, force: bool = False) -> str:
|
|
503
|
+
"""Push the current branch. Creates the upstream on first push.
|
|
504
|
+
|
|
505
|
+
``force`` always means ``--force-with-lease`` (never plain ``--force``).
|
|
506
|
+
"""
|
|
507
|
+
_require_remote(repo)
|
|
508
|
+
branch = _current_branch(repo)
|
|
509
|
+
if not _has_head(repo):
|
|
510
|
+
raise GitError("There are no commits to push yet.")
|
|
511
|
+
args = ["--force-with-lease"] if force else []
|
|
512
|
+
upstream = _upstream(repo, branch)
|
|
513
|
+
if upstream is None:
|
|
514
|
+
names = [r.name for r in repo.remotes]
|
|
515
|
+
remote = "origin" if "origin" in names else (names[0] if len(names) == 1 else None)
|
|
516
|
+
if remote is None:
|
|
517
|
+
raise GitError(f"Branch '{branch}' has no upstream and this repo has several "
|
|
518
|
+
"remotes but none called 'origin'. Push once from a terminal "
|
|
519
|
+
f"with `git push -u <remote> {branch}`.")
|
|
520
|
+
args += ["-u", remote, branch]
|
|
521
|
+
_run(repo.git.push, "--porcelain", *args, **_network_kwargs(repo))
|
|
522
|
+
target = _upstream(repo, branch) or branch
|
|
523
|
+
return f"{'Force pushed' if force else 'Pushed'} {branch} to {target}."
|