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/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}."