add gitdesk to detect updates
This commit is contained in:
1 parent
96c354e35a
commit
d1b990f9f9
2 files changed
+238
-4
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
+228
-3
@@ -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="[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")]
|
||||
@@ -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,12 +4221,12 @@ 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.")
|
||||
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"):
|
||||
|
||||
Reference in new issue
Block a user