add gitdesk to detect updates

This commit is contained in:
LeonL committed 2026-11-06 12:44:06 -08:00
1 parent 96c354e35a
commit d1b990f9f9
2 files changed
+239 -5

No files matched your search

+10 -1
View File
@@ -4,9 +4,18 @@ A GitHub Desktop-style git client that runs in your terminal. Use the mouse or t
## Setup ## Setup
1. Install Python 3.9+ and git (2.25 or newer). 1. Install Python 3.9+. That's not needed if you use the built exe.
2. Install the one dependency: `python -m pip install -r requirements.txt` 2. Install the one dependency: `python -m pip install -r requirements.txt`
(or just run gitdesk once and say yes when it offers to install it). (or just run gitdesk once and say yes when it offers to install it).
3. **git:** if it isn't installed, gitdesk offers to install it for you when it starts.
It does the same if your git is older than 2.25.
- **Windows:** installs Git for Windows (which includes Git LFS) with `winget`, after
the usual permission prompt. gitdesk picks it up straight away. Without winget, it opens
the download page.
- **Linux:** installs `git` and `git-lfs` with apt, dnf, yum, pacman, zypper or apk, using sudo.
- **macOS:** uses Homebrew, or Apple's command line tools.
4. **Your name and email:** the first time you commit or create a repository, gitdesk asks
for them if git doesn't know them yet. They're saved for all your repositories.
## Build a standalone executable (optional) ## Build a standalone executable (optional)
+229 -4
View File
@@ -232,6 +232,154 @@ def hint_for(message: str) -> str:
return "" 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, ...]: def git_version() -> Tuple[int, ...]:
r = run_git(os.getcwd(), ["--version"]) r = run_git(os.getcwd(), ["--version"])
m = re.search(r"(\d+)\.(\d+)", r.out) m = re.search(r"(\d+)\.(\d+)", r.out)
@@ -1970,6 +2118,61 @@ class CloneDialog(ChooseFolderMixin, ModalScreen):
self.dismiss(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): class SshPushDialog(ModalScreen):
"""Offered when an HTTPS push is refused as too large. Returns (ssh_url, always) or None.""" """Offered when an HTTPS push is refused as too large. Returns (ssh_url, always) or None."""
BINDINGS = [Binding("escape", "cancel", "Cancel")] BINDINGS = [Binding("escape", "cancel", "Cancel")]
@@ -3521,7 +3724,28 @@ class GitDesk(App):
push=(remote, [remote, f"HEAD:refs/heads/{target}"])): push=(remote, [remote, f"HEAD:refs/heads/{target}"])):
self.notify(f"Pushed {plural(n, 'commit')} to {remote}.") 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: async def commit_flow(self) -> None:
if not await self.ensure_identity(self.git.path):
return
st = await self.load_state() st = await self.load_state()
summary = self.query_one("#summary", Input).value.strip() summary = self.query_one("#summary", Input).value.strip()
desc = self.query_one("#description", TextArea).text.strip() desc = self.query_one("#description", TextArea).text.strip()
@@ -3864,6 +4088,7 @@ class GitDesk(App):
o = await self.push_screen_wait(NewRepoDialog(parent or default_parent_folder(), name)) o = await self.push_screen_wait(NewRepoDialog(parent or default_parent_folder(), name))
if not o: if not o:
return False return False
await self.ensure_identity(os.path.expanduser("~")) # the first commit needs a name/email
remember_parent_folder(o["parent"]) remember_parent_folder(o["parent"])
path = o["path"] path = o["path"]
if os.path.isdir(os.path.join(path, ".git")): if os.path.isdir(os.path.join(path, ".git")):
@@ -3996,13 +4221,13 @@ def selftest() -> int:
def main() -> None: def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--selftest": if len(sys.argv) > 1 and sys.argv[1] == "--selftest":
sys.exit(selftest()) sys.exit(selftest())
if shutil.which("git") is None: if find_git() is None and not offer_git_install():
print("gitdesk: git isn't installed or isn't in your PATH.")
sys.exit(1) sys.exit(1)
ver = git_version() ver = git_version()
if ver < (2, 25): if ver < (2, 25):
print(f"gitdesk needs git 2.25 or newer (found {ver[0]}.{ver[1]}). Please update git.") print(f"(found git {ver[0]}.{ver[1]})")
sys.exit(1) if not offer_git_install(update=True):
sys.exit(1)
start = sys.argv[1] if len(sys.argv) > 1 else None start = sys.argv[1] if len(sys.argv) > 1 else None
if start in ("-h", "--help"): if start in ("-h", "--help"):
print(__doc__) print(__doc__)