|
1 | 1 | using System.Diagnostics; |
| 2 | +using System.Globalization; |
2 | 3 | using System.Text; |
3 | 4 | using System.Text.RegularExpressions; |
4 | 5 | using CodeIndex.Indexer; |
@@ -52,6 +53,18 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList<string> Unresolv |
52 | 53 | "UU", |
53 | 54 | }; |
54 | 55 |
|
| 56 | + internal const int MaxCapturedGitOutputChars = 1024 * 1024; |
| 57 | + private static readonly TimeSpan DefaultGitCommandTimeout = TimeSpan.FromSeconds(60); |
| 58 | + private static readonly AsyncLocal<TimeSpan?> GitCommandTimeoutOverride = new(); |
| 59 | + internal static TimeSpan GitCommandTimeout |
| 60 | + { |
| 61 | + get => GitCommandTimeoutOverride.Value ?? DefaultGitCommandTimeout; |
| 62 | + set => GitCommandTimeoutOverride.Value = value; |
| 63 | + } |
| 64 | + |
| 65 | + private static readonly TimeSpan GitKillWaitTimeout = TimeSpan.FromSeconds(5); |
| 66 | + private const int GitProcessFailureExitCode = -1; |
| 67 | + |
55 | 68 | /// <summary> |
56 | 69 | /// Resolve the common git directory for a project root, handling both normal repos and worktrees. |
57 | 70 | /// プロジェクトルートの共通gitディレクトリを解決する。通常リポジトリとworktreeの両方に対応。 |
@@ -696,20 +709,124 @@ private static (int ExitCode, string Output, string Error)? RunProcessCapturingO |
696 | 709 | using var process = new Process { StartInfo = psi }; |
697 | 710 | var stdout = new StringBuilder(); |
698 | 711 | var stderr = new StringBuilder(); |
| 712 | + string? failureReason = null; |
| 713 | + var failureLock = new object(); |
| 714 | + |
| 715 | + void MarkFailure(string reason) |
| 716 | + { |
| 717 | + lock (failureLock) |
| 718 | + { |
| 719 | + if (failureReason != null) |
| 720 | + return; |
| 721 | + failureReason = reason; |
| 722 | + } |
| 723 | + TryKillProcessTree(process); |
| 724 | + } |
| 725 | + |
699 | 726 | // Always terminate captured lines with '\n' (not Environment.NewLine) so callers that |
700 | 727 | // split on '\n' see identical output on Windows and POSIX — git writes LF-only to pipes. |
701 | 728 | // キャプチャ行は常に '\n' 区切りにし、Windows/POSIX 双方で git のパイプ出力(LF)と一致させる。 |
702 | | - process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Append(e.Data).Append('\n'); }; |
703 | | - process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.Append(e.Data).Append('\n'); }; |
| 729 | + process.OutputDataReceived += (_, e) => |
| 730 | + { |
| 731 | + if (e.Data != null) |
| 732 | + AppendBoundedCapturedLine(stdout, e.Data, "stdout", MarkFailure); |
| 733 | + }; |
| 734 | + process.ErrorDataReceived += (_, e) => |
| 735 | + { |
| 736 | + if (e.Data != null) |
| 737 | + AppendBoundedCapturedLine(stderr, e.Data, "stderr", MarkFailure); |
| 738 | + }; |
704 | 739 |
|
705 | 740 | if (!process.Start()) |
706 | 741 | return null; |
707 | 742 |
|
708 | 743 | process.BeginOutputReadLine(); |
709 | 744 | process.BeginErrorReadLine(); |
710 | | - process.WaitForExit(); |
| 745 | + if (!process.WaitForExit(ToWaitMilliseconds(GitCommandTimeout))) |
| 746 | + { |
| 747 | + MarkFailure($"git command timed out after {FormatDuration(GitCommandTimeout)}."); |
| 748 | + if (!process.WaitForExit(ToWaitMilliseconds(GitKillWaitTimeout))) |
| 749 | + return (GitProcessFailureExitCode, ReadCaptured(stdout), CombineCapturedError(ReadCaptured(stderr), failureReason!)); |
| 750 | + process.WaitForExit(); |
| 751 | + } |
| 752 | + else |
| 753 | + { |
| 754 | + process.WaitForExit(); |
| 755 | + } |
| 756 | + |
| 757 | + var output = ReadCaptured(stdout); |
| 758 | + var error = ReadCaptured(stderr); |
| 759 | + if (failureReason != null) |
| 760 | + return (GitProcessFailureExitCode, output, CombineCapturedError(error, failureReason)); |
711 | 761 |
|
712 | | - return (process.ExitCode, stdout.ToString(), stderr.ToString()); |
| 762 | + return (process.ExitCode, output, error); |
| 763 | + } |
| 764 | + |
| 765 | + private static string ReadCaptured(StringBuilder builder) |
| 766 | + { |
| 767 | + lock (builder) |
| 768 | + return builder.ToString(); |
| 769 | + } |
| 770 | + |
| 771 | + private static void AppendBoundedCapturedLine( |
| 772 | + StringBuilder builder, |
| 773 | + string data, |
| 774 | + string streamName, |
| 775 | + Action<string> markFailure) |
| 776 | + { |
| 777 | + lock (builder) |
| 778 | + { |
| 779 | + var remaining = MaxCapturedGitOutputChars - builder.Length; |
| 780 | + if (remaining <= 0) |
| 781 | + { |
| 782 | + markFailure(BuildCaptureLimitMessage(streamName)); |
| 783 | + return; |
| 784 | + } |
| 785 | + |
| 786 | + var required = data.Length + 1; |
| 787 | + if (required <= remaining) |
| 788 | + { |
| 789 | + builder.Append(data).Append('\n'); |
| 790 | + return; |
| 791 | + } |
| 792 | + |
| 793 | + builder.Append(data.AsSpan(0, Math.Min(data.Length, remaining))); |
| 794 | + } |
| 795 | + |
| 796 | + markFailure(BuildCaptureLimitMessage(streamName)); |
| 797 | + } |
| 798 | + |
| 799 | + private static string BuildCaptureLimitMessage(string streamName) |
| 800 | + => $"git command captured {streamName} exceeded {MaxCapturedGitOutputChars.ToString(CultureInfo.InvariantCulture)} characters."; |
| 801 | + |
| 802 | + private static string CombineCapturedError(string stderr, string diagnostic) |
| 803 | + => string.IsNullOrWhiteSpace(stderr) |
| 804 | + ? diagnostic |
| 805 | + : stderr.TrimEnd('\r', '\n') + "\n" + diagnostic; |
| 806 | + |
| 807 | + private static int ToWaitMilliseconds(TimeSpan timeout) |
| 808 | + { |
| 809 | + if (timeout <= TimeSpan.Zero) |
| 810 | + return 1; |
| 811 | + if (timeout.TotalMilliseconds >= int.MaxValue) |
| 812 | + return int.MaxValue; |
| 813 | + return Math.Max(1, (int)Math.Ceiling(timeout.TotalMilliseconds)); |
| 814 | + } |
| 815 | + |
| 816 | + private static string FormatDuration(TimeSpan timeout) |
| 817 | + => timeout.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture) + "s"; |
| 818 | + |
| 819 | + private static void TryKillProcessTree(Process process) |
| 820 | + { |
| 821 | + try |
| 822 | + { |
| 823 | + if (!process.HasExited) |
| 824 | + process.Kill(entireProcessTree: true); |
| 825 | + } |
| 826 | + catch |
| 827 | + { |
| 828 | + // Best-effort cleanup only; callers receive the timeout/capture diagnostic. |
| 829 | + } |
713 | 830 | } |
714 | 831 |
|
715 | 832 | private static bool ProbeFileSystemIgnoreCase(string projectRoot) |
|
0 commit comments