Files
GitDesk/gitdesk.py
T
2026-11-06 12:44:06 -08:00

4240 lines
179 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
gitdesk - a GitHub Desktop-style git client that runs in your terminal.
Works with the mouse and the keyboard: click the repository / branch /
Fetch-Pull-Push buttons along the top, tick the files you want in a commit,
click a file to see its diff, and use the History tab to browse commits.
python gitdesk.py open the repository in the current folder, or the one
you had open last time
python gitdesk.py PATH open the repository at PATH
New / Add local / Clone repository are in the repository picker (top-left).
Needs Python 3.9+, git 2.25+ and the "textual" package (pip install textual).
Runs in Windows Terminal / PowerShell / cmd and in Linux / macOS terminals.
"""
from __future__ import annotations
import asyncio
import json
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Set, Tuple
IS_WIN = os.name == "nt"
def _pick_folder_helper(args: List[str]) -> int:
"""`gitdesk --pick-folder TITLE START`: show the system folder picker, print the choice.
Runs as its own short-lived process so the GUI dialog can't interfere with the
terminal UI. Exit codes: 0 = chosen (path on stdout), 1 = cancelled, 3 = unavailable.
"""
title = args[0] if args else "Choose a folder"
start = args[1] if len(args) > 1 else ""
try:
import tkinter as tk
from tkinter import filedialog
except ImportError:
return 3
try:
if IS_WIN:
try: # crisp (not blurry) dialog on high-DPI screens
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
pass
root = tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True) # don't open behind the terminal window
except Exception:
pass
root.update()
path = filedialog.askdirectory(parent=root, title=title, initialdir=start or None, mustexist=True)
root.destroy()
except Exception:
return 3
if not path:
return 1
sys.stdout.buffer.write(os.path.normpath(path).encode("utf-8"))
sys.stdout.flush()
return 0
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--pick-folder":
sys.exit(_pick_folder_helper(sys.argv[2:]))
def _ensure_textual() -> None:
"""Offer to install Textual if it's missing, then restart."""
try:
import textual # noqa: F401
return
except ImportError:
if getattr(sys, "frozen", False): # a built executable can't pip-install anything
raise
print("gitdesk needs the Python package 'textual'.")
try:
answer = input("Install it now with pip? [Y/n] ").strip().lower()
except EOFError:
answer = "n"
if answer not in ("", "y", "yes"):
print("Install it with: python -m pip install textual")
sys.exit(1)
cmd = [sys.executable, "-m", "pip", "install", "textual"]
if sys.prefix == sys.base_prefix: # not inside a virtualenv
cmd.insert(4, "--user")
if subprocess.call(cmd) != 0:
print("\npip couldn't install it. Try: python -m pip install textual")
print("(Some Linux distros need a virtualenv or pipx for this.)")
sys.exit(1)
sys.exit(subprocess.call([sys.executable, *sys.argv])) # restart with textual available
_ensure_textual()
from rich.console import Group # noqa: E402
from rich.table import Table # noqa: E402
from rich.text import Text # noqa: E402
from textual import events, on # noqa: E402
from textual.app import App, ComposeResult, SuspendNotSupported # noqa: E402
from textual.binding import Binding # noqa: E402
from textual.css.query import NoMatches # noqa: E402
from textual.containers import Horizontal, Vertical, VerticalScroll # noqa: E402
from textual.message import Message # noqa: E402
from textual.reactive import reactive # noqa: E402
from textual.screen import ModalScreen # noqa: E402
from textual.theme import Theme # noqa: E402
from textual.widgets import ( # noqa: E402
Button, Checkbox, ContentSwitcher, DirectoryTree, Footer, Input, Label, ListItem, ListView,
OptionList, RichLog, Select, Static, TabbedContent, TabPane, TextArea, Tree,
)
from textual.widgets.option_list import Option # noqa: E402
# --------------------------------------------------------------------------
# Colours (GitHub Desktop dark-ish)
# --------------------------------------------------------------------------
C_MUTED = "#959da5"
C_DIM = "#6a737d"
C_TEXT = "#e1e4e8"
C_BLUE = "#79b8ff"
C_GREEN = "#34d058"
C_RED = "#ea4a5a"
C_YELLOW = "#dbab09"
C_ORANGE = "#f66a0a"
KIND_ICON = {
"M": ("●", C_YELLOW, "modified"),
"A": ("+", C_GREEN, "added"),
"?": ("+", C_GREEN, "new file"),
"D": ("−", C_RED, "deleted"),
"R": ("→", C_BLUE, "renamed"),
"U": ("!", C_ORANGE, "conflict"),
}
MAX_FILES_SHOWN = 1000
MAX_DIFF_ROWS = 4000
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
# Background git commands must never sit waiting for a password prompt;
# if one is needed the user gets a "Retry in terminal" button instead.
GIT_ENV = dict(os.environ)
GIT_ENV.update({
"GIT_TERMINAL_PROMPT": "0",
"GIT_MERGE_AUTOEDIT": "no",
"GIT_OPTIONAL_LOCKS": "0", # status refreshes don't grab the index lock
})
# --------------------------------------------------------------------------
# Git layer
# --------------------------------------------------------------------------
@dataclass
class Result:
code: int
out: str = ""
err: str = ""
lockish: bool = False # failed because another program had the repository locked
handled: bool = False # the user has already been shown this failure
@property
def ok(self) -> bool:
return self.code == 0
@property
def message(self) -> str:
return self.err.strip() or self.out.strip() or f"git exited with code {self.code}"
# Failures that mean "the repository was busy" rather than "this can't be done".
LOCKISH_RE = re.compile(
r"index\.lock|could not write index|unable to write (new )?index|unable to create '[^']*\.lock'"
r"|cannot lock ref|resource busy|permission denied|file exists",
re.I)
# git's normal "no, because..." answers - never retried as if the repo were busy.
NOT_LOCK = ("CONFLICT", "would be overwritten", "already exists", "needs merge", "did not match",
"not a valid", "unknown revision", "nothing to commit", "no changes added",
"Please tell me who you are", "hook", "Filename too long")
STASH_KEPT_RE = re.compile(r"The stash entry is kept in case you need it again\.?")
def looks_lockish(r: Result) -> bool:
text = f"{r.err}\n{r.out}"
if any(s in text for s in NOT_LOCK):
return False
residue = STASH_KEPT_RE.sub("", text).strip()
return not residue or bool(LOCKISH_RE.search(residue))
TOO_LARGE_RE = re.compile(r"HTTP[/ \d.]*\s413\b|error: 413\b|status(?: code)?:? 413\b|\b413 Request"
r"|Request Entity Too Large|Payload Too Large|Content Too Large", re.I)
def too_large(r: Result) -> bool:
"""The server (or a proxy like Cloudflare) refused an HTTP upload as too big."""
return bool(TOO_LARGE_RE.search(f"{r.err}\n{r.out}"))
def ssh_url_for(http_url: str) -> str:
"""'https://git.example.net/Owner/Repo.git' -> '[email protected]:Owner/Repo.git'."""
m = re.match(r"^https?://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/*$", http_url.strip())
return f"git@{m.group(1)}:{m.group(2)}.git" if m else ""
def user_ssh_command_set(path: str) -> bool:
if os.environ.get("GIT_SSH_COMMAND") or os.environ.get("GIT_SSH"):
return True
return run_git(path, ["config", "--get", "core.sshCommand"]).ok
def hint_for(message: str) -> str:
"""Plain-English next step for common git errors."""
if "Filename too long" in message:
return ("Windows limits paths to 260 characters unless git is told otherwise. Run this once, "
"then try again:\n git config --global core.longpaths true")
if "Please tell me who you are" in message:
return ("git needs your name and email for commits. Run these once:\n"
' git config --global user.name "Your Name"\n'
" git config --global user.email [email protected]")
if "Permission denied" in message or "Invalid argument" in message:
return ("A file is probably open in another program (Unity, an editor, a sync tool like OneDrive). "
"Close it and try again.")
return ""
# ---- finding / installing git (runs before the UI starts, as plain prompts) ----
GIT_DOWNLOAD_WIN = "https://git-scm.com/download/win"
def _set_path(new_path: str) -> None:
os.environ["PATH"] = new_path
GIT_ENV["PATH"] = new_path
def refresh_windows_path() -> None:
"""Re-read PATH from the registry, so a git installed a moment ago is found without a restart."""
try:
import winreg
except ImportError:
return
parts = []
for root, key in ((winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
(winreg.HKEY_CURRENT_USER, "Environment")):
try:
with winreg.OpenKey(root, key) as k:
parts.append(os.path.expandvars(winreg.QueryValueEx(k, "Path")[0]))
except OSError:
pass
if parts:
_set_path(os.pathsep.join(parts + [os.environ.get("PATH", "")]))
def find_git() -> Optional[str]:
"""Path of git, also looking where Git for Windows installs if PATH hasn't caught up yet."""
found = shutil.which("git")
if found or not IS_WIN:
return found
refresh_windows_path()
found = shutil.which("git")
if found:
return found
for base in (os.environ.get("ProgramW6432"), os.environ.get("ProgramFiles"),
os.environ.get("ProgramFiles(x86)"), os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs")):
cand = os.path.join(base or "", "Git", "cmd", "git.exe")
if base and os.path.exists(cand):
_set_path(os.path.dirname(cand) + os.pathsep + os.environ.get("PATH", ""))
return cand
return None
def git_install_plan(update: bool = False) -> Tuple[Optional[List[List[str]]], str]:
"""(commands that install git here, a note) - or (None, how to do it by hand)."""
if IS_WIN:
if shutil.which("winget"):
return ([["winget", "upgrade" if update else "install", "--id", "Git.Git", "-e", "--source", "winget",
"--accept-package-agreements", "--accept-source-agreements"]],
"Windows will ask for permission to install it (Git for Windows, which includes Git LFS).")
return None, (f"Download and run the installer from {GIT_DOWNLOAD_WIN} (the default options are fine), "
"then start gitdesk again.")
if sys.platform == "darwin":
if shutil.which("brew"):
return [["brew", "upgrade" if update else "install", "git", "git-lfs"]], ""
return [["xcode-select", "--install"]], "macOS will open a window to install its command line tools, which include git."
if os.geteuid() == 0:
sudo: Optional[List[str]] = []
elif shutil.which("sudo"):
sudo = ["sudo"]
elif shutil.which("doas"):
sudo = ["doas"]
else:
sudo = None
managers = [
("apt-get", [["apt-get", "update"], ["apt-get", "install", "-y", "git", "git-lfs"]]),
("dnf", [["dnf", "install", "-y", "git", "git-lfs"]]),
("yum", [["yum", "install", "-y", "git", "git-lfs"]]),
("pacman", [["pacman", "-S", "--needed", "--noconfirm", "git", "git-lfs"]]),
("zypper", [["zypper", "--non-interactive", "install", "git", "git-lfs"]]),
("apk", [["apk", "add", "git", "git-lfs"]]),
]
for exe, cmds in managers:
if shutil.which(exe):
if sudo is None:
return None, f"Run this as root, then start gitdesk again:\n {' '.join(cmds[-1])}"
return [sudo + c for c in cmds], ("You may be asked for your password." if sudo else "")
return None, "Install git and git-lfs with your distribution's package manager, then start gitdesk again."
def _ask_yes(prompt: str) -> bool:
try:
return input(prompt).strip().lower() in ("", "y", "yes")
except EOFError:
return False
def _pause_before_exit() -> None:
if IS_WIN: # a double-clicked window would close before you could read it
try:
input("\nPress Enter to close...")
except EOFError:
pass
def offer_git_install(update: bool = False) -> bool:
"""Ask to install (or update) git. Returns True once a usable git is available."""
plan, note = git_install_plan(update)
if update:
print("\ngitdesk needs git 2.25 or newer, and this one is older.")
else:
print("\ngitdesk needs git, and it isn't installed (or isn't on your PATH).")
if plan is None:
print(note)
if IS_WIN and _ask_yes("Open the download page now? [Y/n] "):
try:
os.startfile(GIT_DOWNLOAD_WIN) # type: ignore[attr-defined]
except OSError:
pass
_pause_before_exit()
return False
print(("Update" if update else "Install") + " it now? This runs:")
for cmd in plan:
print(" " + " ".join(cmd))
if note:
print(note)
if not _ask_yes("[Y/n] "):
print("OK - install git yourself, then start gitdesk again.")
_pause_before_exit()
return False
for cmd in plan:
try:
rc = subprocess.call(cmd)
except OSError as e:
print(f"Couldn't run {cmd[0]}: {e}")
rc = 1
if rc != 0 and "git-lfs" in cmd:
print("\nThat didn't work with Git LFS included - trying just git...")
rc = subprocess.call([c for c in cmd if c != "git-lfs"])
if rc != 0 and "update" in cmd:
print("(Updating the package list failed - trying the install anyway.)")
if find_git() is None or (update and git_version() < (2, 25)):
if IS_WIN or sys.platform == "darwin":
print("\ngit may still be installing, or this window can't see it yet. "
"When it's done, close this window and start gitdesk again.")
else:
print("\ngit still isn't available - see the messages above.")
_pause_before_exit()
return False
home = os.path.expanduser("~")
if run_git(home, ["lfs", "version"]).ok:
run_git(home, ["lfs", "install", "--skip-repo"]) # set up Git LFS for this user
print(f"\nReady: {run_git(home, ['--version']).out.strip()}\n")
return True
def git_version() -> Tuple[int, ...]:
r = run_git(os.getcwd(), ["--version"])
m = re.search(r"(\d+)\.(\d+)", r.out)
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
@dataclass
class FileChange:
path: str
kind: str # M A D R U ? (see KIND_ICON)
orig: Optional[str] = None # old path for renames
@property
def is_conflict(self) -> bool:
return self.kind == "U"
@dataclass
class Commit:
sha: str
short: str
subject: str
author: str
when: str
@dataclass
class RepoState:
branch: Optional[str] = None # None = detached HEAD
oid: Optional[str] = None # None = no commits yet
upstream: Optional[str] = None # e.g. "origin/main"
upstream_gone: bool = False
ahead: int = 0
behind: int = 0
files: List[FileChange] = field(default_factory=list)
merging: bool = False
rebasing: bool = False
stashes: int = 0
remotes: List[str] = field(default_factory=list)
remote_urls: dict = field(default_factory=dict) # name -> fetch URL
@property
def conflicts(self) -> List[FileChange]:
return [f for f in self.files if f.is_conflict]
@property
def has_upstream(self) -> bool:
return bool(self.upstream) and not self.upstream_gone
class GitError(Exception):
pass
def run_git(cwd: str, args, input: Optional[str] = None, env=None) -> Result:
cmd = ["git", "-c", "core.quotepath=false", "-c", "color.ui=false", *args]
kw = dict(cwd=cwd, capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env or GIT_ENV)
if input is None:
kw["stdin"] = subprocess.DEVNULL
else:
kw["input"] = input
# Detach from the terminal so ssh/git can't grab the keyboard behind the UI.
if IS_WIN:
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
else:
kw["start_new_session"] = True
try:
p = subprocess.run(cmd, **kw)
except FileNotFoundError:
return Result(127, "", "git was not found - is it installed and in your PATH?")
except OSError as e:
return Result(1, "", str(e))
return Result(p.returncode, p.stdout or "", p.stderr or "")
def _kind(xy: str, entry: str) -> str:
if entry == "2":
return "R"
if "D" in xy:
return "D"
if "A" in xy:
return "A"
return "M"
def parse_status(out: str, st: RepoState) -> None:
"""Parse `git status --porcelain=v2 --branch -z` output into st."""
fields = out.split("\0")
i = 0
while i < len(fields):
f = fields[i]
i += 1
if not f:
continue
t = f[0]
if t == "#":
key, _, val = f[2:].partition(" ")
if key == "branch.oid":
st.oid = None if val == "(initial)" else val
elif key == "branch.head":
st.branch = None if val == "(detached)" else val
elif key == "branch.upstream":
st.upstream = val
st.upstream_gone = True # until branch.ab shows up
elif key == "branch.ab":
st.upstream_gone = False
a, b = val.split()
st.ahead, st.behind = int(a.lstrip("+")), int(b.lstrip("-"))
elif t == "1":
p = f.split(" ", 8)
st.files.append(FileChange(p[8], _kind(p[1], "1")))
elif t == "2":
p = f.split(" ", 9)
orig = fields[i] if i < len(fields) else None
i += 1
st.files.append(FileChange(p[9], "R", orig))
elif t == "u":
p = f.split(" ", 10)
st.files.append(FileChange(p[10], "U"))
elif t == "?":
st.files.append(FileChange(f[2:], "?"))
st.files.sort(key=lambda fc: fc.path.lower())
class Git:
def __init__(self, path: str, git_dir: str, common_dir: str, log: Callable):
self.path = path
self.git_dir = git_dir
self.common_dir = common_dir
self._log = log
# Only one git process at a time from gitdesk, so the background refresh can
# never be holding the repository while an action (stash, commit...) runs.
self.lock = threading.RLock()
@classmethod
def open(cls, path: str, log: Callable) -> Optional["Git"]:
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(path):
return None
r = run_git(path, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"])
lines = r.out.splitlines()
if not r.ok or len(lines) < 3:
return None
top, git_dir, common = lines[0], lines[1], lines[2]
if not os.path.isabs(common):
common = os.path.normpath(os.path.join(path, common))
return cls(os.path.normpath(top), git_dir, common, log)
def run(self, *args, input: Optional[str] = None, log: bool = True, serialize: bool = True) -> Result:
if serialize:
with self.lock:
r = run_git(self.path, list(args), input=input)
else:
r = run_git(self.path, list(args), input=input)
if log:
self._log(list(args), r)
return r
def snapshot(self) -> tuple:
"""Everything a failed command could have changed - used to decide if a retry is safe."""
r = self.run("status", "--porcelain=v2", "--branch", "-z", "-uall", log=False)
return (r.out, self.stash_count(), os.path.exists(os.path.join(self.git_dir, "MERGE_HEAD")))
def index_lock_path(self) -> str:
return os.path.join(self.git_dir, "index.lock")
# ---- state -------------------------------------------------------------
def state(self) -> RepoState:
r = self.run("status", "--porcelain=v2", "--branch", "-z", "-uall", log=False)
if not r.ok:
raise GitError(r.message)
st = RepoState()
parse_status(r.out, st)
st.merging = os.path.exists(os.path.join(self.git_dir, "MERGE_HEAD"))
st.rebasing = any(os.path.isdir(os.path.join(self.git_dir, d))
for d in ("rebase-merge", "rebase-apply"))
st.stashes = self.stash_count()
for line in self.run("remote", "-v", log=False).out.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] not in st.remote_urls:
st.remotes.append(parts[0])
st.remote_urls[parts[0]] = parts[1]
return st
def stash_count(self) -> int:
try:
with open(os.path.join(self.common_dir, "logs", "refs", "stash"), "rb") as fh:
return sum(1 for line in fh if line.strip())
except OSError:
return 0
def last_fetch_time(self) -> Optional[float]:
for d in (self.git_dir, self.common_dir):
try:
return os.path.getmtime(os.path.join(d, "FETCH_HEAD"))
except OSError:
pass
return None
def merge_message(self) -> str:
try:
with open(os.path.join(self.git_dir, "MERGE_MSG"), encoding="utf-8", errors="replace") as fh:
return fh.readline().strip()
except OSError:
return ""
# ---- diffs -------------------------------------------------------------
def file_diff(self, fc: FileChange, has_head: bool) -> str:
if fc.kind == "?":
return self._untracked_diff(fc.path)
if fc.kind == "D" and has_head and os.path.exists(os.path.join(self.path, fc.path)):
# un-tracked but still on disk: show what leaves the repository
r = self.run("diff", "--cached", "--no-ext-diff", "HEAD", "--", fc.path, log=False)
return r.out if r.ok else r.message
args = ["diff", "HEAD" if has_head else EMPTY_TREE, "--no-ext-diff", "-M", "--"]
if fc.orig:
args.append(fc.orig)
args.append(fc.path)
r = self.run(*args, log=False)
return r.out if r.ok else r.message
def _untracked_diff(self, path: str) -> str:
try:
with open(os.path.join(self.path, path), "rb") as fh:
data = fh.read(512_000)
except OSError as e:
return f"Can't read this file: {e}"
if b"\0" in data[:8000]:
return f"diff --git a/{path} b/{path}\nBinary files /dev/null and b/{path} differ"
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()
out = [f"diff --git a/{path} b/{path}", f"@@ -0,0 +1,{len(lines)} @@"]
out += ["+" + line for line in lines]
if text and not text.endswith(("\n", "\r")):
out.append("\\ No newline at end of file")
return "\n".join(out)
def changed_between(self, a: str, b: str) -> Set[str]:
r = self.run("diff", "--name-only", "-z", f"{a}...{b}", log=False)
return {p for p in r.out.split("\0") if p}
def commit_header(self, sha: str) -> str:
return self.run("show", "-s", "--date=format:%Y-%m-%d %H:%M",
"--format=%H%n%an <%ae>%n%ad%n%B", sha, log=False).out
def commit_diff(self, sha: str) -> str:
return self.run("show", "--format=", "-p", "-M", "--first-parent",
"--no-ext-diff", sha, log=False).out
# ---- history / branches / stashes -------------------------------------
def history(self, has_upstream: bool, limit: int = 300) -> Tuple[List[Commit], Set[str]]:
r = self.run("log", f"-n{limit}", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%ar", log=False)
commits = []
for line in r.out.splitlines():
parts = line.split("\x1f")
if len(parts) == 5:
commits.append(Commit(*parts))
unpushed: Set[str] = set()
if has_upstream:
unpushed = set(self.run("rev-list", "@{u}..HEAD", log=False).out.split())
return commits, unpushed
def local_branches(self) -> List[Tuple[str, str]]:
r = self.run("for-each-ref", "--sort=-committerdate",
"--format=%(refname:short)%1f%(committerdate:relative)", "refs/heads", log=False)
return [tuple(line.split("\x1f", 1)) for line in r.out.splitlines() if "\x1f" in line]
def remote_branches(self, remote: str) -> List[Tuple[str, str]]:
r = self.run("for-each-ref", "--sort=-committerdate",
"--format=%(refname:short)%1f%(committerdate:relative)",
f"refs/remotes/{remote}", log=False)
out = []
for line in r.out.splitlines():
if "\x1f" not in line:
continue
name, when = line.split("\x1f", 1)
if name in (remote, f"{remote}/HEAD"):
continue
out.append((name, when))
return out
def stash_list(self) -> List[Tuple[str, str, str]]:
r = self.run("stash", "list", "--format=%gd%x1f%gs%x1f%cr", log=False)
return [tuple(line.split("\x1f", 2)) for line in r.out.splitlines() if line.count("\x1f") >= 2]
def stash_preview(self, ref: str) -> str:
r = self.run("stash", "show", "--stat", "--include-untracked", ref, log=False)
if not r.ok:
r = self.run("stash", "show", "--stat", ref, log=False)
return r.out.rstrip() or r.message
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def config_dir() -> str:
if IS_WIN:
base = os.environ.get("APPDATA") or os.path.expanduser("~")
else:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return os.path.join(base, "gitdesk")
def _settings_file() -> str:
return os.path.join(config_dir(), "recent.json")
def load_settings() -> dict:
try:
with open(_settings_file(), encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def save_settings(data: dict) -> None:
try:
os.makedirs(config_dir(), exist_ok=True)
tmp = _settings_file() + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=1)
os.replace(tmp, _settings_file())
except OSError:
pass
def load_recent() -> List[str]:
return [p for p in load_settings().get("recent", []) if isinstance(p, str)]
def save_recent(path: str) -> None:
"""Remember a repository; the first one in the list is reopened next launch."""
norm = os.path.normcase(path)
data = load_settings()
data["recent"] = ([path] + [p for p in load_recent() if os.path.normcase(p) != norm])[:15]
save_settings(data)
def default_parent_folder() -> str:
"""Where new / cloned repositories go by default (like GitHub Desktop's Documents\\GitHub)."""
saved = load_settings().get("parent")
if isinstance(saved, str) and os.path.isdir(saved):
return saved
home = os.path.expanduser("~")
for cand in (os.path.join(home, "Documents", "GitHub"), os.path.join(home, "Documents")):
if os.path.isdir(cand):
return cand
return home
def remember_parent_folder(folder: str) -> None:
data = load_settings()
data["parent"] = folder
save_settings(data)
def repo_name_from_url(url: str) -> str:
"""'git@host:owner/My-Repo.git' -> 'My-Repo'."""
tail = re.split(r"[/:\\]", url.strip().rstrip("/\\"))[-1] if url.strip() else ""
return re.sub(r"\.git$", "", tail)
BAD_NAME_RE = re.compile(r'[\\/:*?"<>|]')
GITIGNORE_OS = """
# OS / editor clutter
.DS_Store
Thumbs.db
desktop.ini
"""
GITIGNORE_TEMPLATES = {
"Unity": """# Unity generated folders
/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/[Bb]uild/
/[Bb]uilds/
/[Ll]ogs/
/[Uu]ser[Ss]ettings/
/[Mm]emoryCaptures/
/[Rr]ecordings/
# Asset meta data should only be ignored when the corresponding asset is also ignored
!/[Aa]ssets/**/*.meta
# Uncomment this line if you wish to ignore the asset store tools plugin
# /[Aa]ssets/AssetStoreTools*
# Autogenerated Jetbrains Rider plugin
/[Aa]ssets/Plugins/Editor/JetBrains*
# Visual Studio / Rider cache directories
.vs/
.idea/
.gradle/
# Autogenerated VS/MD/Consulo solution and project files
ExportedObj/
.consulo/
*.csproj
*.unityproj
*.sln
*.suo
*.tmp
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
*.mdb.meta
# Unity3D generated file on crash reports
sysinfo.txt
# Builds
*.apk
*.aab
*.unitypackage
*.unitypackage.meta
*.app
# Crashlytics generated file
crashlytics-build.properties
# Packed Addressables
/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin*
# Temporary auto-generated Android Assets
/[Aa]ssets/[Ss]treamingAssets/aa.meta
/[Aa]ssets/[Ss]treamingAssets/aa/*
""",
"Python": """# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
env/
.env
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
""",
"Node": """# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
dist/
build/
coverage/
.env
.env.local
.cache/
""",
"Visual Studio / .NET": """# Visual Studio / .NET
.vs/
[Bb]in/
[Oo]bj/
[Dd]ebug/
[Rr]elease/
x64/
x86/
*.user
*.suo
*.userosscache
*.sln.docstates
packages/
TestResults/
*.log
""",
}
MIT_LICENSE = """MIT License
Copyright (c) {year} {name}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
PROGRESS_RE = re.compile(r"^(?:remote: )?(Enumerating|Counting|Compressing|Receiving|Resolving|"
r"Updating files|Filtering content|Checking out|Total|Downloading)", re.I)
def run_git_streaming(cwd: str, args: List[str], on_progress: Callable[[str], None]) -> Result:
"""Run a long git command (clone), passing each progress line to on_progress."""
kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WIN else {"start_new_session": True}
try:
p = subprocess.Popen(["git", *args], cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=GIT_ENV, **kw)
except OSError as e:
return Result(1, "", str(e))
out: List[bytes] = []
reader = threading.Thread(target=lambda: out.append(p.stdout.read()), daemon=True)
reader.start()
messages: List[str] = []
buf = b""
while True:
chunk = p.stderr.read1(4096)
if not chunk:
break
buf += chunk
*lines, buf = re.split(rb"[\r\n]", buf)
for raw in lines:
line = raw.decode("utf-8", "replace").strip()
if not line:
continue
if PROGRESS_RE.match(line):
on_progress(line)
elif line not in messages:
messages.append(line)
if buf.strip():
messages.append(buf.decode("utf-8", "replace").strip())
p.wait()
reader.join(timeout=5)
return Result(p.returncode, (out[0] if out else b"").decode("utf-8", "replace"), "\n".join(messages))
def ago(ts: Optional[float]) -> str:
if not ts:
return "never"
s = max(0, time.time() - ts)
if s < 60:
return "just now"
for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)):
if s >= size:
n = int(s // size)
return f"{n} {unit}{'s' if n != 1 else ''} ago"
return "just now"
def plural(n: int, word: str) -> str:
return f"{n} {word}{'' if n == 1 else 's'}"
MARKER_RE = re.compile(r"^(<{7}|>{7})( |$)", re.M)
def file_has_markers(path: str) -> bool:
try:
with open(path, encoding="utf-8", errors="replace") as fh:
return bool(MARKER_RE.search(fh.read()))
except OSError:
return False
def editor_name() -> str:
if shutil.which("code"):
return "VS Code"
ed = os.environ.get("VISUAL") or os.environ.get("EDITOR")
return os.path.basename(ed.split()[0]) if ed else "your editor"
_SSH_HOSTS: dict = {}
def resolve_ssh_host(host: str) -> str:
"""Turn a Host alias from ~/.ssh/config into the real hostname (cached)."""
if host not in _SSH_HOSTS:
real = ""
if shutil.which("ssh"):
try:
kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WIN else {"start_new_session": True}
p = subprocess.run(["ssh", "-G", host], capture_output=True, text=True, timeout=3,
stdin=subprocess.DEVNULL, **kw)
for line in p.stdout.splitlines():
if line.lower().startswith("hostname "):
real = line.split(None, 1)[1].strip()
break
except (OSError, subprocess.SubprocessError):
pass
_SSH_HOSTS[host] = real or host
return _SSH_HOSTS[host]
def web_url_for(remote_url: str) -> Optional[str]:
"""The repository's web page for a remote URL (GitHub, Gitea, GitLab...), or None."""
u = remote_url.strip()
m = re.match(r"^(https?)://(?:[^@/]+@)?([^/]+)/(.+?)(?:\.git)?/*$", u)
if m: # https://[user:token@]host[:port]/owner/repo.git
return f"{m.group(1)}://{m.group(2)}/{m.group(3)}"
m = re.match(r"^(?:ssh|git\+ssh|ssh\+git|git)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/*$", u)
if not m and "://" not in u: # git@host:owner/repo.git
m = re.match(r"^(?:[^@/:]+@)?([^/:]{2,}):/*(.+?)(?:\.git)?/*$", u)
if not m:
return None # a local path - no web page
return f"https://{resolve_ssh_host(m.group(1))}/{m.group(2).lstrip('/')}"
def web_site_name(web_url: str) -> str:
host = re.sub(r"^https?://", "", web_url).split("/")[0].split(":")[0].lower()
if host == "github.com" or host.endswith(".github.com"):
return "GitHub"
if "gitlab" in host:
return "GitLab"
if "bitbucket" in host:
return "Bitbucket"
return host
def commit_web_url(web_url: str, sha: str) -> str:
host = web_url.lower()
if "gitlab" in host:
return f"{web_url}/-/commit/{sha}"
if "bitbucket" in host:
return f"{web_url}/commits/{sha}"
return f"{web_url}/commit/{sha}" # GitHub, Gitea, Forgejo, Gogs
def _helper_command(title: str, start: str) -> List[str]:
if getattr(sys, "frozen", False):
return [sys.executable, "--pick-folder", title, start]
return [sys.executable, os.path.abspath(__file__), "--pick-folder", title, start]
def _powershell_pick(title: str, start: str) -> Tuple[str, Optional[str]]:
def q(text: str) -> str:
return "'" + text.replace("'", "''") + "'"
ps = ("Add-Type -AssemblyName System.Windows.Forms;"
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;"
f"$f.Description = {q(title)}; $f.SelectedPath = {q(start)}; $f.ShowNewFolderButton = $true;"
"$o = New-Object System.Windows.Forms.Form -Property @{TopMost = $true};"
"if ($f.ShowDialog($o) -eq [System.Windows.Forms.DialogResult]::OK) {"
" try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}; $f.SelectedPath }")
p = subprocess.run(["powershell", "-NoProfile", "-STA", "-Command", ps], capture_output=True,
stdin=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
out = p.stdout.decode("utf-8", "replace").strip()
if p.returncode == 0:
return ("ok", out) if out else ("cancel", None)
return "unavailable", None
def pick_folder_native(title: str, start: str) -> Tuple[str, Optional[str]]:
"""Show the operating system's folder picker (blocks until it closes).
Returns ("ok", path), ("cancel", None), or ("unavailable", None) when there's no
desktop to show one on - then gitdesk shows its own in-terminal folder browser.
"""
try:
if sys.platform == "darwin":
def aq(text: str) -> str:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
script = f"POSIX path of (choose folder with prompt {aq(title)} default location (POSIX file {aq(start)}))"
p = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
if p.returncode == 0 and p.stdout.strip():
return "ok", p.stdout.strip().rstrip("/") or "/"
return ("cancel", None) if "-128" in p.stderr else ("unavailable", None)
if not IS_WIN:
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return "unavailable", None
for cmd in (["zenity", "--file-selection", "--directory", f"--title={title}",
f"--filename={start.rstrip('/')}/"],
["kdialog", "--getexistingdirectory", start, "--title", title]):
if shutil.which(cmd[0]):
p = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL,
start_new_session=True)
if p.returncode == 0 and p.stdout.strip():
return "ok", p.stdout.strip()
if p.returncode == 1:
return "cancel", None
# Windows (and Linux without zenity/kdialog): a helper copy of gitdesk shows the
# native picker through tkinter - on Windows that's the Explorer "Select Folder" window.
kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WIN else {"start_new_session": True}
p = subprocess.run(_helper_command(title, start), capture_output=True, stdin=subprocess.DEVNULL, **kw)
if p.returncode == 0 and p.stdout.strip():
return "ok", p.stdout.decode("utf-8", "replace").strip()
if p.returncode == 1:
return "cancel", None
if IS_WIN:
return _powershell_pick(title, start)
except (OSError, subprocess.SubprocessError):
pass
return "unavailable", None
def existing_folder(path: str) -> str:
"""The nearest folder that exists, walking up from path (falls back to home)."""
path = os.path.abspath(os.path.expanduser(path)) if path.strip() else ""
while path and not os.path.isdir(path):
parent = os.path.dirname(path)
if parent == path:
break
path = parent
return path if path and os.path.isdir(path) else os.path.expanduser("~")
def gitignore_pattern(path: str, folder: bool = False) -> str:
"""A .gitignore line matching exactly this path (from the repo root)."""
p = path.replace("\\", "/").strip("/")
p = re.sub(r"([*?\[])", r"\\\1", p) # glob characters match literally
if p.endswith(" "):
p = p[:-1] + "\\ " # a trailing space would be dropped
return "/" + p + ("/" if folder else "")
def parent_folders(path: str) -> List[str]:
"""'a/b/c/file.txt' -> ['a/b/c', 'a/b', 'a'] (deepest first, like GitHub Desktop)."""
parts = path.replace("\\", "/").split("/")[:-1]
return ["/".join(parts[:i]) for i in range(len(parts), 0, -1)]
def copy_to_system_clipboard(text: str) -> bool:
"""Put text on the OS clipboard. Returns False if there's no way to (caller falls back to OSC 52)."""
try:
if IS_WIN:
import ctypes
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
user32.OpenClipboard.argtypes = [wintypes.HWND]
user32.OpenClipboard.restype = wintypes.BOOL
user32.EmptyClipboard.restype = wintypes.BOOL
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
user32.SetClipboardData.restype = wintypes.HANDLE
user32.CloseClipboard.restype = wintypes.BOOL
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
data = text.encode("utf-16-le") + b"\0\0"
handle = kernel32.GlobalAlloc(0x0002, len(data)) # GMEM_MOVEABLE
ptr = kernel32.GlobalLock(handle)
ctypes.memmove(ptr, data, len(data))
kernel32.GlobalUnlock(handle)
for _ in range(10): # another app may hold it briefly
if user32.OpenClipboard(None):
break
time.sleep(0.05)
else:
return False
try:
user32.EmptyClipboard()
return bool(user32.SetClipboardData(13, handle)) # CF_UNICODETEXT
finally:
user32.CloseClipboard()
if sys.platform == "darwin":
return subprocess.run(["pbcopy"], input=text.encode("utf-8")).returncode == 0
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
return subprocess.run(["wl-copy"], input=text.encode("utf-8")).returncode == 0
if os.environ.get("DISPLAY"):
for cmd in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
if shutil.which(cmd[0]):
return subprocess.run(cmd, input=text.encode("utf-8")).returncode == 0
except (OSError, AttributeError, ValueError):
pass
return False
def file_manager_name() -> str:
return "Explorer" if IS_WIN else ("Finder" if sys.platform == "darwin" else "file manager")
def set_label(button, label: str) -> None:
"""Change a button's label and let it resize to fit."""
if str(button.label) != label:
button.label = label
button.refresh(layout=True)
def path_text(fc: FileChange) -> Text:
t = Text(no_wrap=True, overflow="ellipsis")
d, _, name = fc.path.rpartition("/")
if fc.orig:
t.append(fc.orig + " → ", style=C_DIM)
if d:
t.append(d + "/", style=C_MUTED)
t.append(name)
return t
def render_diff(text: str, multi_file: bool = False):
"""Turn unified diff text into a table with old/new line numbers."""
t = Table.grid(expand=True, padding=(0, 1))
t.add_column(justify="right", width=5, style=C_DIM, no_wrap=True)
t.add_column(justify="right", width=5, style=C_DIM, no_wrap=True)
t.add_column(width=1, no_wrap=True)
t.add_column(ratio=1, overflow="fold")
old = new = 0
in_hunk = False
rows = 0
for raw in text.split("\n"):
line = raw.rstrip("\r")
if rows >= MAX_DIFF_ROWS:
t.add_row("", "", "", Text(f"… diff truncated after {MAX_DIFF_ROWS} lines", style=f"italic {C_MUTED}"))
break
if line.startswith("diff --git "):
in_hunk = False
if multi_file:
name = line.split(" b/", 1)[-1] if " b/" in line else line[11:]
t.add_row("", "", "", Text(name, style=f"bold {C_TEXT}"), style="on #2f363d")
rows += 1
continue
if line.startswith("@@"):
m = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
if m:
old, new = int(m.group(1)), int(m.group(2))
in_hunk = True
t.add_row("", "", "", Text(line, style=C_BLUE), style="on #1c2938")
rows += 1
continue
if not in_hunk:
if line.startswith("Binary files"):
t.add_row("", "", "", Text("Binary file - there's no text diff to show.", style=f"italic {C_MUTED}"))
rows += 1
elif multi_file and line.startswith(("rename from", "rename to", "new file mode", "deleted file mode")):
t.add_row("", "", "", Text(line, style=C_DIM))
rows += 1
continue
if not line:
continue
body = Text(line[1:].expandtabs(4))
if line[0] == "+":
if MARKER_RE.match(line[1:]) or line[1:].startswith("======="):
body.stylize(f"bold {C_ORANGE}")
t.add_row("", str(new), Text("+", style=C_GREEN), body, style="on #12261e")
new += 1
elif line[0] == "-":
t.add_row(str(old), "", Text("-", style=C_RED), body, style="on #3a1d22")
old += 1
elif line[0] == "\\":
t.add_row("", "", "", Text(line[2:], style=f"italic {C_MUTED}"))
else:
t.add_row(str(old), str(new), " ", body)
old += 1
new += 1
rows += 1
if rows == 0:
return Text("No differences to show.", style=f"italic {C_MUTED}")
return t
def create_repository(o: dict) -> Tuple[bool, str]:
"""Create a repo from the New repository dialog's choices.
Returns (created, problem). created=False means nothing usable was made.
"""
path, name, desc = o["path"], o["name"], o.get("description", "")
try:
os.makedirs(path, exist_ok=True)
except OSError as e:
return False, f"Couldn't create the folder: {e}"
branch = run_git(path, ["config", "--get", "init.defaultBranch"]).out.strip() or "main"
r = run_git(path, ["init"])
if not r.ok:
return False, r.message
run_git(path, ["symbolic-ref", "HEAD", f"refs/heads/{branch}"])
wrote = []
try:
def write(fname: str, text: str) -> None:
with open(os.path.join(path, fname), "w", encoding="utf-8", newline="\n") as fh:
fh.write(text)
wrote.append(fname)
if o.get("readme") and not os.path.exists(os.path.join(path, "README.md")):
write("README.md", f"# {name}\n" + (f"\n{desc}\n" if desc else ""))
if o.get("gitignore") in GITIGNORE_TEMPLATES:
ignore_path = os.path.join(path, ".gitignore")
template = GITIGNORE_TEMPLATES[o["gitignore"]] + GITIGNORE_OS
if os.path.exists(ignore_path): # existing folder: add to what's there
with open(ignore_path, "a", encoding="utf-8", newline="\n") as fh:
fh.write("\n" + template)
wrote.append(".gitignore")
else:
write(".gitignore", template)
if o.get("license") == "MIT" and not os.path.exists(os.path.join(path, "LICENSE")):
author = run_git(path, ["config", "--get", "user.name"]).out.strip() or "<your name>"
write("LICENSE", MIT_LICENSE.format(year=time.localtime().tm_year, name=author))
except OSError as e:
return True, f"Couldn't write {e.filename}: {e.strerror}"
if o.get("remote"):
r = run_git(path, ["remote", "add", "origin", o["remote"]])
if not r.ok:
return True, "Couldn't add the remote: " + r.message
if wrote:
r = run_git(path, ["add", "-A"])
if r.ok:
r = run_git(path, ["commit", "-q", "-m", "Initial commit"])
if not r.ok:
return True, r.message
return True, ""
# --------------------------------------------------------------------------
# Small widgets
# --------------------------------------------------------------------------
class ToolbarButton(Static, can_focus=True):
"""Two-line clickable block in the top bar (like GitHub Desktop's)."""
class Pressed(Message):
def __init__(self, button: "ToolbarButton") -> None:
super().__init__()
self.button = button
def __init__(self, icon: str, top: str, bottom: str, *, id: str,
emphasis: str = "bottom", dropdown: bool = False) -> None:
super().__init__(id=id, classes="tb")
self.icon, self.top, self.bottom = icon, top, bottom
self.badge = ""
self.emphasis = emphasis
self.dropdown = dropdown
def set(self, *, icon=None, top=None, bottom=None, badge=None) -> None:
if icon is not None:
self.icon = icon
if top is not None:
self.top = top
if bottom is not None:
self.bottom = bottom
if badge is not None:
self.badge = badge
self.refresh()
def render(self) -> Text:
strong, weak = f"bold {C_TEXT}", C_MUTED
top_style, bottom_style = (weak, strong) if self.emphasis == "bottom" else (strong, weak)
t = Text(no_wrap=True, overflow="ellipsis")
t.append(f"{self.icon} ", style=f"bold {C_TEXT}")
t.append(self.top, style=top_style)
if self.badge:
t.append(f" {self.badge}", style=f"bold {C_BLUE}")
if self.dropdown:
t.append(" ▾", style=C_MUTED)
t.append("\n ")
t.append(self.bottom, style=bottom_style)
return t
def on_click(self, event: events.Click) -> None:
event.stop()
self.post_message(self.Pressed(self))
def key_enter(self) -> None:
self.post_message(self.Pressed(self))
def key_space(self) -> None:
self.post_message(self.Pressed(self))
class Tick(Static):
"""A small clickable checkbox. Clicking it doesn't select the row."""
class Toggled(Message):
def __init__(self, tick: "Tick") -> None:
super().__init__()
self.tick = tick
checked = reactive(True)
def __init__(self, checked: bool = True, key: Optional[str] = None, **kw) -> None:
super().__init__(**kw)
self.set_reactive(Tick.checked, checked)
self.key = key
def render(self) -> Text:
return Text("[x]" if self.checked else "[ ]",
style=f"bold {C_BLUE}" if self.checked else C_MUTED)
def on_click(self, event: events.Click) -> None:
if event.button != 1: # right-click: let the row open its menu
return
event.stop()
self.checked = not self.checked
self.post_message(self.Toggled(self))
def fit_path(path: str, width: int) -> str:
"""Shorten a path in the middle so the file name stays visible (like GitHub Desktop)."""
if width <= 0 or len(path) <= width:
return path
name = path.rsplit("/", 1)[-1]
if len(name) + 8 > width: # the name alone barely fits: cut the middle of it all
head = max(3, width // 3)
return path[:head] + "…" + path[-(width - head - 1):]
return path[:width - len(name) - 2] + "…/" + name
class PathLabel(Static):
"""A file path that fits its width, keeping the file name."""
def __init__(self, fc: FileChange, **kw) -> None:
super().__init__(**kw)
self.fc = fc
def render(self) -> Text:
width = self.size.width
shown = fit_path(self.fc.path, width - (len(self.fc.orig) + 3 if self.fc.orig and width > 60 else 0))
t = Text(no_wrap=True, overflow="ellipsis")
if self.fc.orig and width > 60:
t.append(self.fc.orig + " → ", style=C_DIM)
d, sep, name = shown.rpartition("/")
if sep:
t.append(d + "/", style=C_MUTED)
t.append(name)
return t
class FileRow(ListItem):
class MenuRequested(Message):
def __init__(self, row: "FileRow", x: int, y: int) -> None:
super().__init__()
self.row, self.x, self.y = row, x, y
def __init__(self, fc: FileChange, checked: bool) -> None:
super().__init__(classes="filerow")
self.fc = fc
self._checked = checked
def on_click(self, event: events.Click) -> None:
if event.button == 3: # right-click -> context menu (the row also gets selected)
self.post_message(self.MenuRequested(self, event.screen_x, event.screen_y))
def compose(self) -> ComposeResult:
icon, color, _ = KIND_ICON.get(self.fc.kind, ("●", C_YELLOW, ""))
with Horizontal():
yield Tick(self._checked, key=self.fc.path, classes="tick")
yield PathLabel(self.fc, classes="fpath")
yield Label(Text(icon, style=f"bold {color}"), classes="fkind")
class CommitRow(ListItem):
def __init__(self, c: Commit, unpushed: bool) -> None:
super().__init__(classes="commitrow")
self.commit = c
self.unpushed = unpushed
def compose(self) -> ComposeResult:
t = Text(no_wrap=True, overflow="ellipsis")
if self.unpushed:
t.append("↑ ", style=f"bold {C_BLUE}")
t.append(self.commit.subject)
yield Label(t)
yield Label(Text(f"{self.commit.author} • {self.commit.when}", style=C_MUTED,
no_wrap=True, overflow="ellipsis"))
class Card(Horizontal):
def __init__(self, title: str, desc: str, button: str, action: str) -> None:
super().__init__(classes="card")
self._title, self._desc, self._button, self._action = title, desc, button, action
def compose(self) -> ComposeResult:
with Vertical(classes="card-text"):
yield Label(Text(self._title, style=f"bold {C_TEXT}"))
yield Label(Text(self._desc, style=C_MUTED))
yield Button(self._button, name=self._action, classes="card-btn", compact=True)
# --------------------------------------------------------------------------
# Dialogs
# --------------------------------------------------------------------------
class ChoiceDialog(ModalScreen):
"""Message + a row of buttons. Returns the chosen button's value."""
BINDINGS = [Binding("escape", "cancel", "Close")]
def __init__(self, title: str, body, buttons: List[Tuple[str, str, str]],
cancel: Optional[str] = None) -> None:
super().__init__()
self._title = title
self._body = body if isinstance(body, Text) else Text(str(body))
self._buttons = buttons
self._cancel = cancel if cancel is not None else buttons[-1][0]
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(Text(self._title, style="bold"), classes="dialog-title")
with VerticalScroll(classes="dialog-body"):
yield Static(self._body)
with Horizontal(classes="dialog-buttons"):
for value, label, variant in self._buttons:
yield Button(label, variant=variant, name=value, compact=True)
def on_mount(self) -> None:
self.query(Button).first().focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
self.dismiss(event.button.name)
def action_cancel(self) -> None:
self.dismiss(self._cancel)
class InputDialog(ModalScreen):
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, title: str, prompt: str, ok: str, placeholder: str = "", value: str = "") -> None:
super().__init__()
self._title, self._prompt, self._ok = title, prompt, ok
self._placeholder, self._value = placeholder, value
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(Text(self._title, style="bold"), classes="dialog-title")
yield Static(Text(self._prompt))
yield Input(value=self._value, placeholder=self._placeholder, id="dlg-input")
with Horizontal(classes="dialog-buttons"):
yield Button(self._ok, variant="primary", name="ok", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self.query_one(Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
self.dismiss(event.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
self.dismiss(self.query_one(Input).value if event.button.name == "ok" else None)
def action_cancel(self) -> None:
self.dismiss(None)
class FolderTree(DirectoryTree):
def filter_paths(self, paths):
return [p for p in paths if p.is_dir() and p.name != ".git"]
class FolderBrowser(ModalScreen):
"""In-terminal folder picker, used when there's no desktop folder picker available."""
BINDINGS = [Binding("escape", "cancel", "Cancel"), Binding("backspace", "up", "Up a folder")]
def __init__(self, title: str, start: str) -> None:
super().__init__()
self._title = title
self.current = existing_folder(start)
def compose(self) -> ComposeResult:
with Vertical(classes="dialog wide"):
yield Label(Text(self._title, style="bold"), classes="dialog-title")
yield Static(id="fb-current")
yield FolderTree(self.current, id="fb-tree", classes="folder-tree")
with Horizontal(classes="dialog-buttons"):
yield Button("Up a folder", name="up", compact=True)
yield Button("Select this folder", variant="primary", name="select", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self._show()
self.query_one("#fb-tree").focus()
def _show(self) -> None:
self.query_one("#fb-current", Static).update(Text.assemble(("Selected: ", C_MUTED), (self.current, "bold")))
@on(Tree.NodeHighlighted, "#fb-tree")
def _highlighted(self, event: Tree.NodeHighlighted) -> None:
data = event.node.data
if data is not None and getattr(data, "path", None) is not None:
self.current = str(data.path)
self._show()
def action_up(self) -> None:
parent = os.path.dirname(self.current.rstrip("\\/")) or self.current
if os.path.isdir(parent) and parent != self.current:
self.current = parent
self.query_one("#fb-tree", FolderTree).path = parent
self._show()
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "up":
self.action_up()
elif event.button.name == "select":
self.dismiss(self.current)
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
class ChooseFolderMixin:
"""For dialogs with a path box + "Choose…" button."""
async def choose_into(self, input_id: str, title: str, button_id: str,
start: Optional[str] = None) -> Optional[str]:
inp = self.query_one(f"#{input_id}", Input)
btn = self.query_one(f"#{button_id}", Button)
btn.disabled = True
try:
chosen = await self.app.choose_folder(title, start if start is not None else inp.value)
finally:
btn.disabled = False
if chosen:
inp.value = chosen
inp.cursor_position = len(chosen)
return chosen
class AddLocalDialog(ChooseFolderMixin, ModalScreen):
"""Returns ("open", path), ("create_here", path) or None."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(Text("Add local repository", style="bold"), classes="dialog-title")
yield Label("Local path", classes="field-label")
with Horizontal(classes="path-row"):
yield Input(placeholder="repository path", id="al-path", compact=True)
yield Button("Choose…", name="choose", id="al-choose", compact=True)
yield Static(id="al-error", classes="error")
with Horizontal(classes="dialog-buttons"):
yield Button("Create a repository here", name="create_here", id="al-create",
variant="success", compact=True, classes="hidden")
yield Button("Add repository", variant="primary", name="add", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self.query_one("#al-path", Input).focus()
_checked_value: Optional[str] = None
def on_input_changed(self, event: Input.Changed) -> None:
# Only clear the message when the path really changed since it was checked
# (filling the box from Choose… also sends a Changed event, a moment later).
if event.value != self._checked_value:
self.query_one("#al-error", Static).update("")
self.query_one("#al-create").add_class("hidden")
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
self._submit()
def _path(self) -> str:
raw = self.query_one("#al-path", Input).value.strip()
return os.path.abspath(os.path.expanduser(raw)) if raw else ""
def _submit(self) -> None:
self._checked_value = self.query_one("#al-path", Input).value
path = self._path()
err = self.query_one("#al-error", Static)
if not path:
err.update(Text("Type a folder path or click Choose….", style=C_RED))
elif not os.path.isdir(path):
err.update(Text("That folder doesn't exist.", style=C_RED))
elif run_git(path, ["rev-parse", "--show-toplevel"]).ok:
self.dismiss(("open", path))
else:
err.update(Text("This folder doesn't appear to be a git repository. Would you like to "
"create a repository here instead?", style=C_ORANGE))
self.query_one("#al-create").remove_class("hidden")
async def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
name = event.button.name
if name == "choose":
self.run_worker(self._choose(), exit_on_error=False)
elif name == "add":
self._submit()
elif name == "create_here":
self.dismiss(("create_here", self._path()))
else:
self.dismiss(None)
async def _choose(self) -> None:
if await self.choose_into("al-path", "Choose a repository folder", "al-choose"):
self._submit()
def action_cancel(self) -> None:
self.dismiss(None)
class ContextMenu(ModalScreen):
"""A small popup menu at a screen position. Returns (item id, (x, y) of that item) or None.
items: (id, label) or (id, label, enabled), or None for a separator line.
"""
BINDINGS = [Binding("escape", "cancel", "Close"), Binding("left", "cancel", "Back", show=False)]
def __init__(self, items, x: int, y: int) -> None:
super().__init__()
self.items, self.x, self.y = items, x, y
def compose(self) -> ComposeResult:
options = []
for item in self.items:
if item is None:
options.append(None)
else:
ident, label, *rest = item
options.append(Option(Text(label), id=ident, disabled=bool(rest) and not rest[0]))
yield OptionList(*options, id="ctx-menu")
def on_mount(self) -> None:
menu = self.query_one("#ctx-menu", OptionList)
menu.styles.offset = (self.x, self.y)
menu.focus()
self.call_after_refresh(self._keep_on_screen)
def _keep_on_screen(self) -> None:
menu = self.query_one("#ctx-menu", OptionList)
w, h = menu.outer_size
x = max(0, min(self.x, self.size.width - w))
y = max(0, min(self.y, self.size.height - h))
menu.styles.offset = (x, y)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
event.stop()
menu = self.query_one("#ctx-menu", OptionList)
anchor = (menu.region.right, menu.region.y + 1 + event.option_index - menu.scroll_offset.y)
self.dismiss((event.option.id, anchor))
def on_click(self, event: events.Click) -> None:
menu = self.query_one("#ctx-menu", OptionList)
if not menu.region.contains(event.screen_x, event.screen_y):
self.dismiss(None) # clicked outside the menu
def action_cancel(self) -> None:
self.dismiss(None)
class SimpleRow(ListItem):
def __init__(self, value, *lines: Text) -> None:
super().__init__()
self.value = value
self._lines = lines
def compose(self) -> ComposeResult:
for line in self._lines:
yield Label(line)
class StashDialog(ModalScreen):
BINDINGS = [Binding("escape", "cancel", "Close")]
def __init__(self, git: Git) -> None:
super().__init__()
self.git = git
self.stashes = git.stash_list()
def compose(self) -> ComposeResult:
with Vertical(classes="dialog wide"):
yield Label(Text("Stashed changes", style="bold"), classes="dialog-title")
if not self.stashes:
yield Static(Text("You don't have any stashed changes.", style=C_MUTED))
else:
rows = [SimpleRow(ref, Text(msg, no_wrap=True, overflow="ellipsis"),
Text(f"{ref} • {when}", style=C_MUTED))
for ref, msg, when in self.stashes]
yield ListView(*rows, id="stash-lv", classes="dialog-list")
yield Static(id="stash-preview", classes="preview")
with Horizontal(classes="dialog-buttons"):
if self.stashes:
yield Button("Pop (restore + remove)", variant="primary", name="pop", compact=True)
yield Button("Apply (keep stash)", name="apply", compact=True)
yield Button("Drop", variant="error", name="drop", compact=True)
yield Button("Close", name="close", compact=True)
def on_mount(self) -> None:
if self.stashes:
lv = self.query_one("#stash-lv", ListView)
lv.index = 0
lv.focus()
@on(ListView.Highlighted, "#stash-lv")
def _preview(self, event: ListView.Highlighted) -> None:
if isinstance(event.item, SimpleRow):
self.query_one("#stash-preview", Static).update(
Text(self.git.stash_preview(event.item.value), style=C_MUTED))
def _current(self) -> Optional[str]:
lv = self.query_one("#stash-lv", ListView)
item = lv.highlighted_child
return item.value if isinstance(item, SimpleRow) else None
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "close":
self.dismiss(None)
return
ref = self._current()
self.dismiss((event.button.name, ref) if ref else None)
def action_cancel(self) -> None:
self.dismiss(None)
class BranchDialog(ModalScreen):
BINDINGS = [Binding("escape", "cancel", "Close")]
def __init__(self, git: Git, current: Optional[str], remote: Optional[str]) -> None:
super().__init__()
self.current = current
self.local = git.local_branches()
local_names = {n for n, _ in self.local}
self.remote_only = []
if remote:
for name, when in git.remote_branches(remote):
short = name[len(remote) + 1:]
if short not in local_names:
self.remote_only.append((short, name, when))
def compose(self) -> ComposeResult:
with Vertical(classes="dialog wide"):
yield Label(Text("Branches", style="bold"), classes="dialog-title")
yield Input(placeholder="Filter, or type a new branch name", id="branch-filter")
yield ListView(id="branch-lv", classes="dialog-list")
with Horizontal(classes="dialog-buttons"):
yield Button("New branch", variant="success", name="create", id="branch-create",
compact=True, disabled=True)
yield Button("Close", name="close", compact=True)
async def on_mount(self) -> None:
await self._fill("")
self.query_one("#branch-filter", Input).focus()
async def _fill(self, text: str) -> None:
lv = self.query_one("#branch-lv", ListView)
await lv.clear()
needle = text.lower()
rows = []
for name, when in self.local:
if needle and needle not in name.lower():
continue
t = Text(no_wrap=True, overflow="ellipsis")
if name == self.current:
t.append("✓ " + name, style=f"bold {C_GREEN}")
else:
t.append(" " + name)
t.append(f" {when}", style=C_DIM)
rows.append(SimpleRow(("switch", name, None), t))
for short, full, when in self.remote_only:
if needle and needle not in short.lower():
continue
t = Text(no_wrap=True, overflow="ellipsis")
t.append(" " + short)
t.append(f" on {full.split('/')[0]} • {when}", style=C_DIM)
rows.append(SimpleRow(("switch", short, full), t))
await lv.extend(rows)
btn = self.query_one("#branch-create", Button)
names = {n for n, _ in self.local}
btn.disabled = not text.strip() or text.strip() in names
set_label(btn, f'Create branch "{text.strip()}"' if text.strip() else "New branch")
async def on_input_changed(self, event: Input.Changed) -> None:
await self._fill(event.value)
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
lv = self.query_one("#branch-lv", ListView)
rows = list(lv.query(SimpleRow))
if len(rows) == 1:
self._pick(rows[0].value)
elif not self.query_one("#branch-create", Button).disabled:
self.dismiss(("create", event.value.strip(), None))
def _pick(self, value) -> None:
if value[1] == self.current and value[2] is None:
self.dismiss(None)
else:
self.dismiss(value)
@on(ListView.Selected, "#branch-lv")
def _selected(self, event: ListView.Selected) -> None:
if isinstance(event.item, SimpleRow):
self._pick(event.item.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "create":
self.dismiss(("create", self.query_one("#branch-filter", Input).value.strip(), None))
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
class RepoDialog(ModalScreen):
"""Repository picker: recent repos, plus Add local / New / Clone.
Returns a path, "add_local", "new", "clone", or None.
"""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, current: str, first: bool, error: str = "") -> None:
super().__init__()
self.current, self.first, self.error = current, first, error
self.recent = [p for p in load_recent() if os.path.isdir(p)]
def compose(self) -> ComposeResult:
with Vertical(classes="dialog wide"):
yield Label(Text("Repositories", style="bold"), classes="dialog-title")
if self.error:
yield Static(Text(self.error, style=C_RED))
if self.recent:
yield Static(Text("Recent", style=f"bold {C_MUTED}"))
rows = [SimpleRow(p, Text(os.path.basename(p) or p, style="bold"),
Text(p, style=C_MUTED, no_wrap=True, overflow="ellipsis"))
for p in self.recent]
yield ListView(*rows, id="repo-lv", classes="dialog-list")
else:
yield Static(Text("No recent repositories yet. Add one you already have, create a new "
"one, or clone one from GitHub, Gitea, etc.", style=C_MUTED))
with Horizontal(classes="dialog-buttons"):
yield Button("Add local repository…", name="add_local", variant="success", compact=True)
yield Button("New repository…", name="new", variant="success", compact=True)
yield Button("Clone repository…", name="clone", variant="success", compact=True)
yield Button("Quit" if self.first else "Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
if self.recent:
self.query_one("#repo-lv", ListView).focus()
else:
self.query(Button).first().focus()
@on(ListView.Selected, "#repo-lv")
def _selected(self, event: ListView.Selected) -> None:
if isinstance(event.item, SimpleRow):
self.dismiss(event.item.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
name = event.button.name
if name in ("new", "clone", "add_local"):
self.dismiss(name)
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
class NewRepoDialog(ChooseFolderMixin, ModalScreen):
"""Create a new repository. Returns a dict of the choices, or None."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, parent: str, name: str = "") -> None:
super().__init__()
self.parent_folder = parent
self.initial_name = name
def compose(self) -> ComposeResult:
ignore_opts = [("None", "")] + [(k, k) for k in GITIGNORE_TEMPLATES]
with Vertical(classes="dialog wide"):
yield Label(Text("Create a new repository", style="bold"), classes="dialog-title")
yield Static(id="nr-error", classes="error pinned")
with VerticalScroll(classes="form"):
yield Label("Name", classes="field-label")
yield Input(value=self.initial_name, placeholder="my-project", id="nr-name", compact=True)
yield Label("Description (optional)", classes="field-label")
yield Input(id="nr-desc", compact=True)
yield Label("Local path - the new folder is created inside this one", classes="field-label")
with Horizontal(classes="path-row"):
yield Input(value=self.parent_folder, id="nr-parent", compact=True)
yield Button("Choose…", name="choose", id="nr-choose", compact=True)
yield Static(id="nr-where", classes="hint")
yield Checkbox("Initialize this repository with a README", value=True, id="nr-readme",
compact=True)
yield Label("Git ignore", classes="field-label")
yield Select(ignore_opts, value="", allow_blank=False, id="nr-ignore", compact=True)
yield Label("License", classes="field-label")
yield Select([("None", ""), ("MIT License", "MIT")], value="", allow_blank=False, id="nr-license",
compact=True)
yield Label("Remote URL (optional) - an empty repository you've already created on "
"GitHub, Gitea, etc.", classes="field-label")
yield Input(placeholder="e.g. [email protected]:you/my-project.git", id="nr-remote", compact=True)
with Horizontal(classes="dialog-buttons pinned"):
yield Button("Create repository", variant="primary", name="create", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self.query_one("#nr-name", Input).focus()
self._update_where()
def _target(self) -> str:
name = self.query_one("#nr-name", Input).value.strip()
parent = os.path.expanduser(self.query_one("#nr-parent", Input).value.strip())
return os.path.join(parent, name) if name else parent
def _update_where(self) -> None:
name = self.query_one("#nr-name", Input).value.strip()
target = self._target()
text = Text(f"Will be created at: {target}" if name else "", style=C_MUTED)
if name and os.path.isdir(os.path.join(target, ".git")):
text = Text(f"{target} is already a repository - you'll be offered to open it.", style=C_ORANGE)
elif name and os.path.isdir(target) and os.listdir(target):
text = Text(f"{target} already exists - it will become a repository, and the files already "
"in it will be included in the first commit.", style=C_ORANGE)
self.query_one("#nr-where", Static).update(text)
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id in ("nr-name", "nr-parent"):
self._update_where()
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
self._submit()
def _error(self, msg: str) -> None:
self.query_one("#nr-error", Static).update(Text(msg, style=C_RED))
def _submit(self) -> None:
name = self.query_one("#nr-name", Input).value.strip()
parent = os.path.expanduser(self.query_one("#nr-parent", Input).value.strip())
if not name:
return self._error("Give the repository a name.")
if BAD_NAME_RE.search(name) or name in (".", ".."):
return self._error('The name can\'t contain \\ / : * ? " < > |')
if not parent:
return self._error("Choose the folder to create it in.")
target = os.path.join(parent, name)
if os.path.exists(target) and not os.path.isdir(target):
return self._error(f"{target} is a file, not a folder.")
self.dismiss({
"name": name,
"parent": parent,
"path": target,
"description": self.query_one("#nr-desc", Input).value.strip(),
"readme": self.query_one("#nr-readme", Checkbox).value,
"gitignore": self.query_one("#nr-ignore", Select).value or "",
"license": self.query_one("#nr-license", Select).value or "",
"remote": self.query_one("#nr-remote", Input).value.strip(),
})
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "choose":
self.run_worker(self.choose_into("nr-parent", "Choose where to create the repository", "nr-choose"),
exit_on_error=False)
elif event.button.name == "create":
self._submit()
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
class CloneDialog(ChooseFolderMixin, ModalScreen):
"""Clone a repository. Returns (url, path) or None."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, parent: str) -> None:
super().__init__()
self.parent_folder = parent
self._auto_path = parent
def compose(self) -> ComposeResult:
with Vertical(classes="dialog wide"):
yield Label(Text("Clone a repository", style="bold"), classes="dialog-title")
yield Label("Repository URL - HTTPS or SSH", classes="field-label")
yield Input(placeholder="https://github.com/owner/repo.git or git@host:owner/repo.git",
id="cl-url", compact=True)
yield Label("Local path", classes="field-label")
with Horizontal(classes="path-row"):
yield Input(value=self.parent_folder, id="cl-path", compact=True)
yield Button("Choose…", name="choose", id="cl-choose", compact=True)
yield Static(id="cl-error", classes="error")
with Horizontal(classes="dialog-buttons"):
yield Button("Clone", variant="primary", name="clone", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self.query_one("#cl-url", Input).focus()
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id != "cl-url":
return
path_input = self.query_one("#cl-path", Input)
if path_input.value == self._auto_path: # user hasn't typed their own path
name = repo_name_from_url(event.value)
self._auto_path = os.path.join(self.parent_folder, name) if name else self.parent_folder
path_input.value = self._auto_path
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
self._submit()
def _submit(self) -> None:
url = self.query_one("#cl-url", Input).value.strip()
path = os.path.expanduser(self.query_one("#cl-path", Input).value.strip())
err = self.query_one("#cl-error", Static)
if not url:
err.update(Text("Paste the repository's URL.", style=C_RED))
elif not path:
err.update(Text("Choose where to put it.", style=C_RED))
elif os.path.isdir(path) and os.listdir(path):
err.update(Text(f"{path} already exists and isn't empty - pick another folder.", style=C_RED))
else:
self.dismiss((url, path))
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "choose":
self.run_worker(self._choose(), exit_on_error=False)
elif event.button.name == "clone":
self._submit()
else:
self.dismiss(None)
async def _choose(self) -> None:
chosen = await self.choose_into("cl-path", "Choose where to put the cloned repository", "cl-choose",
start=self.parent_folder)
if chosen:
self.parent_folder = chosen
name = repo_name_from_url(self.query_one("#cl-url", Input).value)
self._auto_path = os.path.join(chosen, name) if name else chosen
self.query_one("#cl-path", Input).value = self._auto_path
def action_cancel(self) -> None:
self.dismiss(None)
class IdentityDialog(ModalScreen):
"""git's user.name / user.email, asked before the first commit. Returns (name, email) or None."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, name: str, email: str) -> None:
super().__init__()
self.name_value, self.email_value = name, email
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(Text("Tell git who you are", style="bold"), classes="dialog-title")
yield Static(Text("git puts a name and email on every commit. Use the email of your GitHub / Gitea "
"account so your commits are linked to you. This is saved for all your repositories.",
style=C_MUTED))
yield Label("Name", classes="field-label")
yield Input(value=self.name_value, placeholder="Your Name", id="id-name", compact=True)
yield Label("Email", classes="field-label")
yield Input(value=self.email_value, placeholder="[email protected]", id="id-email", compact=True)
yield Static(id="id-error", classes="error")
with Horizontal(classes="dialog-buttons"):
yield Button("Save", variant="primary", name="save", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self.query_one("#id-name" if not self.name_value else "#id-email", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
if event.input.id == "id-name" and not self.query_one("#id-email", Input).value.strip():
self.query_one("#id-email", Input).focus()
else:
self._submit()
def _submit(self) -> None:
name = self.query_one("#id-name", Input).value.strip()
email = self.query_one("#id-email", Input).value.strip()
err = self.query_one("#id-error", Static)
if not name:
err.update(Text("Enter your name.", style=C_RED))
elif "@" not in email or " " in email:
err.update(Text("Enter a valid email address.", style=C_RED))
else:
self.dismiss((name, email))
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "save":
self._submit()
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
class SshPushDialog(ModalScreen):
"""Offered when an HTTPS push is refused as too large. Returns (ssh_url, always) or None."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self, error: str, suggestion: str) -> None:
super().__init__()
self.error, self.suggestion = error, suggestion
def compose(self) -> ComposeResult:
with Vertical(classes="dialog wide"):
yield Label(Text("Push is too big for HTTPS", style="bold"), classes="dialog-title")
yield Static(Text(self.error, style="#f97583"))
yield Static(Text(
"\nThe server - or a proxy in front of it - refused this push because the upload is too big "
"(HTTP 413). Cloudflare's free plan, for example, stops any single upload over 100 MB. Git LFS "
"files upload one at a time so they usually get through, but the rest of the push is sent "
"in one piece. Pushing over SSH avoids the limit."))
yield Label("SSH address of this repository", classes="field-label")
yield Input(value=self.suggestion, placeholder="git@host:owner/repo.git or ssh://git@host:22/owner/repo.git",
id="ssh-url", compact=True)
yield Static(Text(
"If the website goes through Cloudflare or another proxy, SSH has to use an address that reaches "
"the server directly - a DNS-only hostname or the server's IP, e.g. "
"ssh://[email protected]:22/owner/repo.git", style=C_MUTED), classes="hint")
yield Checkbox("Always push this repository over SSH (fetching stays on HTTPS)", value=True,
id="ssh-always", compact=True)
yield Static(id="ssh-error", classes="error")
with Horizontal(classes="dialog-buttons"):
yield Button("Push over SSH", variant="primary", name="push", compact=True)
yield Button("Cancel", name="cancel", compact=True)
def on_mount(self) -> None:
self.query_one("#ssh-url", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
event.stop()
self._submit()
def _submit(self) -> None:
url = self.query_one("#ssh-url", Input).value.strip()
if not url:
self.query_one("#ssh-error", Static).update(Text("Enter the SSH address.", style=C_RED))
return
self.dismiss((url, self.query_one("#ssh-always", Checkbox).value))
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.name == "push":
self._submit()
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
ACTIONS = [
("fetch", "Fetch"),
("pull", "Pull"),
("push", "Push"),
("sync", "Sync (pull, then push)"),
("stash", "Stash all changes…"),
("stashes", "Stashed changes…"),
("discard_ticked", "Discard ticked changes…"),
("branches", "Switch / create branch…"),
("new_repo", "New repository…"),
("repositories", "Repositories…"),
("open_repo", "Add local repository…"),
("clone_repo", "Clone repository…"),
("add_remote", "Add / change remote URL…"),
("open_editor", "Open in editor"),
("open_folder", "Show in file manager"),
("view_remote", "View on GitHub / remote site"),
("toggle_log", "Show / hide git output"),
("refresh", "Refresh"),
]
class ActionsDialog(ModalScreen):
BINDINGS = [Binding("escape", "cancel", "Close")]
def compose(self) -> ComposeResult:
with Vertical(classes="dialog narrow"):
yield Label(Text("Actions", style="bold"), classes="dialog-title")
for action, label in ACTIONS:
yield Button(label, name=action, classes="action-btn", compact=True)
with Horizontal(classes="dialog-buttons"):
yield Button("Close", name="", compact=True)
def on_mount(self) -> None:
self.query(Button).first().focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
self.dismiss(event.button.name or None)
def action_cancel(self) -> None:
self.dismiss(None)
# --------------------------------------------------------------------------
# The app
# --------------------------------------------------------------------------
GITDESK_THEME = Theme(
name="gitdesk",
primary="#2188ff",
secondary="#79b8ff",
accent="#2188ff",
warning="#f66a0a",
error="#d73a49",
success="#28a745",
foreground=C_TEXT,
background="#24292e",
surface="#2f363d",
panel="#1f2428",
dark=True,
)
CSS = """
Screen { background: #24292e; }
.hidden { display: none; }
#toolbar { height: 3; background: #1f2428; }
.tb { height: 3; width: 32; padding: 0 1; content-align: left middle;
border-right: vkey #444d56; background: #1f2428; }
.tb:hover { background: #2f363d; }
.tb:focus { background: #2f363d; text-style: none; }
.tb:disabled { color: #6a737d; }
#tb-remote { width: 34; }
#tb-more { width: 14; }
#main { height: 1fr; }
#left { width: 42; min-width: 30; border-right: vkey #444d56; }
#right { width: 1fr; }
#tabs { height: 1fr; }
#tabs > ContentSwitcher { height: 1fr; }
TabPane { padding: 0; height: 1fr; }
#tab-changes > Vertical, #tab-history > Vertical { height: 1fr; }
#banner { height: auto; background: #3b2a12; padding: 0 1; border-bottom: solid #f66a0a; }
#banner-buttons { height: auto; margin-top: 1; }
#changes-header { height: 1; padding: 0 1; background: #2b3036; }
#changes-header .tick { width: 4; }
#changes-count { width: 1fr; }
#files { height: 1fr; background: #24292e; }
.filerow { padding: 0 1; height: 1; }
.filerow > Horizontal { height: 1; }
.filerow .tick { width: 4; }
.filerow .fpath { width: 1fr; }
.filerow .fkind { width: 2; }
#stash-bar { height: 1; padding: 0 1; margin-top: 1; }
#stash-bar Button { margin-right: 1; }
#commit-box { height: auto; padding: 1 1 0 1; border-top: solid #444d56; }
#summary { margin-bottom: 1; }
#description { height: 5; }
#commit { width: 100%; margin: 1 0; }
#history { height: 1fr; }
.commitrow { height: auto; padding: 0 1; border-bottom: solid #30363d; }
#placeholder { padding: 1 4; }
#ph-title { text-style: bold; margin-bottom: 1; }
#ph-cards { height: auto; margin-top: 1; }
.card { height: auto; border: round #444d56; padding: 0 1; margin-bottom: 1; }
.card-text { width: 1fr; height: auto; margin-right: 2; }
.card-text > Label { width: 100%; }
.card-btn { margin-top: 1; }
#diffview { height: 1fr; }
#diff-header { height: 1; background: #1f2428; padding: 0 1; }
#diff-title { width: 1fr; }
#diff-header Button { margin-left: 1; }
#diff-scroll { height: 1fr; }
#gitlog { height: 12; border-top: solid #444d56; background: #1f2428; }
ModalScreen { align: center middle; background: rgba(0, 0, 0, 0.55); }
ContextMenu { align: left top; background: rgba(0, 0, 0, 0.15); }
#ctx-menu { width: auto; min-width: 30; max-width: 80; height: auto; max-height: 24;
background: #2f363d; border: round #586069; padding: 0; }
#ctx-menu > .option-list--option { padding: 0 1; }
.dialog { width: 76; max-width: 95%; height: auto; max-height: 90%;
background: #2f363d; border: round #586069; padding: 1 2; }
.dialog.wide { width: 90; }
.dialog.narrow { width: 44; }
.dialog-title { margin-bottom: 1; }
.dialog-body { height: auto; max-height: 22; }
.dialog-buttons { height: auto; align-horizontal: right; margin-top: 1; }
.dialog-buttons Button { margin-left: 2; }
.dialog-list { height: auto; max-height: 16; margin-top: 1; background: #24292e; }
.dialog-list > ListItem { padding: 0 1; }
.preview { height: auto; max-height: 8; margin-top: 1; }
.action-btn { width: 100%; margin-bottom: 0; }
#dlg-input { margin-top: 1; }
.form { height: auto; }
.pinned { dock: bottom; }
.dialog-buttons.pinned { margin-top: 0; padding-top: 1; }
.error.pinned { margin-top: 0; }
.form Input, .dialog Input.-textual-compact { background: #24292e; }
.form Select, .form Checkbox { width: 100%; }
.field-label { margin-top: 1; color: #959da5; }
.hint { height: auto; color: #959da5; }
.error { height: auto; margin-top: 1; }
#nr-readme { margin-top: 1; }
#ssh-always { margin-top: 1; }
.path-row { height: auto; }
.path-row Input { width: 1fr; }
.path-row Button { margin-left: 1; min-width: 12; }
.folder-tree { height: 18; background: #24292e; margin-top: 1; }
"""
class GitDesk(App):
CSS = CSS
TITLE = "gitdesk"
BINDINGS = [
Binding("f", "fetch", "Fetch"),
Binding("p", "pull", "Pull"),
Binding("u", "push", "Push"),
Binding("b", "branches", "Branch"),
Binding("s", "stash", "Stash"),
Binding("t", "stashes", "Stashes"),
Binding("d", "discard", "Discard"),
Binding("o", "open_repo", "Add local"),
Binding("n", "new_repo", "New"),
Binding("c", "clone_repo", "Clone"),
Binding("g", "view_remote", "Web"),
Binding("l", "toggle_log", "Log"),
Binding("r", "refresh", "Refresh"),
Binding("space", "toggle_file", "Include file", show=False),
Binding("m", "file_menu", "File menu", show=False),
Binding("shift+f10", "file_menu", "File menu", show=False),
Binding("q", "quit", "Quit"),
]
def __init__(self, start_path: Optional[str] = None) -> None:
super().__init__()
self.start_path = start_path
self.busy_detail = ""
self.git: Optional[Git] = None
self.state: Optional[RepoState] = None
self.excluded: Set[str] = set()
self.selected: Optional[str] = None
self.mode = "changes" # or "history"
self.busy = False
self.busy_label = ""
self.last_fetch: Optional[float] = None
self.log_q: "queue.Queue" = queue.Queue()
self._files_key = None
self._ph_key = None
self._diff_key = None
self._history_key = None
self._merge_prefilled = False
self._lock: Optional[asyncio.Lock] = None
self._net_lock: Optional[asyncio.Lock] = None
self.fetching = False
# ---- layout ------------------------------------------------------------
def compose(self) -> ComposeResult:
with Horizontal(id="toolbar"):
yield ToolbarButton("▤", "Current repository", "—", id="tb-repo", dropdown=True)
yield ToolbarButton("⎇", "Current branch", "—", id="tb-branch", dropdown=True)
yield ToolbarButton("↻", "Fetch origin", "", id="tb-remote", emphasis="top")
yield ToolbarButton("☰", "More", "Actions", id="tb-more")
with Horizontal(id="main"):
with Vertical(id="left"):
with TabbedContent(id="tabs", initial="tab-changes"):
with TabPane("Changes", id="tab-changes"):
with Vertical():
with Vertical(id="banner", classes="hidden"):
yield Static(id="banner-text")
with Horizontal(id="banner-buttons"):
yield Button("Abort merge", id="abort-merge", variant="error", compact=True)
with Horizontal(id="changes-header"):
yield Tick(True, id="tick-all", classes="tick")
yield Label("0 changed files", id="changes-count")
yield ListView(id="files")
with Horizontal(id="stash-bar"):
yield Button("Stash all", id="stash-all", compact=True)
yield Button("Stashed (0)", id="stash-list", compact=True)
with Vertical(id="commit-box"):
yield Input(placeholder="Summary (required)", id="summary")
yield TextArea(id="description", placeholder="Description",
show_line_numbers=False, soft_wrap=True)
yield Button("Commit", id="commit", variant="primary", compact=True)
with TabPane("History", id="tab-history"):
with Vertical():
yield ListView(id="history")
with ContentSwitcher(id="right", initial="placeholder"):
with VerticalScroll(id="placeholder"):
yield Static(id="ph-title")
yield Static(id="ph-sub")
yield Vertical(id="ph-cards")
with Vertical(id="diffview"):
with Horizontal(id="diff-header"):
yield Label("", id="diff-title")
yield Button("Open in editor", id="open-file", compact=True)
yield Button("Discard", id="discard-file", variant="error", compact=True)
yield Button("View on web", id="view-commit", compact=True)
yield Button("Mark as resolved", id="mark-resolved", variant="warning", compact=True)
with VerticalScroll(id="diff-scroll"):
yield Static(id="diff")
yield RichLog(id="gitlog", classes="hidden", markup=False, wrap=True, max_lines=3000)
yield Footer()
async def on_mount(self) -> None:
self.register_theme(GITDESK_THEME)
self.theme = "gitdesk"
self._lock = asyncio.Lock()
self._net_lock = asyncio.Lock()
self.set_interval(3, self.poll)
self.set_interval(0.3, self.drain_log)
self.set_interval(30, self.update_remote_button)
self.set_interval(300, self.auto_fetch)
if await self.open_start_repo():
self.run_worker(self.auto_fetch(), exit_on_error=False)
else:
self.run_worker(self.open_repo_flow(first=True), exit_on_error=False)
async def open_start_repo(self) -> bool:
"""A path on the command line wins; otherwise the folder you launched from if it's a
repository (but not gitdesk's own folder); otherwise the repository you had open last."""
if self.start_path:
return await self.load_repo(self.start_path)
candidates = []
here = await asyncio.to_thread(Git.open, os.getcwd(), self._log_cmd)
if here is not None:
app_dir = os.path.dirname(os.path.abspath(
sys.executable if getattr(sys, "frozen", False) else __file__))
own = await asyncio.to_thread(Git.open, app_dir, self._log_cmd)
if own is None or os.path.normcase(own.path) != os.path.normcase(here.path):
candidates.append(here.path)
candidates += [p for p in load_recent() if os.path.isdir(p)]
for path in candidates:
if await self.load_repo(path):
return True
return False
APP_KEYS = {"fetch", "pull", "push", "branches", "stash", "stashes", "open_repo", "discard", "view_remote",
"new_repo", "clone_repo", "add_remote", "repositories", "file_menu",
"toggle_log", "refresh", "toggle_file", "quit"}
def check_action(self, action: str, parameters) -> Optional[bool]:
# Single-letter shortcuts are switched off while a popup is open.
if action in self.APP_KEYS and isinstance(self.screen, ModalScreen):
return False
return True
# ---- small accessors ---------------------------------------------------
def remote_name(self) -> str:
st = self.state
if st and st.upstream and st.remotes:
for r in sorted(st.remotes, key=len, reverse=True):
if st.upstream.startswith(r + "/"):
return r
if st and st.remotes:
return "origin" if "origin" in st.remotes else st.remotes[0]
return "origin"
def web_url(self) -> Optional[str]:
"""Web page of the current remote. Override per repo: git config gitdesk.webUrl <url>"""
st = self.state
if st is None or self.git is None:
return None
url = st.remote_urls.get(self.remote_name(), "")
key = (self.git.path, url)
if getattr(self, "_web_cache_key", None) != key:
override = self.git.run("config", "--get", "gitdesk.webUrl", log=False).out.strip()
self._web_cache_key = key
self._web_cache = override.rstrip("/") or (web_url_for(url) if url else None)
return self._web_cache
def open_url(self, url: str) -> None:
"""Open a link in the browser, without letting a text browser take over this terminal."""
try:
if IS_WIN:
os.startfile(url) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", url])
elif (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) and shutil.which("xdg-open"):
subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True)
elif shutil.which("wslview"):
subprocess.Popen(["wslview", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
else:
self.copy_to_clipboard(url)
self.notify(f"No browser found here - the link is copied to your clipboard:\n{url}", timeout=20)
return
self.notify(f"Opening {url}")
except OSError as e:
self.notify(f"Couldn't open the browser ({e}). The link is:\n{url}", severity="error", timeout=20)
def action_view_remote(self) -> None:
if self.git is None or self.state is None:
return
url = self.web_url()
if not url:
remote = self.state.remote_urls.get(self.remote_name())
if not remote:
self.notify("This repository has no remote, so there's no web page to open.", severity="warning")
else:
self.notify(f"Couldn't work out a web page from {remote}. Set one with:\n"
"git config gitdesk.webUrl https://host/owner/repo", severity="warning", timeout=12)
return
self.open_url(url)
def included_files(self) -> List[FileChange]:
return [f for f in (self.state.files if self.state else []) if f.path not in self.excluded]
async def bg(self, *args, input: Optional[str] = None, serialize: bool = True) -> Result:
return await asyncio.to_thread(self.git.run, *args, input=input, serialize=serialize)
async def load_state(self) -> RepoState:
st = await asyncio.to_thread(self.git.state)
self.state = st
return st
def _log_cmd(self, args, res: Result) -> None:
self.log_q.put((args, res)) # called from worker threads
def drain_log(self) -> None:
try:
log = self.query_one("#gitlog", RichLog)
except NoMatches: # app is starting up or shutting down
return
while True:
try:
args, res = self.log_q.get_nowait()
except queue.Empty:
break
log.write(Text("$ git " + " ".join(args), style=f"bold {C_BLUE}"))
out = (res.out + ("\n" if res.out and res.err else "") + res.err).strip()
if out:
lines = out.splitlines()
if len(lines) > 40:
lines = lines[:40] + [f"… ({len(lines) - 40} more lines)"]
log.write(Text("\n".join(lines), style=C_TEXT if res.ok else "#f97583"))
# ---- loading / refreshing ----------------------------------------------
async def load_repo(self, path: str) -> bool:
g = await asyncio.to_thread(Git.open, path, self._log_cmd)
if g is None:
return False
self.git = g
self.state = None
self.excluded.clear()
self.selected = None
self._files_key = self._ph_key = self._diff_key = self._history_key = None
self.last_fetch = g.last_fetch_time()
save_recent(g.path)
self.sub_title = g.path
await self.query_one("#history", ListView).clear()
await self.refresh_state(force=True)
return True
async def poll(self) -> None:
if not self.busy and self.git is not None:
await self.refresh_state()
async def refresh_state(self, force: bool = False) -> None:
if self.git is None or self._lock is None:
return
if self._lock.locked() and not force:
return
async with self._lock:
g = self.git
try:
st = await asyncio.to_thread(g.state)
except GitError as e:
self.notify(str(e), title="git status failed", severity="error")
return
if g is not self.git:
return
self.state = st
try:
await self.apply_state(st)
except NoMatches: # app is shutting down
pass
async def apply_state(self, st: RepoState) -> None:
g = self.git
self.query_one("#tb-repo", ToolbarButton).set(bottom=os.path.basename(g.path) or g.path)
branch = st.branch or (f"detached at {st.oid[:7]}" if st.oid else "detached")
self.query_one("#tb-branch", ToolbarButton).set(bottom=branch)
self.update_remote_button()
key = tuple((f.path, f.kind, f.orig) for f in st.files)
if key != self._files_key:
self._files_key = key
await self.rebuild_files(st)
self.update_counts()
self.update_banner(st)
set_label(self.query_one("#stash-list", Button), f"Stashed ({st.stashes})")
self.query_one("#stash-list", Button).disabled = self.busy or st.stashes == 0
self.query_one("#stash-all", Button).disabled = self.busy or not st.files
if st.merging and not self._merge_prefilled:
self._merge_prefilled = True
summary = self.query_one("#summary", Input)
if not summary.value:
summary.value = g.merge_message()
elif not st.merging:
self._merge_prefilled = False
self.update_commit_button()
if self.mode == "changes":
await self.show_changes_side(st)
else:
await self.load_history()
async def rebuild_files(self, st: RepoState) -> None:
lv = self.query_one("#files", ListView)
paths = {f.path for f in st.files}
self.excluded &= paths
await lv.clear()
shown = st.files[:MAX_FILES_SHOWN]
rows: List[ListItem] = [FileRow(f, f.path not in self.excluded) for f in shown]
if len(st.files) > len(shown):
rows.append(ListItem(Label(Text(f"… and {len(st.files) - len(shown)} more files",
style=C_MUTED)), disabled=True))
await lv.extend(rows)
if self.selected not in paths:
self.selected = shown[0].path if shown else None
for i, f in enumerate(shown):
if f.path == self.selected:
lv.index = i
break
def update_counts(self) -> None:
st = self.state
n = len(st.files) if st else 0
inc = len(self.included_files())
self.query_one("#changes-count", Label).update(
Text(plural(n, "changed file"), style="bold" if n else C_MUTED))
self.query_one("#tick-all", Tick).checked = n > 0 and inc == n
self.query_one("#tabs", TabbedContent).get_tab("tab-changes").label = (
f"Changes ({n})" if n else "Changes")
def update_banner(self, st: RepoState) -> None:
banner = self.query_one("#banner")
abort = self.query_one("#abort-merge", Button)
text = None
if st.rebasing:
text = Text.assemble(("Rebase in progress. ", "bold"),
"Finish it in a terminal with git rebase --continue (or --abort).")
abort.display = False
elif st.merging:
if st.conflicts:
text = Text.assemble(("Merge conflicts: ", "bold"),
f"{plural(len(st.conflicts), 'file')} need fixing (marked ", (" ! ", f"bold {C_ORANGE}"),
"). Open each one, keep the parts you want, remove the <<<<<<< ======= >>>>>>> "
"lines, click Mark as resolved, then commit the merge.")
else:
text = Text.assemble(("All conflicts resolved. ", "bold"), "Commit the merge to finish it.")
abort.display = True
elif st.conflicts:
text = Text.assemble(("Conflicts: ", "bold"),
f"{plural(len(st.conflicts), 'file')} couldn't be merged with your stashed changes. "
"Fix the <<<<<<< ======= >>>>>>> parts, then click Mark as resolved.")
abort.display = False
banner.set_class(text is None, "hidden")
if text is not None:
self.query_one("#banner-text", Static).update(text)
def update_commit_button(self) -> None:
st = self.state
btn = self.query_one("#commit", Button)
if st is None:
btn.disabled = True
return
summary = self.query_one("#summary", Input).value.strip()
if st.merging:
btn.label = "Commit merge"
btn.disabled = self.busy or bool(st.conflicts)
else:
n = len(self.included_files())
btn.label = f"Commit {plural(n, 'file')} to {st.branch or 'HEAD'}"
btn.disabled = self.busy or n == 0 or not summary or bool(st.conflicts)
def remote_mode(self) -> str:
st = self.state
if st is None or not st.remotes:
return "none"
if st.branch is None:
return "fetch"
if not st.has_upstream:
return "publish"
if st.behind:
return "pull"
if st.ahead:
return "push"
return "fetch"
def update_remote_button(self) -> None:
try:
b = self.query_one("#tb-remote", ToolbarButton)
except NoMatches: # app is shutting down
return
st = self.state
if self.busy:
b.set(icon="…", top=self.busy_label + "…", bottom=self.busy_detail or "Working", badge="")
b.disabled = True
return
b.disabled = False
remote = self.remote_name()
fetched = f"Last fetched {ago(self.last_fetch)}"
if self.fetching:
fetched = "Checking for changes…"
mode = self.remote_mode()
if mode == "none":
b.set(icon="+", top="No remote", bottom="Click to add one", badge="")
elif mode == "publish":
b.set(icon="↑", top="Publish branch", bottom=f"Push it to {remote}", badge="")
elif mode == "pull":
badge = f"↓{st.behind}" + (f" ↑{st.ahead}" if st.ahead else "")
b.set(icon="↓", top=f"Pull {remote}", bottom=fetched, badge=badge)
elif mode == "push":
b.set(icon="↑", top=f"Push {remote}", bottom=fetched, badge=f"↑{st.ahead}")
else:
b.set(icon="↻", top=f"Fetch {remote}", bottom=fetched, badge="")
def set_busy(self, busy: bool, label: str = "") -> None:
self.busy, self.busy_label = busy, label
for sel in ("#tb-branch", "#tb-repo", "#stash-all", "#stash-list", "#abort-merge", "#mark-resolved", "#discard-file"):
self.query_one(sel).disabled = busy
self.update_remote_button()
self.update_commit_button()
# ---- right-hand side ---------------------------------------------------
async def show_changes_side(self, st: RepoState) -> None:
switcher = self.query_one("#right", ContentSwitcher)
if not st.files:
self._diff_key = None
await self.rebuild_placeholder(st)
switcher.current = "placeholder"
return
fc = next((f for f in st.files if f.path == self.selected), None)
if fc is None:
fc = st.files[0]
self.selected = fc.path
switcher.current = "diffview"
self.run_worker(self.show_file_diff(fc), group="diff", exclusive=True, exit_on_error=False)
async def show_file_diff(self, fc: FileChange) -> None:
g, st = self.git, self.state
text = await asyncio.to_thread(g.file_diff, fc, st.oid is not None)
if self.mode != "changes" or self.selected != fc.path:
return
key = ("file", fc.path, fc.kind, text)
if key == self._diff_key:
return
new_file = not self._diff_key or self._diff_key[1] != fc.path
self._diff_key = key
icon, color, label = KIND_ICON.get(fc.kind, ("●", C_YELLOW, ""))
title = Text(no_wrap=True, overflow="ellipsis")
title.append(f"{icon} ", style=f"bold {color}")
room = self.query_one("#diff-header").size.width - len(label) - 48 # leave space for the buttons
title.append(fit_path(fc.path, max(20, room)), style="bold")
title.append(f" {label}", style=C_MUTED)
self.query_one("#diff-title", Label).update(title)
self.query_one("#open-file").display = True
self.query_one("#discard-file").display = not fc.is_conflict
self.query_one("#view-commit").display = False
self.query_one("#mark-resolved").display = fc.is_conflict
self.query_one("#diff", Static).update(render_diff(text))
self.query_one("#right", ContentSwitcher).current = "diffview"
if new_file:
self.query_one("#diff-scroll", VerticalScroll).scroll_home(animate=False)
async def rebuild_placeholder(self, st: RepoState) -> None:
key = (st.behind, st.ahead, st.has_upstream, bool(st.remotes), st.branch, st.stashes, self.web_url())
if key == self._ph_key:
return
self._ph_key = key
self.query_one("#ph-title", Static).update(Text("No local changes", style=f"bold {C_TEXT}"))
self.query_one("#ph-sub", Static).update(Text(
"There are no uncommitted changes in this repository. Here are some friendly "
"suggestions for what to do next.", style=C_MUTED))
remote = self.remote_name()
cards = []
if not st.remotes:
cards.append(Card("Publish your repository",
"It isn't on GitHub, Gitea or another server yet. Create an empty repository "
"there, then add its URL here.", "Add remote URL", "add_remote"))
if st.remotes and st.branch:
if not st.has_upstream and st.oid:
cards.append(Card("Publish your branch",
f"'{st.branch}' isn't on {remote} yet. Publish it so it's backed up and shareable.",
"Publish branch", "push"))
elif st.behind:
cards.append(Card(f"Pull {plural(st.behind, 'commit')} from {remote}",
"The remote has changes you don't have yet.", f"Pull {remote}", "pull"))
elif st.ahead:
cards.append(Card(f"Push {plural(st.ahead, 'commit')} to {remote}",
"You have local commits that aren't on the remote yet.", f"Push {remote}", "push"))
if st.stashes:
cards.append(Card(f"You have {plural(st.stashes, 'stash')}",
"Changes you put aside earlier. Bring them back any time.",
"View stashed changes", "stashes"))
cards.append(Card("Open the repository in your external editor",
f"Opens it in {editor_name()}.", "Open in editor", "open_editor"))
cards.append(Card("View the files of your repository",
f"Opens the folder in {file_manager_name()}.",
f"Show in {file_manager_name()}", "open_folder"))
web = self.web_url()
if web:
site = web_site_name(web)
cards.append(Card(f"Open the repository page on {site} in your browser", web,
f"View on {site}", "view_remote"))
box = self.query_one("#ph-cards", Vertical)
await box.remove_children()
await box.mount_all(cards)
async def load_history(self) -> None:
st, g = self.state, self.git
if st is None:
return
key = (st.oid, st.upstream, st.ahead)
if key == self._history_key:
return
self._history_key = key
commits, unpushed = await asyncio.to_thread(g.history, st.has_upstream) if st.oid else ([], set())
lv = self.query_one("#history", ListView)
await lv.clear()
await lv.extend([CommitRow(c, c.sha in unpushed) for c in commits])
if not commits:
self._show_message("History", "No commits yet.")
def _show_message(self, title: str, message: str) -> None:
self._diff_key = None
self.query_one("#diff-title", Label).update(Text(title, style="bold"))
self.query_one("#open-file").display = False
self.query_one("#discard-file").display = False
self.query_one("#mark-resolved").display = False
self.query_one("#view-commit").display = False
self.query_one("#diff", Static).update(Text(message, style=f"italic {C_MUTED}"))
self.query_one("#right", ContentSwitcher).current = "diffview"
async def show_commit(self, c: Commit, unpushed: bool = False) -> None:
g = self.git
header = await asyncio.to_thread(g.commit_header, c.sha)
diff = await asyncio.to_thread(g.commit_diff, c.sha)
if self.mode != "history":
return
lines = header.splitlines()
head = Text()
if len(lines) >= 3:
body = "\n".join(lines[3:]).strip()
_, _, rest = body.partition("\n") # subject is already in the title bar
head.append(f" {lines[1]} • {lines[2]} • {lines[0][:10]}\n", style=C_MUTED)
if rest.strip():
head.append("\n" + "\n".join(" " + ln for ln in rest.strip().splitlines()) + "\n",
style=C_TEXT)
head.append("\n")
self._diff_key = ("commit", c.sha)
title = Text(no_wrap=True, overflow="ellipsis")
title.append(c.short + " ", style=C_BLUE)
title.append(c.subject, style="bold")
self.query_one("#diff-title", Label).update(title)
self.query_one("#open-file").display = False
self.query_one("#discard-file").display = False
self.query_one("#mark-resolved").display = False
web = self.web_url()
view = self.query_one("#view-commit", Button)
view.display = bool(web) and not unpushed # unpushed commits aren't on the site yet
if web:
set_label(view, f"View on {web_site_name(web)}")
self._shown_commit = c.sha
self.query_one("#diff", Static).update(Group(head, render_diff(diff, multi_file=True)))
self.query_one("#right", ContentSwitcher).current = "diffview"
self.query_one("#diff-scroll", VerticalScroll).scroll_home(animate=False)
# ---- UI events ---------------------------------------------------------
@on(TabbedContent.TabActivated, "#tabs")
async def _tab_changed(self, event: TabbedContent.TabActivated) -> None:
self.mode = "history" if event.pane.id == "tab-history" else "changes"
self._diff_key = None
if self.state is None:
return
if self.mode == "history":
await self.load_history()
lv = self.query_one("#history", ListView)
if isinstance(lv.highlighted_child, CommitRow):
self.run_worker(self.show_commit(lv.highlighted_child.commit, lv.highlighted_child.unpushed),
group="diff",
exclusive=True, exit_on_error=False)
elif lv.children:
self._show_message("History", "Click a commit to see what changed.")
else:
await self.show_changes_side(self.state)
@on(ListView.Highlighted, "#files")
def _file_highlighted(self, event: ListView.Highlighted) -> None:
if isinstance(event.item, FileRow) and self.mode == "changes":
self.selected = event.item.fc.path
self.run_worker(self.show_file_diff(event.item.fc), group="diff", exclusive=True,
exit_on_error=False)
@on(ListView.Highlighted, "#history")
def _commit_highlighted(self, event: ListView.Highlighted) -> None:
if isinstance(event.item, CommitRow) and self.mode == "history":
self.run_worker(self.show_commit(event.item.commit, event.item.unpushed), group="diff", exclusive=True,
exit_on_error=False)
@on(Tick.Toggled)
def _tick_toggled(self, event: Tick.Toggled) -> None:
st = self.state
if st is None:
return
tick = event.tick
if tick.id == "tick-all":
self.excluded = set() if tick.checked else {f.path for f in st.files}
for t in self.query("#files Tick"):
t.checked = tick.checked
elif tick.key:
(self.excluded.discard if tick.checked else self.excluded.add)(tick.key)
self.update_counts()
self.update_commit_button()
@on(FileRow.MenuRequested)
def _row_menu(self, event: FileRow.MenuRequested) -> None:
if not self.busy:
self.run_worker(self.file_menu_flow(event.row.fc, event.x, event.y), exit_on_error=False)
def action_file_menu(self) -> None:
lv = self.query_one("#files", ListView)
row = lv.highlighted_child
if self.mode == "changes" and isinstance(row, FileRow) and not self.busy:
self.run_worker(self.file_menu_flow(row.fc, row.region.x + 6, row.region.y + 1), exit_on_error=False)
async def file_menu_flow(self, fc: FileChange, x: int, y: int) -> None:
full = os.path.normpath(os.path.join(self.git.path, fc.path))
exists = os.path.exists(full)
ext = os.path.splitext(fc.path)[1]
folders = parent_folders(fc.path)
editor = "Visual Studio Code" if shutil.which("code") else editor_name()
items = [
("discard", "Discard changes...", not fc.is_conflict),
None,
("ignore_file", "Ignore file (add to .gitignore)"),
("ignore_folder", "Ignore folder (add to .gitignore) >", bool(folders)),
("ignore_ext", f"Ignore all {ext} files (add to .gitignore)" if ext else "Ignore all files of this type",
bool(ext)),
None,
("copy_path", "Copy file path"),
("copy_rel", "Copy relative file path"),
None,
("reveal", f"Show in {file_manager_name()}"),
("open_editor", f"Open in {editor}", exists),
("open_default", "Open with default program", exists),
]
res = await self.push_screen_wait(ContextMenu(items, x, y))
if not res:
return
choice, (ax, ay) = res
if choice == "discard":
await self.discard_flow([fc])
elif choice == "ignore_file":
await self.ignore_flow(gitignore_pattern(fc.path))
elif choice == "ignore_folder":
sub = await self.push_screen_wait(ContextMenu([(f, "/" + f) for f in folders], ax, ay - 1))
if sub:
await self.ignore_flow(gitignore_pattern(sub[0], folder=True))
elif choice == "ignore_ext":
await self.ignore_flow("*" + re.sub(r"([*?\[])", r"\\\1", ext))
elif choice == "copy_path":
self.copy_text(full)
elif choice == "copy_rel":
self.copy_text(os.path.normpath(fc.path))
elif choice == "reveal":
self.reveal_in_file_manager(full)
elif choice == "open_editor":
self.action_open_editor(full)
elif choice == "open_default":
self.open_default(full)
def copy_text(self, text: str) -> None:
if not copy_to_system_clipboard(text):
self.copy_to_clipboard(text) # terminal clipboard (OSC 52) - works in Windows Terminal
self.notify(f"Copied: {text}")
def reveal_in_file_manager(self, path: str) -> None:
try:
if IS_WIN:
if os.path.exists(path):
subprocess.Popen(f'explorer /select,"{path}"')
else:
os.startfile(os.path.dirname(path)) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path] if os.path.exists(path) else ["open", os.path.dirname(path)])
elif shutil.which("xdg-open") and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
subprocess.Popen(["xdg-open", os.path.dirname(path)], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
else:
self.notify("No file manager available here.", severity="warning")
except OSError as e:
self.notify(f"Couldn't open the file manager: {e}", severity="error")
def open_default(self, path: str) -> None:
try:
if IS_WIN:
os.startfile(path) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
elif shutil.which("xdg-open") and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
else:
self.notify("No desktop here to open files with.", severity="warning")
return
self.notify(f"Opening {os.path.basename(path)}")
except OSError as e:
self.notify(f"Couldn't open it: {e}", severity="error")
async def ignore_flow(self, pattern: str) -> None:
"""Add a line to the repo's .gitignore; offer to stop tracking files it now covers."""
if not self.idle_or_warn():
return
g = self.git
ignore_file = os.path.join(g.path, ".gitignore")
try:
with open(ignore_file, encoding="utf-8", errors="replace") as fh:
current = fh.read()
except FileNotFoundError:
current = ""
except OSError as e:
await self.error_dialog("Couldn't read .gitignore", str(e))
return
if pattern in (line.strip() for line in current.splitlines()):
self.notify(f"{pattern} is already in .gitignore.")
return
def tracked_ignored() -> Set[str]:
r = g.run("ls-files", "-z", "--cached", "--ignored", "--exclude-standard", log=False)
return {f for f in r.out.split("\0") if f}
before = await asyncio.to_thread(tracked_ignored)
try:
with open(ignore_file, "a", encoding="utf-8", newline="\n") as fh:
if current and not current.endswith("\n"):
fh.write("\n")
fh.write(pattern + "\n")
except OSError as e:
await self.error_dialog("Couldn't update .gitignore", str(e))
return
self._log_cmd([f"(gitdesk added '{pattern}' to .gitignore)"], Result(0))
newly = sorted(await asyncio.to_thread(tracked_ignored) - before)
self.notify(f"Added {pattern} to .gitignore.")
if newly:
body = Text(f"{plural(len(newly), 'file')} matching {pattern} "
f"{'is' if len(newly) == 1 else 'are'} already tracked by git, so ignoring "
f"{'it' if len(newly) == 1 else 'them'} won't hide changes:\n\n")
for f in newly[:10]:
body.append(f" {f}\n", style="bold")
if len(newly) > 10:
body.append(f" … and {len(newly) - 10} more\n", style=C_MUTED)
body.append("\nStop tracking them? They stay on your disk; they're removed from the repository "
"in your next commit (anyone who pulls will see them deleted).", style=C_MUTED)
choice = await self.ask("Stop tracking these files too?", body,
[("untrack", "Stop tracking", "warning"), ("keep", "Keep tracking", "default")],
cancel="keep")
if choice == "untrack":
async def flow():
for i in range(0, len(newly), 100): # keep command lines short on Windows
r = await self.gitop("--literal-pathspecs", "rm", "-q", "--cached", "--", *newly[i:i + 100])
if not r.ok:
await self.fail("Couldn't stop tracking the files", r)
return
self.notify(f"Stopped tracking {plural(len(newly), 'file')}. Commit to finish.")
self.start_op("Updating", flow)
return
await self.refresh_state(force=True)
def action_toggle_file(self) -> None:
lv = self.query_one("#files", ListView)
if self.mode != "changes" or not lv.has_focus or not isinstance(lv.highlighted_child, FileRow):
return
tick = lv.highlighted_child.query_one(Tick)
tick.checked = not tick.checked
tick.post_message(Tick.Toggled(tick))
@on(Input.Changed, "#summary")
def _summary_changed(self) -> None:
self.update_commit_button()
@on(Input.Submitted, "#summary")
def _summary_submitted(self) -> None:
if not self.query_one("#commit", Button).disabled:
self.start_op("Committing", self.commit_flow)
@on(ToolbarButton.Pressed)
def _toolbar(self, event: ToolbarButton.Pressed) -> None:
bid = event.button.id
if bid == "tb-repo":
self.action_repositories()
elif bid == "tb-branch":
self.action_branches()
elif bid == "tb-more":
self.run_worker(self.actions_flow(), exit_on_error=False)
elif bid == "tb-remote":
mode = self.remote_mode()
if mode == "none":
self.action_add_remote()
elif mode == "pull":
self.action_pull()
elif mode in ("push", "publish"):
self.action_push()
else:
self.action_fetch()
async def on_button_pressed(self, event: Button.Pressed) -> None:
b = event.button
if b.has_class("card-btn") and b.name:
await self.run_action(b.name)
elif b.id == "commit":
self.start_op("Committing", self.commit_flow)
elif b.id == "stash-all":
self.action_stash()
elif b.id == "stash-list":
self.action_stashes()
elif b.id == "abort-merge":
self.start_op("Aborting merge", self.abort_merge_flow)
elif b.id == "mark-resolved":
self.start_op("Marking resolved", self.mark_resolved_flow)
elif b.id == "view-commit" and self.web_url() and getattr(self, "_shown_commit", None):
self.open_url(commit_web_url(self.web_url(), self._shown_commit))
elif b.id == "discard-file":
self.action_discard()
elif b.id == "open-file" and self.selected and self.git:
self.action_open_editor(os.path.join(self.git.path, self.selected))
# ---- actions (keyboard + buttons) --------------------------------------
def action_fetch(self) -> None:
self.start_op("Fetching", self.fetch_flow)
def action_pull(self) -> None:
self.start_op("Pulling", self.pull_flow)
def action_push(self) -> None:
self.start_op("Pushing", self.push_flow)
def action_sync(self) -> None:
self.start_op("Syncing", lambda: self.push_flow(auto_pull=True))
def action_stash(self) -> None:
self.run_worker(self.stash_all_flow(), exit_on_error=False)
def action_stashes(self) -> None:
self.run_worker(self.stashes_flow(), exit_on_error=False)
def action_discard(self) -> None:
st = self.state
fc = next((f for f in (st.files if st else []) if f.path == self.selected), None)
if self.mode != "changes" or fc is None:
self.notify("Select a changed file first.", severity="warning")
return
self.run_worker(self.discard_flow([fc]), exit_on_error=False)
def action_discard_ticked(self) -> None:
self.run_worker(self.discard_flow(self.included_files()), exit_on_error=False)
def action_branches(self) -> None:
self.run_worker(self.branches_flow(), exit_on_error=False)
def action_open_repo(self) -> None:
self.run_worker(self.add_local_flow(), exit_on_error=False)
def action_toggle_log(self) -> None:
self.query_one("#gitlog").toggle_class("hidden")
async def action_refresh(self) -> None:
self._files_key = self._ph_key = self._diff_key = self._history_key = None
await self.refresh_state(force=True)
def action_open_editor(self, path: Optional[str] = None) -> None:
if self.git is None:
return
target = path or self.git.path
code = shutil.which("code")
try:
if code:
if IS_WIN:
subprocess.Popen(f'"{code}" "{target}"', shell=True, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW)
else:
subprocess.Popen([code, target], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
self.notify(f"Opening in VS Code: {os.path.basename(target)}")
return
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
if editor:
with self.suspend():
subprocess.call(f'{editor} "{target}"', shell=True)
return
except (OSError, SuspendNotSupported) as e:
self.notify(f"Couldn't open the editor: {e}", severity="error")
return
self.notify("Couldn't find VS Code ('code' command) or an EDITOR environment variable.",
severity="warning")
def action_open_folder(self) -> None:
if self.git is None:
return
try:
if IS_WIN:
os.startfile(self.git.path) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", self.git.path])
elif shutil.which("xdg-open"):
subprocess.Popen(["xdg-open", self.git.path], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
else:
self.notify("No file manager found (xdg-open isn't installed).", severity="warning")
except OSError as e:
self.notify(f"Couldn't open the folder: {e}", severity="error")
# ---- running operations ------------------------------------------------
def idle_or_warn(self) -> bool:
if self.git is None or self.state is None:
self.notify("Open a repository first.", severity="warning")
return False
if self.busy:
self.notify(f"Still busy ({self.busy_label.lower()}) - try again in a moment.", severity="warning")
return False
return True
def start_op(self, label: str, flow: Callable) -> None:
if self.git is None or self.state is None:
self.notify("Open a repository first.", severity="warning")
return
if self.busy:
self.notify("Still working on the last action…", severity="warning")
return
self.set_busy(True, label)
self.run_worker(self._run_op(flow), exit_on_error=False)
async def _run_op(self, flow: Callable) -> None:
try:
await flow()
except Exception as e: # keep the app alive whatever happens
await self.error_dialog("Something went wrong", f"{type(e).__name__}: {e}")
finally:
self.set_busy(False)
await self.refresh_state(force=True)
self.update_remote_button()
async def ask(self, title: str, body, buttons, cancel=None) -> str:
return await self.push_screen_wait(ChoiceDialog(title, body, buttons, cancel))
async def error_dialog(self, title: str, message: str) -> None:
await self.ask(title, Text(message.strip(), style="#f97583"), [("ok", "OK", "primary")])
async def fail(self, title: str, r: Result, hint: str = "") -> None:
"""Show a git failure, unless the user has already seen it (e.g. the lock popup)."""
if r.handled:
return
hint = hint or hint_for(r.message)
await self.error_dialog(title, r.message + (f"\n\n{hint}" if hint else ""))
async def gitop(self, *args, input: Optional[str] = None) -> Result:
"""Run a git command that changes the repository.
If it fails because another program (GitHub Desktop, VS Code, Unity, a
terminal...) has the repository locked, and the failed attempt didn't
change anything, wait a moment and try again. If the lock doesn't go
away, ask the user what to do.
"""
while True:
r = await self._gitop_retrying(args, input)
if r.ok or not r.lockish:
return r
choice = await self.lock_dialog(r)
if choice == "remove":
try:
os.remove(self.git.index_lock_path())
self._log_cmd(["(gitdesk removed .git/index.lock)"], Result(0))
except OSError as e:
await self.error_dialog("Couldn't remove the lock file", str(e))
r.handled = True
return r
elif choice != "retry":
r.handled = True
return r
async def _gitop_retrying(self, args, input) -> Result:
before = await asyncio.to_thread(self.git.snapshot)
for delay in (0.25, 0.5, 0.75, 1.0, 1.5, None):
r = await self.bg(*args, input=input)
if r.ok or not looks_lockish(r):
return r
after = await asyncio.to_thread(self.git.snapshot)
if after != before:
return r # it changed something - never repeat blindly
r.lockish = True
if delay is None:
return r
await asyncio.sleep(delay)
return r
async def lock_dialog(self, r: Result) -> str:
lock = self.git.index_lock_path()
body = Text()
detail = STASH_KEPT_RE.sub("", f"{r.err}\n{r.out}").strip()
if detail:
body.append(detail + "\n\n", style="#f97583")
if os.path.exists(lock):
try:
age = ago(os.path.getmtime(lock))
except OSError:
age = "a moment ago"
body.append("Another git program is using this repository right now, so git couldn't "
"update it. The lock file ")
body.append(".git/index.lock", style="bold")
body.append(f" was created {age}.\n\n")
body.append("If GitHub Desktop, VS Code, Unity or another terminal is busy with this repository, "
"wait for it to finish and try again. If nothing else is running, the lock was "
"left behind by a git program that crashed, and it's safe to remove it.", style=C_MUTED)
buttons = [("retry", "Try again", "primary"), ("remove", "Remove lock file & retry", "warning"),
("close", "Close", "default")]
else:
body.append("Another program had the repository busy, so git couldn't update it. This is usually "
"GitHub Desktop, VS Code, Unity, an antivirus scan, or a sync tool (OneDrive, Dropbox) "
"briefly holding the repository's files. Wait a moment and try again.", style=C_MUTED)
buttons = [("retry", "Try again", "primary"), ("close", "Close", "default")]
return await self.ask("The repository is busy", body, buttons, cancel="close")
async def net(self, args: List[str], what: str, push: Optional[Tuple[str, List[str]]] = None) -> bool:
"""Run a network git command in the background; on failure offer to retry.
push=(remote, push args): if an HTTPS push is refused as too big (HTTP 413),
offer to push over SSH instead.
"""
while True:
async with self._net_lock:
r = await self.bg(*args, serialize=False)
if r.ok:
return True
if push and too_large(r) and self.push_url(push[0]).startswith(("http://", "https://")):
return await self.ssh_push_flow(push[0], push[1], r)
tail = "\n".join(r.message.splitlines()[-10:])
body = Text.assemble((tail, "#f97583"), "\n\n",
("If git needs a password or token, an SSH passphrase, or wants you to "
"confirm the server's key, 'Retry in terminal' lets it ask you.", C_MUTED))
choice = await self.ask(f"Couldn't {what}", body,
[("retry", "Try again", "default"),
("terminal", "Retry in terminal", "primary"),
("close", "Close", "default")], cancel="close")
if choice == "terminal":
return self.run_in_terminal(args)
if choice != "retry":
return False
def push_url(self, remote: str) -> str:
r = self.git.run("config", "--get", f"remote.{remote}.pushurl", log=False)
return r.out.strip() if r.ok and r.out.strip() else (self.state.remote_urls.get(remote, "") if self.state else "")
async def ssh_push_flow(self, remote: str, push_args: List[str], failed: Result) -> bool:
saved = self.git.run("config", "--get", "gitdesk.sshUrl", log=False).out.strip()
suggestion = saved or ssh_url_for(self.push_url(remote))
detail = "\n".join(line for line in failed.message.splitlines()
if "413" in line or "RPC failed" in line or "hung up" in line)[-600:]
res = await self.push_screen_wait(SshPushDialog(detail or failed.message[-600:], suggestion))
if not res:
return False
url, always = res
await self.bg("config", "gitdesk.sshUrl", url) # prefilled next time
prefix: List[str] = []
if not await asyncio.to_thread(user_ssh_command_set, self.git.path):
# fail fast (instead of hanging for minutes) if the SSH port isn't reachable
prefix = ["-c", "core.sshCommand=ssh -o ConnectTimeout=15"]
if always:
r = await self.gitop("remote", "set-url", "--push", remote, url)
if not r.ok:
await self.fail("Couldn't save the SSH push address", r)
return False
args = prefix + ["push", *push_args]
else:
args = prefix + ["-c", f"remote.{remote}.pushurl={url}", "push", *push_args]
ok = await self.net(args, f"push to {remote} over SSH")
if ok and always:
self.notify("Pushed over SSH. This repository will push over SSH from now on; fetching still "
"uses HTTPS.", timeout=10)
elif not ok and always:
# didn't work - put pushing back the way it was
await self.gitop("config", "--unset", f"remote.{remote}.pushurl")
return ok
def run_in_terminal(self, args: List[str], cwd: Optional[str] = None) -> bool:
rc = 1
try:
with self.suspend():
print(f"\n[gitdesk] git {' '.join(args)}\n", flush=True)
rc = subprocess.call(["git", *args], cwd=cwd or self.git.path)
print("\n[gitdesk] " + ("Done." if rc == 0 else f"git exited with code {rc}."))
try:
input("Press Enter to go back to gitdesk...")
except EOFError:
pass
except SuspendNotSupported:
self.notify("This terminal can't hand over to git for a prompt.", severity="error")
return False
self._log_cmd(args, Result(rc, "(ran in the terminal)"))
return rc == 0
async def auto_fetch(self) -> None:
"""Quiet background fetch. Never blocks other actions and never pops up dialogs."""
if self.busy or self.git is None or self.state is None or not self.state.remotes:
return
if self._net_lock.locked():
return
async with self._net_lock:
self.fetching = True
self.update_remote_button()
try:
r = await self.bg("fetch", "--prune", self.remote_name(), serialize=False)
if r.ok:
self.last_fetch = time.time()
finally:
self.fetching = False
await self.refresh_state(force=True)
self.update_remote_button()
async def do_fetch(self) -> bool:
remote = self.remote_name()
ok = await self.net(["fetch", "--prune", remote], f"fetch from {remote}")
if ok:
self.last_fetch = time.time()
return ok
def blocked_by_merge(self, st: RepoState) -> bool:
if st.merging or st.conflicts or st.rebasing:
self.notify("Finish resolving the conflicts (or abort the merge) first.", severity="warning")
return True
return False
# ---- flows -------------------------------------------------------------
async def fetch_flow(self) -> None:
if not self.state.remotes:
self.notify("This repository has no remote to fetch from.", severity="warning")
return
if await self.do_fetch():
self.notify(f"Fetched {self.remote_name()}.")
async def pull_flow(self) -> None:
st = self.state
if self.blocked_by_merge(st):
return
if st.branch is None:
self.notify("You're not on a branch, so there's nothing to pull.", severity="warning")
return
if not st.has_upstream:
self.notify(f"'{st.branch}' isn't on {self.remote_name()} yet. Publish it first.", severity="warning")
return
if await self.do_fetch():
await self.merge_upstream()
async def merge_upstream(self) -> bool:
"""Merge the upstream branch in, offering to stash if local changes are in the way."""
st = await self.load_state()
if st.behind == 0:
self.notify("Already up to date.")
return True
n = st.behind
remote = self.remote_name()
r = await self.gitop("merge", "--no-edit", st.upstream)
if r.ok:
self.notify(f"Pulled {plural(n, 'commit')} from {remote}.")
return True
st = await self.load_state()
if st.merging:
await self.conflict_dialog(auto_stashed=False)
return False
if not st.files:
await self.fail("Pull failed", r)
return False
# git refused because uncommitted changes are in the way
incoming = await asyncio.to_thread(self.git.changed_between, "HEAD", st.upstream)
local = {f.path for f in st.files}
blocking = sorted(local & incoming) or sorted(local)
body = Text("Unable to pull when changes are present on your branch. "
"These files would be overwritten:\n\n")
for p in blocking[:12]:
body.append(f" {p}\n", style="bold")
if len(blocking) > 12:
body.append(f" … and {len(blocking) - 12} more\n", style=C_MUTED)
body.append("\nYou can stash your changes now, pull, and then they'll be put back automatically.")
choice = await self.ask("Your changes are in the way", body,
[("stash", "Stash, pull & re-apply", "primary"), ("close", "Close", "default")])
if choice != "stash":
return False
if not await self.stash_push("gitdesk: auto-stash before pull"):
return False
r = await self.gitop("merge", "--no-edit", st.upstream)
if not r.ok:
st = await self.load_state()
if st.merging:
await self.conflict_dialog(auto_stashed=True)
return False
await self.fail("Pull failed", r)
await self.stash_pop("stash@{0}")
return False
self.notify(f"Pulled {plural(n, 'commit')} from {remote}.")
await self.stash_pop("stash@{0}")
return True
async def conflict_dialog(self, auto_stashed: bool) -> None:
st = await self.load_state()
body = Text("Your commits and the commits you pulled changed the same lines in:\n\n")
for f in st.conflicts[:12]:
body.append(f" {f.path}\n", style="bold")
body.append("\nTo fix it, click each file on the left, keep the parts you want and delete the "
"<<<<<<< ======= >>>>>>> marker lines (Open in editor helps). Then click "
"'Mark as resolved' and commit the merge.\n\nOr abort to put everything back the "
"way it was before the pull.")
if auto_stashed:
body.append("\n\nYour own uncommitted changes are safe in Stashed changes.", style=C_MUTED)
choice = await self.ask("Merge conflicts", body,
[("resolve", "Resolve conflicts", "primary"), ("abort", "Abort merge", "error")],
cancel="resolve")
if choice == "abort":
r = await self.gitop("merge", "--abort")
if not r.ok:
await self.fail("Couldn't abort the merge", r)
return
self.notify("Merge aborted - nothing was changed.")
if auto_stashed:
await self.stash_pop("stash@{0}")
elif auto_stashed:
self.notify("After you commit the merge, bring your changes back from Stashed changes.",
timeout=10)
async def stash_push(self, message: Optional[str], paths: Optional[List[str]] = None) -> bool:
"""Stash changes (including new files). Returns True if a stash entry was created."""
st = await self.load_state()
if st.oid is None:
await self.error_dialog("Can't stash yet",
"git can only stash once the repository has at least one commit. "
"Make a first commit, then stash.")
return False
if st.conflicts:
self.notify("Resolve the conflicts first - git can't stash files that are mid-merge.",
severity="warning")
return False
before = self.git.stash_count()
args = ["stash", "push", "-u"] + (["-m", message] if message else [])
if paths:
args = ["--literal-pathspecs", *args, "--", *paths]
r = await self.gitop(*args)
created = self.git.stash_count() > before
if created and not r.ok:
# The stash was saved but git hit a problem afterwards (often a file that
# another program - Unity, an editor - has open on Windows).
await self.error_dialog(
"Stashed, but git reported a problem",
r.message + "\n\nYour changes ARE saved in Stashed changes. Some files may not have been "
"removed from your folder - close any program using them and check the Changes list.")
return True
if not r.ok:
await self.fail("Couldn't stash your changes", r)
return False
if not created:
self.notify("There was nothing to stash.", severity="warning")
return False
return True
async def stash_pop(self, ref: str, keep: bool = False) -> bool:
st = await self.load_state()
if st.conflicts or st.merging:
self.notify("Resolve the current conflicts (or abort the merge) first.", severity="warning")
return False
r = await self.gitop("stash", "apply" if keep else "pop", ref)
if r.ok:
self.notify("Your stashed changes are back." if not keep else "Applied - the stash is still saved too.")
return True
if r.handled:
return False
st = await self.load_state()
if st.conflicts:
body = Text("Your stashed changes clash with the current version of:\n\n")
for f in st.conflicts[:12]:
body.append(f" {f.path}\n", style="bold")
body.append("\nThe clashing parts are marked inside those files with <<<<<<< / ======= / >>>>>>>. "
"Edit each file to keep what you want, then click 'Mark as resolved'.\n\n"
"The stash itself was kept, so nothing is lost - drop it from Stashed changes "
"once you're happy.")
await self.ask("Stashed changes conflict", body, [("ok", "OK", "primary")])
else:
msg = r.message
if "would be overwritten" in msg:
hint = ("Some of the stashed files also have changes in your folder right now. Commit or "
"stash your current changes first, then bring this stash back.")
elif "already exists" in msg:
hint = ("Some new files in the stash already exist in your folder. Move or delete those "
"files, then try again.")
else:
hint = ""
await self.error_dialog("Couldn't bring back your stashed changes",
msg + (f"\n\n{hint}" if hint else "") + "\n\nThe stash was kept, so nothing is lost.")
return False
async def push_flow(self, auto_pull: bool = False) -> None:
st = self.state
if self.blocked_by_merge(st):
return
if st.branch is None:
self.notify("You're not on a branch, so there's nothing to push.", severity="warning")
return
if not st.remotes:
self.notify("This repository has no remote. Add one with: git remote add origin <url>",
severity="warning")
return
if st.oid is None:
self.notify("Make a commit first - there's nothing to push yet.", severity="warning")
return
remote = self.remote_name()
if not st.has_upstream:
if await self.net(["push", "-u", remote, st.branch], f"publish to {remote}",
push=(remote, ["-u", remote, st.branch])):
self.notify(f"Published '{st.branch}' to {remote}.")
return
if not await self.do_fetch():
return
st = await self.load_state()
if st.behind:
if not auto_pull:
choice = await self.ask(
"Newer commits on the remote",
f"{remote} has {plural(st.behind, 'commit')} on '{st.branch}' that you don't have yet, "
"so a push would be rejected.\n\nPull them first, then push?",
[("pull", "Pull, then push", "primary"), ("close", "Cancel", "default")])
if choice != "pull":
return
if not await self.merge_upstream():
return
st = await self.load_state()
if st.ahead == 0:
self.notify("Nothing to push - you're up to date.")
return
target = st.upstream[len(remote) + 1:]
n = st.ahead
if await self.net(["push", remote, f"HEAD:refs/heads/{target}"], f"push to {remote}",
push=(remote, [remote, f"HEAD:refs/heads/{target}"])):
self.notify(f"Pushed {plural(n, 'commit')} to {remote}.")
async def ensure_identity(self, cwd: str) -> bool:
"""Make sure git knows the user's name and email (a fresh install doesn't)."""
name = run_git(cwd, ["config", "user.name"]).out.strip()
email = run_git(cwd, ["config", "user.email"]).out.strip()
if name and email:
return True
res = await self.push_screen_wait(IdentityDialog(name, email))
if not res:
self.notify("git needs your name and email before it can commit.", severity="warning")
return False
for key, value in zip(("user.name", "user.email"), res):
r = run_git(cwd, ["config", "--global", key, value])
if not r.ok:
await self.fail("Couldn't save your name and email", r)
return False
self._log_cmd(["config", "--global", "user.name / user.email"], Result(0, f"{res[0]} <{res[1]}>"))
self.notify(f"Saved - your commits will be signed {res[0]} <{res[1]}>.")
return True
async def commit_flow(self) -> None:
if not await self.ensure_identity(self.git.path):
return
st = await self.load_state()
summary = self.query_one("#summary", Input).value.strip()
desc = self.query_one("#description", TextArea).text.strip()
if st.conflicts:
self.notify("Resolve the conflicts first (click each ! file and Mark as resolved).",
severity="warning")
return
message = summary + (f"\n\n{desc}" if desc else "")
if st.merging:
r = await self.gitop("add", "-A")
if r.ok:
if summary:
r = await self.gitop("commit", "--cleanup=whitespace", "-F", "-", input=message)
else:
r = await self.gitop("commit", "--no-edit")
else:
files = [f for f in st.files if f.path not in self.excluded]
if not files:
self.notify("Tick at least one file to commit.", severity="warning")
return
if not summary:
self.notify("Write a summary for the commit.", severity="warning")
return
if len(files) == len(st.files):
r = await self.gitop("add", "-A")
else:
# Commit only the ticked files: reset the index, then add just those.
if st.oid:
r = await self.gitop("reset", "-q")
else:
r = await self.gitop("rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".")
if r.ok:
now = await self.load_state()
present = {f.path for f in now.files}
# A "deleted" file that's still on disk was un-tracked (git rm --cached);
# the reset above undid that, so redo it rather than adding the file back.
untrack = [f.path for f in files if f.kind == "D"
and os.path.exists(os.path.join(self.git.path, f.path))]
specs = []
for f in files:
if f.path in untrack:
continue
specs += [p for p in (f.path, f.orig) if p and p in present]
if untrack:
r = await self.gitop("--literal-pathspecs", "rm", "-q", "--cached", "--", *untrack)
if not r.ok:
await self.fail("Commit failed", r)
return
if not specs and untrack:
r = await self.gitop("commit", "--cleanup=whitespace", "-F", "-", input=message)
if not r.ok:
await self.fail("Commit failed", r)
return
self.query_one("#summary", Input).value = ""
self.query_one("#description", TextArea).text = ""
self.excluded.clear()
self.notify(f"Committed: {summary}")
return
if not specs:
self.notify("The ticked files have no changes left to commit.", severity="warning")
return
r = await self.gitop("--literal-pathspecs", "add", "-A", "--pathspec-from-file=-",
"--pathspec-file-nul", input="\0".join(specs))
if r.ok:
r = await self.gitop("commit", "--cleanup=whitespace", "-F", "-", input=message)
if not r.ok:
await self.fail("Commit failed", r)
return
self.query_one("#summary", Input).value = ""
self.query_one("#description", TextArea).text = ""
self.excluded.clear()
self.notify(f"Committed: {summary or 'merge'}")
async def mark_resolved_flow(self) -> None:
st = self.state
fc = next((f for f in st.files if f.path == self.selected), None)
if fc is None or not fc.is_conflict:
return
if await asyncio.to_thread(file_has_markers, os.path.join(self.git.path, fc.path)):
choice = await self.ask(
"Conflict markers are still in this file",
f"{fc.path} still has <<<<<<< / >>>>>>> lines in it. Mark it as resolved anyway?",
[("yes", "Mark as resolved", "warning"), ("no", "Cancel", "default")])
if choice != "yes":
return
r = await self.gitop("--literal-pathspecs", "add", "--", fc.path)
if r.ok and not st.merging and st.oid:
# a stash conflict: leave the file as a normal (unstaged) change
r = await self.gitop("--literal-pathspecs", "restore", "--staged", "--", fc.path)
if r.ok:
self.notify(f"Marked {fc.path} as resolved.")
else:
await self.fail("Couldn't mark it as resolved", r)
async def abort_merge_flow(self) -> None:
choice = await self.ask(
"Abort the merge?",
"This throws away the merge and any conflict fixes you've made, and puts the branch back "
"the way it was before the pull.",
[("abort", "Abort merge", "error"), ("cancel", "Cancel", "default")])
if choice != "abort":
return
r = await self.gitop("merge", "--abort")
if r.ok:
self.notify("Merge aborted.")
else:
await self.fail("Couldn't abort the merge", r)
async def stash_all_flow(self) -> None:
if not self.idle_or_warn():
return
if not self.state.files:
self.notify("There's nothing to stash.", severity="warning")
return
name = await self.push_screen_wait(InputDialog(
"Stash all changes",
"Puts all your uncommitted changes (including new files) aside so you have a clean "
"working folder. Give the stash a name if you like:",
"Stash changes", placeholder="e.g. half-done README edit"))
if name is None:
return
async def flow():
if await self.stash_push(name.strip() or None):
self.notify("Changes stashed. Bring them back from Stashed changes.")
self.start_op("Stashing", flow)
async def discard_flow(self, files: List[FileChange]) -> None:
st = self.state
if not self.idle_or_warn():
return
if not files:
self.notify("Tick the files you want to discard first.", severity="warning")
return
if st.merging or st.conflicts:
self.notify("Finish resolving the conflicts (or abort the merge) first.", severity="warning")
return
if st.oid is None:
self.notify("Discard needs at least one commit in the repository.", severity="warning")
return
n = len(files)
body = Text("These changes will be removed from your folder:\n\n")
for f in files[:12]:
body.append(f" {f.path}\n", style="bold")
if n > 12:
body.append(f" … and {n - 12} more\n", style=C_MUTED)
body.append("\ngitdesk keeps a backup as a stash named 'gitdesk: discarded …', so you can get "
"them back from Stashed changes if you change your mind.", style=C_MUTED)
choice = await self.ask(f"Discard changes to {plural(n, 'file')}?", body,
[("discard", "Discard changes", "error"), ("cancel", "Cancel", "default")],
cancel="cancel")
if choice != "discard":
return
paths: List[str] = []
for f in files:
paths.append(f.path)
if f.orig:
paths.append(f.orig)
everything = n == len(st.files)
if not everything and sum(len(p) + 3 for p in paths) > 20000:
self.notify("That's too many files to discard in one go - tick fewer.", severity="warning")
return
label = files[0].path if n == 1 else plural(n, "file")
async def flow():
limit = None
if not everything:
# A path-limited stash records the whole index, so unstage first (contents are
# untouched); then only the chosen files end up in the backup stash. This also
# turns staged renames/additions into plain deletions/new files that stash handles.
r = await self.gitop("reset", "-q")
if not r.ok:
await self.fail("Couldn't discard the changes", r)
return
now = await self.load_state()
present = {f.path for f in now.files}
limit = [p for p in paths if p in present]
if not limit:
self.notify("Those files have no changes left to discard.")
return
if await self.stash_push(f"gitdesk: discarded {label}", paths=limit):
self.notify("Discarded. A backup is in Stashed changes.")
self.start_op("Discarding", flow)
async def stashes_flow(self) -> None:
if not self.idle_or_warn():
return
res = await self.push_screen_wait(StashDialog(self.git))
if not res:
return
action, ref = res
if action == "drop":
choice = await self.ask("Drop this stash?",
f"{ref} will be deleted for good. This can't be undone.",
[("drop", "Drop stash", "error"), ("cancel", "Cancel", "default")])
if choice != "drop":
return
async def drop():
r = await self.gitop("stash", "drop", ref)
if r.ok:
self.notify(f"Dropped {ref}.")
else:
await self.fail("Couldn't drop the stash", r)
self.start_op("Dropping stash", drop)
return
async def restore():
st = await self.load_state()
if st.conflicts:
self.notify("Resolve the current conflicts first.", severity="warning")
return
await self.stash_pop(ref, keep=(action == "apply"))
self.start_op("Restoring stash", restore)
async def branches_flow(self) -> None:
if not self.idle_or_warn():
return
st = self.state
remote = self.remote_name() if st.remotes else None
res = await self.push_screen_wait(BranchDialog(self.git, st.branch, remote))
if not res:
return
action, name, remote_ref = res
if action == "create":
self.start_op("Creating branch", lambda: self.create_branch(name))
else:
self.start_op("Switching branch", lambda: self.switch_branch(name, remote_ref))
async def create_branch(self, name: str) -> None:
r = await self.bg("check-ref-format", "--branch", name)
if not r.ok:
self.notify(f"'{name}' isn't a valid branch name.", severity="error")
return
r = await self.gitop("switch", "-c", name)
if r.ok:
self.notify(f"Created and switched to '{name}'.")
else:
await self.fail("Couldn't create the branch", r)
async def switch_branch(self, name: str, remote_ref: Optional[str]) -> None:
st = self.state
if self.blocked_by_merge(st):
return
args = ["switch", "--track", remote_ref] if remote_ref else ["switch", name]
r = await self.gitop(*args)
if r.ok:
self.notify(f"Switched to '{name}'.")
return
if not st.files:
await self.fail("Couldn't switch branch", r)
return
choice = await self.ask(
"Your changes are in the way",
f"Switching to '{name}' would overwrite some of your uncommitted changes.\n\n"
"Stash them first? They'll be saved under Stashed changes so you can bring them back "
"later, on any branch.",
[("stash", "Stash & switch", "primary"), ("close", "Cancel", "default")])
if choice != "stash" or not await self.stash_push(f"gitdesk: changes from {st.branch}"):
return
r = await self.gitop(*args)
if r.ok:
self.notify(f"Switched to '{name}'. Your changes are saved in Stashed changes.", timeout=8)
else:
await self.fail("Couldn't switch branch", r)
async def open_repo_flow(self, first: bool = False) -> None:
if self.busy:
self.notify("Still busy - try again in a moment.", severity="warning")
return
current = self.git.path if self.git else os.path.abspath(self.start_path or os.getcwd())
error = f"{current} isn't a git repository." if first and self.start_path and self.git is None else ""
while True:
choice = await self.push_screen_wait(RepoDialog(current, first and self.git is None, error))
if choice == "add_local":
if await self.add_local_flow():
return
continue
if choice == "new":
if await self.new_repo_flow():
return
continue
if choice == "clone":
if await self.clone_flow():
return
continue
if not choice:
if first and self.git is None:
self.exit()
return
if await self.load_repo(choice):
self.run_worker(self.auto_fetch(), exit_on_error=False)
return
current, error = choice, f"{choice} isn't a git repository."
# ---- new / clone / remote ---------------------------------------------
async def choose_folder(self, title: str, start: str) -> Optional[str]:
"""System folder picker (Explorer on Windows); the in-terminal browser if there isn't one."""
start = existing_folder(start or "")
status, path = await asyncio.to_thread(pick_folder_native, title, start)
if status == "ok":
return path
if status == "cancel":
return None
return await self.push_screen_wait(FolderBrowser(title, start))
def action_repositories(self) -> None:
self.run_worker(self.open_repo_flow(), exit_on_error=False)
async def add_local_flow(self) -> bool:
if self.busy:
self.notify("Still busy - try again in a moment.", severity="warning")
return False
res = await self.push_screen_wait(AddLocalDialog())
if not res:
return False
action, path = res
if action == "create_here":
return await self.new_repo_flow(parent=os.path.dirname(path), name=os.path.basename(path))
if await self.load_repo(path):
self.run_worker(self.auto_fetch(), exit_on_error=False)
self.notify(f"Added {os.path.basename(self.git.path)}.")
return True
await self.error_dialog("Couldn't open the repository", path)
return False
def action_new_repo(self) -> None:
self.run_worker(self.new_repo_flow(), exit_on_error=False)
def action_clone_repo(self) -> None:
self.run_worker(self.clone_flow(), exit_on_error=False)
def action_add_remote(self) -> None:
self.run_worker(self.add_remote_flow(), exit_on_error=False)
async def new_repo_flow(self, parent: Optional[str] = None, name: str = "") -> bool:
if self.busy:
self.notify("Still busy - try again in a moment.", severity="warning")
return False
o = await self.push_screen_wait(NewRepoDialog(parent or default_parent_folder(), name))
if not o:
return False
await self.ensure_identity(os.path.expanduser("~")) # the first commit needs a name/email
remember_parent_folder(o["parent"])
path = o["path"]
if os.path.isdir(os.path.join(path, ".git")):
choice = await self.ask("That's already a repository", f"{path} is already a git repository. Open it?",
[("open", "Open it", "primary"), ("cancel", "Cancel", "default")])
return choice == "open" and await self.load_repo(path)
self.set_busy(True, "Creating repository")
try:
created, problem = await asyncio.to_thread(create_repository, o)
finally:
self.set_busy(False)
if not created:
await self.error_dialog("Couldn't create the repository", problem)
return False
if not await self.load_repo(path):
await self.error_dialog("Couldn't open the new repository", path)
return False
if problem:
hint = hint_for(problem)
await self.error_dialog("The repository was created, but there was a problem",
problem + (f"\n\n{hint}" if hint else ""))
else:
self.notify(f"Created {o['name']}.")
return True
def _progress_from_thread(self, line: str) -> None:
self.call_from_thread(self._show_progress, line)
def _show_progress(self, line: str) -> None:
m = re.search(r"([A-Za-z][A-Za-z ]+?):\s+(\d+)%", line)
self.busy_detail = f"{m.group(1)} {m.group(2)}%" if m else line[:40]
self.update_remote_button()
async def clone_flow(self) -> bool:
if self.busy:
self.notify("Still busy - try again in a moment.", severity="warning")
return False
res = await self.push_screen_wait(CloneDialog(default_parent_folder()))
if not res:
return False
url, path = res
path = os.path.abspath(path)
parent = os.path.dirname(path)
try:
os.makedirs(parent, exist_ok=True)
except OSError as e:
await self.error_dialog("Couldn't create the folder", str(e))
return False
remember_parent_folder(parent)
args = ["clone", "--progress", url, path]
while True:
self.busy_detail = "Connecting…"
self.set_busy(True, "Cloning")
try:
r = await asyncio.to_thread(run_git_streaming, parent, args, self._progress_from_thread)
finally:
self.busy_detail = ""
self.set_busy(False)
self._log_cmd(args, r)
if r.ok:
break
tail = "\n".join(r.message.splitlines()[-10:])
body = Text.assemble((tail, "#f97583"), "\n\n",
("If the server needs a password or token, an SSH passphrase, or wants you to "
"confirm its key, 'Retry in terminal' lets git ask you.", C_MUTED))
choice = await self.ask("Couldn't clone the repository", body,
[("retry", "Try again", "default"), ("terminal", "Retry in terminal", "primary"),
("close", "Close", "default")], cancel="close")
if choice == "terminal":
if self.run_in_terminal(["clone", url, path], cwd=parent):
break
return False
if choice != "retry":
return False
if not await self.load_repo(path):
await self.error_dialog("Cloned, but couldn't open it", path)
return False
self.last_fetch = time.time()
self.update_remote_button()
self.notify(f"Cloned {os.path.basename(path)}.")
return True
async def add_remote_flow(self) -> None:
if not self.idle_or_warn():
return
st = self.state
name = self.remote_name() if st.remotes else "origin"
current = st.remote_urls.get(name, "")
url = await self.push_screen_wait(InputDialog(
"Remote URL",
f"Paste the address of this repository on GitHub, Gitea, etc. It's saved as the '{name}' remote.\n"
"If it doesn't exist there yet, create an empty repository on the website first "
"(without a README), then paste its URL here.",
"Save", placeholder="e.g. [email protected]:you/project.git", value=current))
if url is None or not url.strip() or url.strip() == current:
return
url = url.strip()
async def flow():
args = ["remote", "set-url", name, url] if name in st.remotes else ["remote", "add", name, url]
r = await self.gitop(*args)
if r.ok:
self._web_cache_key = None
self.notify(f"Saved the {name} remote." + ("" if st.has_upstream else
" Click Publish branch to push your commits."))
else:
await self.fail("Couldn't save the remote", r)
self.start_op("Saving remote", flow)
async def actions_flow(self) -> None:
action = await self.push_screen_wait(ActionsDialog())
if action:
await self.run_action(action)
def selftest() -> int:
"""Start the UI headless for a moment and quit - proves every module made it into a build."""
import tempfile
async def autopilot(pilot) -> None:
await pilot.pause(0.5)
pilot.app.exit(0 if pilot.app.query("#tb-repo") else 1)
nowhere = os.path.join(tempfile.gettempdir(), "gitdesk-selftest-no-such-folder")
rc = GitDesk(nowhere).run(headless=True, size=(120, 40), auto_pilot=autopilot)
print("gitdesk selftest ok" if rc == 0 else f"gitdesk selftest failed ({rc})")
return 0 if rc == 0 else 1
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--selftest":
sys.exit(selftest())
if find_git() is None and not offer_git_install():
sys.exit(1)
ver = git_version()
if ver < (2, 25):
print(f"(found git {ver[0]}.{ver[1]})")
if not offer_git_install(update=True):
sys.exit(1)
start = sys.argv[1] if len(sys.argv) > 1 else None
if start in ("-h", "--help"):
print(__doc__)
return
GitDesk(start).run()
if __name__ == "__main__":
main()