Skip to content

Windows support #1

Description

@ojura

Windows: diff syntax highlighting silently fails — and the obvious fixes hit a console-window flash, then a ~5s launch stall

Summary

On a stock Windows 11 + Git for Windows setup, diff syntax highlighting in git-gui-pyggy
silently does nothing. Chasing it down surfaced three distinct Windows-only problems,
each of which bites the "obvious" fix for the previous one:

  1. syntax_python picks an interpreter that Tcl can't actually launch, so highlighting is silently disabled.
  2. Using a console interpreter makes git-gui pop up (and focus) an empty console window.
  3. Using a windowless interpreter (pythonw/pyw) avoids the window but stalls git-gui launch by ~5 seconds.

A single design — spawn windowless, and have the helper re-exec itself as a detached
windowless grandchild
— solves all three. Details and a proposed patch below.

Environment

  • Windows 11 Pro (26200)
  • Git for Windows (git-gui under wish.exe, Tcl/Tk 8.6 bundled in mingw64/bin)
  • Python: multiple interpreters on PATH, including Microsoft Store "App execution alias" stubs
  • git-gui-pyggy gitgui-0.21.pyggy

gui.diffsyntax defaults to true, the Python helper works fine when run by hand, and
Pygments is importable — yet no highlighting appeared in the diff viewer.


Problem 1 — syntax_python returns an unlaunchable Store stub

On Windows, python3 (and often python) resolve first to Microsoft Store App
execution alias
stubs under …\AppData\Local\Microsoft\WindowsApps\. These are zero-byte
reparse points. They work from cmd/PowerShell, but Tcl's [open "|…"] cannot launch
them
:

couldn't execute "…\WindowsApps\python3.EXE": no such file or directory

syntax_python tries python3 / python before py, returns the stub, syntax_start's
open fails, syntax_fd is left empty, and highlighting is silently disabled — with no
fallback to the working py launcher.

Reproduced with Git's own bundled tclsh:

auto_execok python3 = …\WindowsApps\python3.EXE
PYTHON3: OPEN FAILED: couldn't execute "…\WindowsApps\python3.EXE": no such file or directory
PY3:     GOT: RES 1 6          # py -3 -> real interpreter, works

Fix: skip any candidate whose path contains WindowsApps.


Problem 2 — console interpreter pops up an empty console window

git-gui runs under wish.exe, a GUI-subsystem process with no console. When Tcl spawns
a console interpreter (py.exe / python.exe), Windows allocates a fresh, visible
console window for the child — it pops up and steals focus on every git-gui launch.

Hiding it after the fact from inside the helper (ShowWindow(GetConsoleWindow(), SW_HIDE))
is not good enough: the window still flashes open and plays a minimize animation. Looks
broken.


Problem 3 — windowless interpreter stalls launch ~5 seconds

The natural answer to Problem 2 is the windowless interpreter (pythonw.exe / pyw.exe),
which never shows a window. But that makes [open "|…"] block for ~5.5 s on every
launch.

Measured with the bundled tclsh (open duration only; the helper then responds in ~25 ms):

interpreter spawned subsystem open time
py -3 (launcher) console 4 ms
…\Python312\python.exe console 6 ms
…\anaconda3\python.exe console 5 ms
pyw -3 (launcher) windowless 5526 ms
…\Python312\pythonw.exe windowless 5532 ms
…\anaconda3\pythonw.exe windowless 5571 ms

This is Tcl's tclWinPipe.c doing WaitForSingleObject(child, 5000) on GUI-subsystem
children — it waits for the child to exit, and a persistent helper never does, so it always
burns the full 5 s timeout.

It is not WaitForInputIdle, which is the tempting explanation. I disproved that: a
windowless process that creates a real message-only window and sits idle in GetMessage()
still stalled 5.5 s, while a windowless process that simply exits fast returned in
~30 ms. So the gate is process exit, not input-idle state — no in-process message-pump trick
can help a long-lived helper.


The fix that resolves all three

