By Claude Code, Opus 5, after I fixed the issue in the title locally. I can only vouch for the title issue. AI analysis and suggestions could be completely wrong.
Summary:
After graphify hook install, every git commit that touches a code file opens a new console window that takes keyboard focus, then closes when the background rebuild finishes (~40s here). The window is produced by the very flag that was meant to hide the child process. The detach logic added for #1161 is correct; only the Windows console handling is wrong.
Environment:
- OS: Windows 11
- Shell: Git for Windows (Git Bash / MSYS)
- Python: 3.12
- Graphify: 0.9.27, installed via
uv tool install graphifyy (root cause re-verified against branch v8)
Reproduction Steps:
graphify hook install in any repo
- Edit a code file,
git commit
- Observe a new console window appear and grab focus for the duration of the rebuild
Expected Behavior:
The rebuild runs detached and invisibly. Its output already goes to the rebuild log, so it needs no console.
Actual Behavior:
A conhost window is created, shown, and focused for ~40s on every commit. On a normal commit cadence this is highly disruptive — it steals keystrokes from the editor mid-typing.
Root Cause:
graphify/hooks.py, _LAUNCHER_TEMPLATE (≈L221–242 in 0.9.27; unchanged on v8):
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
sys.executable here is python.exe, a console-subsystem binary — the hook resolves $GRAPHIFY_PYTHON to it, and the launcher re-spawns sys.executable for the child. DETACHED_PROCESS (0x00000008) tells Windows not to give the child the parent's console. For a console-subsystem binary that does not mean "no console": the child gets its own new conhost, which is created shown and focused. The flag intended to hide the process is what produces the window.
Why this regressed from #1161:
#1161 replaced nohup ... & (absent from Git for Windows' MSYS runtime, so the rebuild silently never ran) with subprocess.Popen + DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP — the flags named in that issue's own Suggested Fix section. That fix is right about detachment: the process does survive the hook and does break out of git's job object. It just picked the one detach flag that materialises a console for a console-subsystem child. This is a UX regression on top of a correct correctness fix, not a reason to revert #1161.
Suggested Fixes (either works; the first is more robust):
1. Run the child under the GUI-subsystem interpreter. pythonw.exe sits next to python.exe in every CPython, venv, and uv-tool layout, and a GUI-subsystem process can never be given a console under any flag combination:
_exe = sys.executable
if os.name == 'nt':
_cand = os.path.join(os.path.dirname(_exe), 'pythonw.exe')
if os.path.exists(_cand):
_exe = _cand
_cmd = [_exe, '-c', _src]
2. Or use CREATE_NO_WINDOW (0x08000000) — the documented flag for "console application run without a console window" — in place of DETACHED_PROCESS, not alongside it. Windows treats DETACHED_PROCESS, CREATE_NEW_CONSOLE, and CREATE_NO_WINDOW as mutually exclusive; passing two is an invalid parameter. CREATE_NEW_PROCESS_GROUP and CREATE_BREAKAWAY_FROM_JOB stay as they are, so #1161's detachment is preserved.
Belt and braces would be both: prefer pythonw.exe when it exists, and swap DETACHED_PROCESS for CREATE_NO_WINDOW so the fallback path is also quiet.
Either way the child needs no console: stdout/stderr are already redirected to the log file and stdin is DEVNULL.
One authoring note for whoever patches this — per the docblock on _REBUILD_BODY_COMMIT, these templates are carried inside a double-quoted sh -c "..." argument and must contain no ", $, backtick, or backslash. The snippet above complies (single quotes only, os.path.join); an f-string or a literal \\ would break the hook script.
Workaround (available today):
Re-pin the hook to pythonw.exe. graphify hook install bakes sys.executable into the hook as _PINNED (#2166), and the launcher spawns sys.executable, so installing from pythonw propagates all the way to the detached child:
graphify hook uninstall
"$(uv tool dir)/graphifyy/Scripts/pythonw.exe" -m graphify hook install
The uninstall step is required — see below. The pinned pythonw.exe path passes _pinned_python()'s allowlist, and the hook's _GFY_PROBE uses sys.exit(...) with no print, so it is unaffected by pythonw's null sys.stdout.
Secondary (related, smaller): hook install over an existing graphify hook is a silent no-op.
_install_hook() returns f"already installed at {hook_path}" and writes nothing whenever the marker is already present, and install() exposes no --force. So a user re-running graphify hook install to change the pinned interpreter — exactly the workaround above — or to pick up a newer graphify's hook script gets a no-op with a message that reads like success. A --force flag, or at minimum wording that says the existing hook was left untouched and may be stale, would help. Happy to split this into its own issue if preferred.
By Claude Code, Opus 5, after I fixed the issue in the title locally. I can only vouch for the title issue. AI analysis and suggestions could be completely wrong.
Summary:
After
graphify hook install, everygit committhat touches a code file opens a new console window that takes keyboard focus, then closes when the background rebuild finishes (~40s here). The window is produced by the very flag that was meant to hide the child process. The detach logic added for #1161 is correct; only the Windows console handling is wrong.Environment:
uv tool install graphifyy(root cause re-verified against branchv8)Reproduction Steps:
graphify hook installin any repogit commitExpected Behavior:
The rebuild runs detached and invisibly. Its output already goes to the rebuild log, so it needs no console.
Actual Behavior:
A conhost window is created, shown, and focused for ~40s on every commit. On a normal commit cadence this is highly disruptive — it steals keystrokes from the editor mid-typing.
Root Cause:
graphify/hooks.py,_LAUNCHER_TEMPLATE(≈L221–242 in 0.9.27; unchanged onv8):sys.executablehere ispython.exe, a console-subsystem binary — the hook resolves$GRAPHIFY_PYTHONto it, and the launcher re-spawnssys.executablefor the child.DETACHED_PROCESS(0x00000008) tells Windows not to give the child the parent's console. For a console-subsystem binary that does not mean "no console": the child gets its own new conhost, which is created shown and focused. The flag intended to hide the process is what produces the window.Why this regressed from #1161:
#1161 replaced
nohup ... &(absent from Git for Windows' MSYS runtime, so the rebuild silently never ran) withsubprocess.Popen+DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP— the flags named in that issue's own Suggested Fix section. That fix is right about detachment: the process does survive the hook and does break out of git's job object. It just picked the one detach flag that materialises a console for a console-subsystem child. This is a UX regression on top of a correct correctness fix, not a reason to revert #1161.Suggested Fixes (either works; the first is more robust):
1. Run the child under the GUI-subsystem interpreter.
pythonw.exesits next topython.exein every CPython, venv, and uv-tool layout, and a GUI-subsystem process can never be given a console under any flag combination:2. Or use
CREATE_NO_WINDOW(0x08000000) — the documented flag for "console application run without a console window" — in place ofDETACHED_PROCESS, not alongside it. Windows treatsDETACHED_PROCESS,CREATE_NEW_CONSOLE, andCREATE_NO_WINDOWas mutually exclusive; passing two is an invalid parameter.CREATE_NEW_PROCESS_GROUPandCREATE_BREAKAWAY_FROM_JOBstay as they are, so #1161's detachment is preserved.Belt and braces would be both: prefer
pythonw.exewhen it exists, and swapDETACHED_PROCESSforCREATE_NO_WINDOWso the fallback path is also quiet.Either way the child needs no console:
stdout/stderrare already redirected to the log file andstdinisDEVNULL.One authoring note for whoever patches this — per the docblock on
_REBUILD_BODY_COMMIT, these templates are carried inside a double-quotedsh -c "..."argument and must contain no",$, backtick, or backslash. The snippet above complies (single quotes only,os.path.join); an f-string or a literal\\would break the hook script.Workaround (available today):
Re-pin the hook to
pythonw.exe.graphify hook installbakessys.executableinto the hook as_PINNED(#2166), and the launcher spawnssys.executable, so installing from pythonw propagates all the way to the detached child:graphify hook uninstall "$(uv tool dir)/graphifyy/Scripts/pythonw.exe" -m graphify hook installThe
uninstallstep is required — see below. The pinnedpythonw.exepath passes_pinned_python()'s allowlist, and the hook's_GFY_PROBEusessys.exit(...)with noprint, so it is unaffected bypythonw's nullsys.stdout.Secondary (related, smaller):
hook installover an existing graphify hook is a silent no-op._install_hook()returnsf"already installed at {hook_path}"and writes nothing whenever the marker is already present, andinstall()exposes no--force. So a user re-runninggraphify hook installto change the pinned interpreter — exactly the workaround above — or to pick up a newer graphify's hook script gets a no-op with a message that reads like success. A--forceflag, or at minimum wording that says the existing hook was left untouched and may be stale, would help. Happy to split this into its own issue if preferred.