diff --git a/README.md b/README.md index 4278769..41193c7 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ Tip: put this folder on your PATH (Windows), or symlink it on Linux/macOS while fetches stay on HTTPS. - If the site is behind Cloudflare, SSH needs an address that reaches the server directly: a DNS-only hostname or the server's IP. +- **"Filename too long" (Windows):** Unity projects often have paths past Windows' 260-character + limit. As soon as git reports one, gitdesk runs `git config --global core.longpaths true` + and tells you. Then: + - if the action hadn't changed anything yet (stash, commit, refresh), it runs it again; + - if it got part-way (e.g. switched branch but couldn't write some files), it re-creates + just the missing files; + - if a clone downloaded but couldn't write its files, it writes them and opens the repo. - **Another program has the repository busy** (GitHub Desktop, VS Code, Unity, OneDrive, antivirus): gitdesk waits and retries for a few seconds. If a `.git/index.lock` is left behind by a crashed git, it offers **Remove lock file & retry**. diff --git a/gitdesk.py b/gitdesk.py index 8b690eb..2a1c095 100644 --- a/gitdesk.py +++ b/gitdesk.py @@ -165,6 +165,7 @@ class Result: out: str = "" err: str = "" lockish: bool = False # failed because another program had the repository locked + longpath_fixed: bool = False # hit "Filename too long", and core.longpaths was just turned on handled: bool = False # the user has already been shown this failure @property @@ -220,8 +221,14 @@ def user_ssh_command_set(path: str) -> bool: def hint_for(message: str) -> str: """Plain-English next step for common git errors.""" if "Filename too long" in message: + if LONGPATHS["on"]: + return ("Long path support (core.longpaths) is on, so this path is too long for something else - " + "a folder name may be over 255 characters, or another program can't handle it. Moving the " + "repository to a shorter folder (e.g. C:\\dev\\) usually helps.") 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 "File name too long" in message: + return "A file or folder name is longer than this file system allows (usually 255 characters)." 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' @@ -434,7 +441,75 @@ class GitError(Exception): pass -def run_git(cwd: str, args, input: Optional[str] = None, env=None) -> Result: +# ---- Windows long paths ------------------------------------------------------ +# Windows limits paths to 260 characters unless git is told to work around it. +# When git says "Filename too long", gitdesk turns on core.longpaths (globally), +# re-runs read-only commands straight away, and lets gitop() recover the rest. +AUTO_LONGPATHS = IS_WIN +LONGPATH_RE = re.compile(r"Filename too long", re.I) +UNWRITTEN_RE = re.compile(r"unable to create file (.+?): Filename too long" + r"|cannot create directory at '(.+?)': Filename too long", re.I) +LONGPATHS = {"on": False, "just_enabled": False} +READ_ONLY_COMMANDS = {"status", "diff", "log", "ls-files", "show", "rev-parse", "rev-list", "for-each-ref", + "remote", "check-ignore", "cat-file", "config"} + + +def _subcommand(args) -> Tuple[str, List[str]]: + i = 0 + while i < len(args): + a = args[i] + if a == "-c": + i += 2 + elif a.startswith("-"): + i += 1 + else: + return a, list(args[i + 1:]) + return "", [] + + +def is_read_only(args) -> bool: + sub, rest = _subcommand(args) + if sub == "stash": + return bool(rest) and rest[0] in ("list", "show") + return sub in READ_ONLY_COMMANDS + + +def enable_longpaths() -> bool: + """Run `git config --global core.longpaths true`. True if it was off and is now on.""" + if LONGPATHS["on"]: + return False + home = os.path.expanduser("~") + current = run_git(home, ["config", "--global", "--get", "core.longpaths"], longpath_check=False) + if current.out.strip().lower() in ("true", "yes", "on", "1"): + LONGPATHS["on"] = True # already on - the problem is something else + return False + r = run_git(home, ["config", "--global", "core.longpaths", "true"], longpath_check=False) + if r.ok: + LONGPATHS.update(on=True, just_enabled=True) + return True + return False + + +def long_path_exists(path: str) -> bool: + """os.path.exists that also works for paths over 260 characters on Windows.""" + if IS_WIN: + full = os.path.abspath(path) + if not full.startswith("\\\\?\\"): + full = "\\\\?\\" + full + return os.path.exists(full) + return os.path.exists(path) + + +def run_git(cwd: str, args, input: Optional[str] = None, env=None, longpath_check: bool = True) -> Result: + r = _run_git_once(cwd, args, input, env) + if longpath_check and AUTO_LONGPATHS and LONGPATH_RE.search(f"{r.err}\n{r.out}") and enable_longpaths(): + if is_read_only(args): # safe to just ask again + r = _run_git_once(cwd, args, input, env) + r.longpath_fixed = True + return r + + +def _run_git_once(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) @@ -544,6 +619,22 @@ class Git: 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 restore_unwritten(self, r: Result) -> List[str]: + """Re-create files git couldn't write because their paths were too long. + + Only paths that don't exist on disk are touched, so nothing of the user's + is overwritten. Returns the paths restored. + """ + paths = [] + for m in UNWRITTEN_RE.finditer(f"{r.err}\n{r.out}"): + p = (m.group(1) or m.group(2) or "").strip().strip("'\"") + if p and p not in paths and not long_path_exists(os.path.join(self.path, p)): + paths.append(p) + if not paths: + return [] + rr = self.run("--literal-pathspecs", "restore", "--worktree", "--", *paths[:500]) + return paths if rr.ok else [] + def index_lock_path(self) -> str: return os.path.join(self.git_dir, "index.lock") @@ -1289,6 +1380,8 @@ def create_repository(o: dict) -> Tuple[bool, str]: return True, "Couldn't add the remote: " + r.message if wrote: r = run_git(path, ["add", "-A"]) + if not r.ok and r.longpath_fixed: # long paths were just turned on - try again + r = run_git(path, ["add", "-A"]) if r.ok: r = run_git(path, ["commit", "-q", "-m", "Initial commit"]) if not r.ok: @@ -2652,6 +2745,7 @@ class GitDesk(App): if g is not self.git: return self.state = st + self.announce_longpaths() try: await self.apply_state(st) except NoMatches: # app is shutting down @@ -3324,6 +3418,7 @@ class GitDesk(App): self.set_busy(False) await self.refresh_state(force=True) self.update_remote_button() + self.announce_longpaths() async def ask(self, title: str, body, buttons, cancel=None) -> str: return await self.push_screen_wait(ChoiceDialog(title, body, buttons, cancel)) @@ -3367,6 +3462,8 @@ class GitDesk(App): 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.longpath_fixed and not r.ok: + r = await self.after_longpath_fix(args, input, r, before) if r.ok or not looks_lockish(r): return r after = await asyncio.to_thread(self.git.snapshot) @@ -3378,6 +3475,35 @@ class GitDesk(App): await asyncio.sleep(delay) return r + async def after_longpath_fix(self, args, input, r: Result, before) -> Result: + """A command hit "Filename too long" and core.longpaths has just been turned on.""" + if await asyncio.to_thread(self.git.snapshot) == before: + return await self.bg(*args, input=input) # nothing happened yet - just do it again + # It got part-way (e.g. switched branch but couldn't write some files): put those files back. + restored = await asyncio.to_thread(self.git.restore_unwritten, r) + body = Text("Some file paths in this repository are longer than Windows normally allows, so git " + "couldn't write them. gitdesk has turned on long path support:\n\n") + body.append(" git config --global core.longpaths true\n\n", style="bold") + if restored: + body.append(f"and re-created {plural(len(restored), 'file')} that couldn't be written:\n") + for p in restored[:8]: + body.append(f" {p}\n", style=C_MUTED) + if len(restored) > 8: + body.append(f" … and {len(restored) - 8} more\n", style=C_MUTED) + body.append("\n") + body.append("Check the Changes list. If the last action didn't finish, just run it again.") + LONGPATHS["just_enabled"] = False # this popup is the announcement + await self.ask("Turned on long file paths", body, [("ok", "OK", "primary")]) + r.handled = True + return r + + def announce_longpaths(self) -> None: + if LONGPATHS["just_enabled"]: + LONGPATHS["just_enabled"] = False + self._log_cmd(["config", "--global", "core.longpaths", "true"], Result(0, "(turned on by gitdesk)")) + self.notify("This repository has file paths longer than Windows normally allows, so gitdesk turned " + "on long path support: git config --global core.longpaths true", timeout=12) + async def lock_dialog(self, r: Result) -> str: lock = self.git.index_lock_path() body = Text() @@ -4150,6 +4276,14 @@ class GitDesk(App): self._log_cmd(args, r) if r.ok: break + if AUTO_LONGPATHS and LONGPATH_RE.search(r.message) and enable_longpaths(): + if os.path.isdir(os.path.join(path, ".git")): + # "Clone succeeded, but checkout failed": write the files now that long paths work + fix = await asyncio.to_thread(run_git, path, ["restore", "--source=HEAD", ":/"]) + self._log_cmd(["restore", "--source=HEAD", ":/"], fix) + if fix.ok: + break + continue # nothing was kept - clone again 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 "