diff --git a/README.md b/README.md index cb6dfa3..25110d2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,18 @@ A GitHub Desktop-style git client that runs in your terminal. Use the mouse or t ## 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` (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) diff --git a/gitdesk.py b/gitdesk.py index 6ac804a..8b690eb 100644 --- a/gitdesk.py +++ b/gitdesk.py @@ -232,6 +232,154 @@ def hint_for(message: str) -> str: 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) @@ -1970,6 +2118,61 @@ class CloneDialog(ChooseFolderMixin, ModalScreen): 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="you@example.com", 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")] @@ -3521,7 +3724,28 @@ class GitDesk(App): 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() @@ -3864,6 +4088,7 @@ class GitDesk(App): 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")): @@ -3996,13 +4221,13 @@ def selftest() -> int: def main() -> None: if len(sys.argv) > 1 and sys.argv[1] == "--selftest": sys.exit(selftest()) - if shutil.which("git") is None: - print("gitdesk: git isn't installed or isn't in your PATH.") + if find_git() is None and not offer_git_install(): sys.exit(1) ver = git_version() if ver < (2, 25): - print(f"gitdesk needs git 2.25 or newer (found {ver[0]}.{ver[1]}). Please update git.") - sys.exit(1) + 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__)