Spawn the windowless interpreter (no window — Problem 2 solved), and have the helper
re-exec itself once as a detached windowless grandchild that inherits the stdio pipe, then
exit immediately
. Tcl only ever waits on the short-lived bootstrap (exits in ~50 ms → fast
open — Problem 3 solved), while the grandchild keeps serving on the same pipe with no window.
Combined with skipping the Store stubs (Problem 1 solved).

Validated end-to-end against the installed files:

interp -> …\Launcher\pyw.EXE -3
open took 48 ms
first RES after 74 ms: RES 1 2

No console window, ~48 ms launch, highlighting works.

lib/syntax.tclsyntax_python

Prefer windowless, skip Store stubs:

proc syntax_python {} {
	# Skip Windows "App execution alias" stubs (under WindowsApps): zero-byte
	# reparse points that [open |...] cannot launch. Prefer the WINDOWLESS
	# pythonw / pyw so no console window appears; the helper re-exec's itself to
	# avoid the ~5s WaitForSingleObject stall (see git-gui-highlight.py). Console
	# python is the fallback. On Linux/macOS pyw/pythonw usually don't exist, so
	# this falls through to python3/python.
	set cands {}
	set exe [auto_execok pyw]
	if {$exe ne {}} {lappend cands [concat $exe -3]}
	set exe [auto_execok pythonw]
	if {$exe ne {}} {lappend cands $exe}
	foreach cand {python3 python} {
		set exe [auto_execok $cand]
		if {$exe ne {}} {lappend cands $exe}
	}
	set exe [auto_execok py]
	if {$exe ne {}} {lappend cands [concat $exe -3]}
	foreach exe $cands {
		if {[string match -nocase *WindowsApps* [lindex $exe 0]]} continue
		return $exe
	}
	return [lindex $cands 0]
}

lib/git-gui-highlight.py — re-exec to a detached windowless grandchild

Near the top, before the Pygments import (requires import os):

# On Windows, re-exec ourselves once as a detached, windowless grandchild and
# exit. This is the only way to get BOTH no console window AND a fast git-gui
# launch:
#   * a console interpreter spawns instantly but pops up an empty console window;
#   * a windowless interpreter shows no window, but Tcl's [open |...] does
#     WaitForSingleObject(child, 5000) on GUI-subsystem children, so a persistent
#     windowless helper stalls git-gui startup ~5s.
# git-gui spawns us windowless (pythonw -> no window); we relaunch the real,
# persistent helper as a windowless grandchild that inherits this stdio pipe,
# then exit at once. Tcl waits only on us (we exit in ~50ms), and the grandchild
# keeps serving with no window. The pipe survives our exit because the grandchild
# holds inherited copies of the handles. Gated on actually running under
# pythonw.exe so a console fallback doesn't spawn a second window.
if (sys.platform == 'win32'
        and os.environ.get('_GG_HL_CHILD') != '1'
        and os.path.basename(sys.executable).lower() == 'pythonw.exe'):
    import subprocess
    _env = dict(os.environ)
    _env['_GG_HL_CHILD'] = '1'
    subprocess.Popen([sys.executable, os.path.abspath(__file__)],
                     stdin=sys.stdin, stdout=sys.stdout,
                     stderr=subprocess.DEVNULL, env=_env, close_fds=False)
    sys.exit(0)

The grandchild's stdin EOF (git-gui closing the pipe on exit) terminates it cleanly, so there
is no leaked process.


Notes

  • All three issues are Windows-only; the sys.platform == 'win32' gate and the absence of
    pyw/pythonw on Linux/macOS keep those paths unchanged.
  • Even with the Store stubs removed from PATH, preferring pyw/pythonw is still the right
    call, since it's what enables the no-window path.
  • The Windows installer's pip install pygments targets whichever interpreter Get-Command python resolves to (often the Store stub's backing interpreter), which may differ from the
    one git-gui ends up spawning. Worth ensuring the chosen interpreter is the one Pygments is
    installed into (or detecting a missing Pygments and degrading visibly rather than silently).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions