Skip to content

Delegate git process invocation to ktsu.RunCommand [patch] - #51

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/delegate-git-to-runcommand
Sep 29, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
claude/delegate-git-to-runcommand

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

GitRunner.RunAsync now runs git through RunCommand.ExecuteAsync (ktsu.RunCommand 1.9.0) instead of driving a Process by hand. This follows the maintainer decision on the issue. RunCommand#82 has shipped, and DrainTimeout is not a blocker.

What moved to RunCommand

  • Starting the process with an argument list
  • Reading stdout and stderr
  • Killing the whole process tree on cancellation or timeout
  • Giving up on the reads once the process is gone

That last point replaces DrainTimeout. RunCommand abandons the pending reads after a kill rather than waiting for a pipe that a surviving grandchild might hold open. Kill, DrainAsync and BuildStartInfo are removed.

What stays in GitRunner

  • Environment: ApplyEnvironment(ProcessStartInfo, …) becomes BuildEnvironment(invocation, settings, inherited), which returns the overlay for CommandOptions.EnvironmentVariables. It keeps the same flags and the same GIT_CONFIG_* credential protocol. Each inherited GIT_* variable now maps to null, which removes it from the child's environment.
  • Standard input: set to StandardInputMode.Closed, which keeps the no-hang guarantee process.StandardInput.Close() gave.
  • Strict UTF-8: the strict encoding goes to OutputHandler. A decode failure is still reported as the existing "not valid UTF-8" GitResult, including when it arrives wrapped in another exception.
  • Timeout vs. cancellation: telling this service's own timeout apart from the caller giving up works as before.
  • Working directory: resolved with Path.GetFullPath before becoming an AbsoluteDirectoryPath. A relative path still means relative to the current directory, as it did before.

Tests

  • The three ApplyEnvironment_* tests now target BuildEnvironment. The inherited-GIT_DIR test asserts the overlay's null, and a new assertion checks that non-git variables are left out of the overlay.
  • New: RunAsync_ACommandThatReadsStandardInput_SeesEndOfStreamRatherThanWaiting
  • New: RunAsync_OutputThatIsNotUtf8_IsReportedRatherThanReadAsEmptyOrReplaced, covering invalid bytes alone and invalid bytes mixed with valid text. These are the two cases the 2026-09-21 triage recorded. POSIX only, via OSCondition.

Full suite: 219 passed locally (net10.0, Linux).

This is a refactor, so the new tests also pass on main. They pin guarantees the swap could have broken, and I checked that each one catches that:

  • Stdin: changing StandardInput to Inherit makes the stdin test fail, "The command waited on standard input until it was killed", when the test host's stdin is an open pipe (sleep 200 | dotnet test …). If CI's stdin is /dev/null, this test cannot catch that regression there.
  • Decode: disabling the decode catch makes both UTF-8 cases fail with an unhandled DecoderFallbackException.

dotnet format --verify-no-changes reports nothing in the changed files. The two findings it does report, in RealRepositoryTests.cs and MirrorStartupCheck.cs, are also on main.

Fixes #27

🤖 Generated with Claude Code

https://claude.ai/code/session_01TAvt7dvjcukkH3y6mtUL16


Generated by Claude Code

GitRunner now runs git through RunCommand.ExecuteAsync instead of driving a
Process by hand. RunCommand owns starting the process, reading both streams,
killing the whole tree on cancellation, and giving up on reads once the
process is gone. GitRunner keeps what is particular to git: the GIT_* and
GIT_CONFIG_* environment, strict UTF-8 decoding, and telling its own timeout
apart from the caller giving up.

- Standard input is closed through StandardInputMode.Closed (RunCommand 1.9.0).
- The environment is built as an overlay. Each inherited GIT_* variable maps
  to null, which removes it from the child's environment.
- A decode failure is still reported as a GitResult, including when it
  arrives wrapped.
- The bounded post-kill drain (DrainTimeout) goes away. RunCommand abandons
  the reads after a kill instead of waiting for a pipe a grandchild holds open.

New tests cover a command that reads standard input, and output that is not
valid UTF-8, both alone and mixed with valid text.

Fixes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAvt7dvjcukkH3y6mtUL16

Copy link
Copy Markdown
Contributor Author

CI red: Test on ubuntu-latest and Test on windows-latest. Both fail the same test: RunAsync_ExceedingItsTimeout_ReportsTimedOutAndKillsTheTree. The sleeper's process tree was still alive after the timeout. This PR caused the failure, but the bug is in ktsu.RunCommand, so it can't be fixed from here.

Cause: RunCommand's RunAsync killed the process only through cancellationToken.Register(() => TryKill(process)). Token callbacks run newest first, and WaitForExitAsync(token) registers its callback after that one. So the wait can end the call and dispose the kill registration before the kill has run. The old hand-rolled GitRunner killed synchronously in its catch, so it never hit this.

Fix: ktsu-dev/RunCommand#94 also calls TryKill when a cancelled wait ends the call.

I ran this test locally, 8 times each, with this repo pointed at the RunCommand project:

  • RunCommand main: 3 of 8 runs failed
  • with #94: 0 of 8 runs failed

Why it isn't ported here: the fix lives inside the package, and GitRunner never sees the Process, so there is nothing to port into this PR. This PR stays red until a RunCommand release containing #94 ships. It then needs only a ktsu.RunCommand version bump, which I'll push. I haven't re-run CI: the failure reproduces locally, so it isn't a flake.


Generated by Claude Code

…o-runcommand

# Conflicts:
#	Directory.Packages.props

Copy link
Copy Markdown
Contributor Author

Merged main into this branch to clear the conflict in Directory.Packages.props (31cc75f). The resolution takes main's newer Polyfill/Essentials/Semantics versions and keeps ktsu.RunCommand 1.9.0.

CI will likely stay red on ubuntu/windows. RunAsync_ExceedingItsTimeout_ReportsTimedOutAndKillsTheTree fails intermittently on this branch both before and after the merge (locally, 4 of 4 runs failed on the pre-merge head and 2 of 3 on the merged head). This is the RunCommand kill-on-cancel race that ktsu-dev/RunCommand#94 fixes, so it isn't something to fix here. Once #94 ships, bump ktsu.RunCommand here and this should go green.


Generated by Claude Code

RunCommand 1.9.1 kills the process tree whenever a cancelled wait ends the
call (ktsu-dev/RunCommand#94). Before that, GitRunner's timeout could return
with the tree still running, which failed
RunAsync_ExceedingItsTimeout_ReportsTimedOutAndKillsTheTree on every CI leg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAvt7dvjcukkH3y6mtUL16
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit a3cfa2e into main Sep 29, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/delegate-git-to-runcommand branch September 29, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delegate git process invocation to ktsu.RunCommand

2 participants