Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions changelog.d/unreleased/3723.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
category: fixed
issues:
- 3723
affected:
- src/CodeIndex/Cli/GitHelper.cs
- src/CodeIndex/Cli/IndexCommandRunner.cs
- src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs
- src/CodeIndex/Cli/IndexCommandRunner.Update.cs
- src/CodeIndex/Cli/IndexFreshnessChecker.cs
- src/CodeIndex/Cli/QueryCommandRunner.cs
- src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs
- tests/CodeIndex.Tests/GitHelperTests.cs
---

## English

- **Git helper calls now honor caller cancellation (#3723)** — Git-backed freshness, status, and commit-resolution paths now pass explicit cancellation tokens into process execution instead of relying on non-cancelable helper shortcuts.

## 日本語

- **Git helper 呼び出しが caller cancellation を尊重するようになりました (#3723)** — Git を使う freshness、status、commit 解決の経路が、キャンセル不能な helper shortcut に頼らず、明示的な cancellation token を process 実行へ渡すようになりました。
67 changes: 40 additions & 27 deletions src/CodeIndex/Cli/GitHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ internal static IReadOnlyList<string> TrustedGitExecutableCandidatePathsForTests
/// In a worktree, .git is a file containing "gitdir: path/to/.git/worktrees/name".
/// The common dir is resolved via the "commondir" file inside the worktree git dir.
/// </summary>
public static string? ResolveGitCommonDir(string projectRoot)
public static string? ResolveGitCommonDir(string projectRoot, CancellationToken cancellationToken = default)
{
var dotGit = Path.Combine(projectRoot, ".git");
var ioDotGit = LongPath.EnsureWindowsPrefix(dotGit);
Expand All @@ -219,7 +219,7 @@ internal static IReadOnlyList<string> TrustedGitExecutableCandidatePathsForTests
// Worktree: .git is a file containing "gitdir: <path>" / worktree: .gitがファイルで "gitdir: <path>" を含む
if (!File.Exists(ioDotGit))
{
return TryGetRepositoryType(projectRoot) == GitRepositoryType.Bare
return TryGetRepositoryType(projectRoot, cancellationToken) == GitRepositoryType.Bare
? Path.GetFullPath(projectRoot)
: null;
}
Expand Down Expand Up @@ -256,7 +256,7 @@ internal static IReadOnlyList<string> TrustedGitExecutableCandidatePathsForTests
/// Try to classify the repository shape for <paramref name="projectRoot"/>.
/// projectRoot の git リポジトリ形状を best-effort で判定する。
/// </summary>
public static GitRepositoryType TryGetRepositoryType(string projectRoot)
public static GitRepositoryType TryGetRepositoryType(string projectRoot, CancellationToken cancellationToken = default)
{
var dotGit = Path.Combine(projectRoot, ".git");
var ioDotGit = LongPath.EnsureWindowsPrefix(dotGit);
Expand All @@ -265,7 +265,7 @@ public static GitRepositoryType TryGetRepositoryType(string projectRoot)
if (File.Exists(ioDotGit))
return GitRepositoryType.Worktree;

var isBare = TryRunGit(projectRoot, "rev-parse", "--is-bare-repository")?.Trim();
var isBare = TryRunGit(projectRoot, cancellationToken, "rev-parse", "--is-bare-repository")?.Trim();
return string.Equals(isBare, "true", StringComparison.OrdinalIgnoreCase)
? GitRepositoryType.Bare
: GitRepositoryType.None;
Expand Down Expand Up @@ -433,12 +433,21 @@ private static void ValidateGitRef(string value, string parameterName)
: null;
}

public static string? TryResolveCommit(string projectRoot, string refName)
/// <summary>
/// Try to resolve a git ref to a commit SHA. Pass a caller token for cancelable production paths;
/// the default token preserves legacy best-effort behavior for compatibility.
/// git ref を commit SHA に解決する。production 経路では caller token を渡す。
/// </summary>
public static string? TryResolveCommit(string projectRoot, string refName, CancellationToken cancellationToken = default)
{
try
{
ValidateGitRef(refName, nameof(refName));
return TryRunGit(projectRoot, "rev-parse", "--verify", $"{refName}^{{commit}}")?.Trim();
return TryRunGit(projectRoot, cancellationToken, "rev-parse", "--verify", $"{refName}^{{commit}}")?.Trim();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
Expand Down Expand Up @@ -525,14 +534,17 @@ internal static GitHeadCommitResult TryGetHeadCommitResult(
/// 現在の HEAD が指定 commit より何コミット進んでいるかを安全に数える。git が無い、
/// commit が解決できない、または線形な祖先関係に無い場合は null を返す。
/// </summary>
public static int? TryCountCommitsAhead(string projectRoot, string baseCommit)
public static int? TryCountCommitsAhead(
string projectRoot,
string baseCommit,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(baseCommit))
return null;
if (baseCommit.StartsWith('-') || !Regex.IsMatch(baseCommit, @"^[a-zA-Z0-9_./^~\-]+$"))
return null;

var headSha = TryGetHeadCommit(projectRoot);
var headSha = TryGetHeadCommit(projectRoot, cancellationToken);
if (string.IsNullOrWhiteSpace(headSha))
return null;

Expand All @@ -547,10 +559,10 @@ internal static GitHeadCommitResult TryGetHeadCommitResult(
// branch tip, etc.). rev-list will succeed with exit=0 but a misleading count.
// indexed commit が現在 HEAD の祖先である場合のみ「N コミット進んでいる」の解釈が
// 成立するので、merge-base --is-ancestor で検証する。
if (!TryRunGitForExitCode(projectRoot, "merge-base", "--is-ancestor", baseCommit, "HEAD"))
if (!TryRunGitForExitCode(projectRoot, cancellationToken, "merge-base", "--is-ancestor", baseCommit, "HEAD"))
return null;

var output = TryRunGit(projectRoot, "rev-list", "--count", $"{baseCommit}..HEAD");
var output = TryRunGit(projectRoot, cancellationToken, "rev-list", "--count", $"{baseCommit}..HEAD");
if (output == null)
return null;
var trimmed = output.Trim();
Expand All @@ -559,7 +571,7 @@ internal static GitHeadCommitResult TryGetHeadCommitResult(
: null;
}

private static bool TryRunGitForExitCode(string projectRoot, params string[] args)
private static bool TryRunGitForExitCode(string projectRoot, CancellationToken cancellationToken, params string[] args)
{
try
{
Expand All @@ -573,9 +585,13 @@ private static bool TryRunGitForExitCode(string projectRoot, params string[] arg
// Reuse the shared event-driven drainer (PR #1497) so we don't reintroduce
// sync-over-async on git's stderr pipe. We only care about exit code here.
// #1497 で導入した共有 drainer を使い、stderr の sync-over-async を再導入しない。
var result = RunProcessCapturingOutput(psi);
var result = RunProcessCapturingOutput(psi, cancellationToken);
return result != null && result.Value.ExitCode == 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return false;
Expand All @@ -591,7 +607,8 @@ private static bool TryRunGitForExitCode(string projectRoot, params string[] arg

internal static GitRepositoryType TryGetRepositoryType(
string projectRoot,
IReadOnlyDictionary<string, string?>? gitEnvironmentOverrides)
IReadOnlyDictionary<string, string?>? gitEnvironmentOverrides,
CancellationToken cancellationToken = default)
{
var dotGit = Path.Combine(projectRoot, ".git");
var ioDotGit = LongPath.EnsureWindowsPrefix(dotGit);
Expand All @@ -600,7 +617,7 @@ internal static GitRepositoryType TryGetRepositoryType(
if (File.Exists(ioDotGit))
return GitRepositoryType.Worktree;

var isBare = TryRunGit(projectRoot, gitEnvironmentOverrides, "rev-parse", "--is-bare-repository")?.Trim();
var isBare = TryRunGit(projectRoot, gitEnvironmentOverrides, cancellationToken, "rev-parse", "--is-bare-repository")?.Trim();
return string.Equals(isBare, "true", StringComparison.OrdinalIgnoreCase)
? GitRepositoryType.Bare
: GitRepositoryType.None;
Expand Down Expand Up @@ -633,19 +650,19 @@ internal static bool ResolveIgnoreCase(
/// Try to determine whether the worktree has uncommitted changes.
/// worktree に未コミット変更があるか安全に判定する。
/// </summary>
public static bool? TryIsWorktreeDirty(string projectRoot)
public static bool? TryIsWorktreeDirty(string projectRoot, CancellationToken cancellationToken = default)
{
var status = TryGetWorktreeStatus(projectRoot);
var status = TryGetWorktreeStatus(projectRoot, cancellationToken);
return status?.IsDirty;
}

/// <summary>
/// Try to determine worktree dirtiness and unresolved merge paths from git porcelain status.
/// git porcelain status から worktree の dirty 状態と未解決 merge path を取得する。
/// </summary>
public static WorktreeStatus? TryGetWorktreeStatus(string projectRoot)
public static WorktreeStatus? TryGetWorktreeStatus(string projectRoot, CancellationToken cancellationToken = default)
{
var output = TryRunGit(projectRoot, "-c", "core.quotePath=false", "status", "--porcelain");
var output = TryRunGit(projectRoot, cancellationToken, "-c", "core.quotePath=false", "status", "--porcelain");
if (output == null)
return null;

Expand Down Expand Up @@ -686,16 +703,18 @@ private static string ParsePorcelainPath(string path)
/// git が無い場合は null、該当無しは空集合を返す。sparse-checkout(cone/non-cone)・partial
/// clone・手動 update-index --skip-worktree がいずれも同じビットを使うのを横断的に拾う。
/// </summary>
public static HashSet<string>? TryGetSkipWorktreePaths(string projectRoot)
=> TryGetSkipWorktreePaths(projectRoot, gitEnvironmentOverrides: null);
public static HashSet<string>? TryGetSkipWorktreePaths(string projectRoot, CancellationToken cancellationToken = default)
=> TryGetSkipWorktreePaths(projectRoot, gitEnvironmentOverrides: null, cancellationToken);

internal static HashSet<string>? TryGetSkipWorktreePaths(
string projectRoot,
IReadOnlyDictionary<string, string?>? gitEnvironmentOverrides)
IReadOnlyDictionary<string, string?>? gitEnvironmentOverrides,
CancellationToken cancellationToken = default)
{
var output = TryRunGit(
projectRoot,
gitEnvironmentOverrides,
cancellationToken,
"-c",
"core.quotePath=false",
"ls-files",
Expand Down Expand Up @@ -761,9 +780,6 @@ private static bool HasGitMetadataEntry(string projectRoot)
return Directory.Exists(ioDotGit) || File.Exists(ioDotGit);
}

private static string? TryRunGit(string projectRoot, params string[] args)
=> TryRunGit(projectRoot, gitEnvironmentOverrides: null, args);

private static string? TryRunGit(string projectRoot, CancellationToken cancellationToken, params string[] args)
=> TryRunGit(projectRoot, gitEnvironmentOverrides: null, cancellationToken, args);

Expand Down Expand Up @@ -800,9 +816,6 @@ private readonly record struct GitProcessCaptureResult(
GitCommandFailureKind FailureKind,
string? Diagnostic);

private static string? TryRunGit(string projectRoot, IReadOnlyDictionary<string, string?>? gitEnvironmentOverrides, params string[] args)
=> TryRunGit(projectRoot, gitEnvironmentOverrides, CancellationToken.None, args);

private static string? TryRunGit(
string projectRoot,
IReadOnlyDictionary<string, string?>? gitEnvironmentOverrides,
Expand Down
10 changes: 7 additions & 3 deletions src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,13 @@ private static string CollapseLineBreaks(string value)
return buffer.ToString();
}

private static int? RejectUnresolvedMergeState(string projectRoot, bool json, JsonSerializerOptions jsonOptions)
private static int? RejectUnresolvedMergeState(
string projectRoot,
bool json,
JsonSerializerOptions jsonOptions,
CancellationToken cancellationToken)
{
var status = GitHelper.TryGetWorktreeStatus(projectRoot);
var status = GitHelper.TryGetWorktreeStatus(projectRoot, cancellationToken);
if (status == null || status.UnresolvedMergeFiles.Count == 0)
return null;

Expand Down Expand Up @@ -742,7 +746,7 @@ private static int RunFullScan(
var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions);
var memorySamples = options.MemoryTrace ? new List<IndexMemorySampleJsonResult> { CaptureMemorySample("start", stopwatch) } : [];
_ = priorMetadataTargetCsharp; // full-scan resolver runs unconditionally on success / 成功時に常に再解決するため不要
var unresolvedMergeExitCode = RejectUnresolvedMergeState(projectRoot, options.Json, jsonOptions);
var unresolvedMergeExitCode = RejectUnresolvedMergeState(projectRoot, options.Json, jsonOptions, cancellationToken);
if (unresolvedMergeExitCode != null)
return unresolvedMergeExitCode.Value;

Expand Down
4 changes: 2 additions & 2 deletions src/CodeIndex/Cli/IndexCommandRunner.Update.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ private static int RunUpdateMode(
var memorySamples = options.MemoryTrace ? new List<IndexMemorySampleJsonResult> { CaptureMemorySample("start", stopwatch) } : [];
var currentSqlGraphContractVersion = DbContext.SqlGraphContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture);
var sqlGraphContractMatchesCurrent = priorSqlGraphContractVersion == currentSqlGraphContractVersion;
var unresolvedMergeExitCode = RejectUnresolvedMergeState(projectRoot, options.Json, jsonOptions);
var unresolvedMergeExitCode = RejectUnresolvedMergeState(projectRoot, options.Json, jsonOptions, cancellationToken);
if (unresolvedMergeExitCode != null)
return unresolvedMergeExitCode.Value;
var symbolKindFilterMatchesPrior = string.Equals(
Expand Down Expand Up @@ -1053,7 +1053,7 @@ void ThrowIfUpdateCancelled()
if (errors == 0)
{
StampIndexedHeadMetadata(writer, projectRoot, indexRunDiagnostics, cancellationToken);
StampCommitScopedFreshHeadMetadata(writer, options, projectRoot, currentHeadCommit, indexRunDiagnostics);
StampCommitScopedFreshHeadMetadata(writer, options, projectRoot, currentHeadCommit, indexRunDiagnostics, cancellationToken);
if (options.MemoryTrace)
memorySamples.Add(CaptureMemorySample("finalize", stopwatch));
var memoryTimelineForStamp = BuildMemoryTimeline(memorySamples);
Expand Down
46 changes: 36 additions & 10 deletions src/CodeIndex/Cli/IndexCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C

db.InitializeSchema();
var indexRunDiagnostics = new List<string>();
AddToGitExclude(options.ProjectPath, dbPath, indexRunDiagnostics);
AddToGitExclude(options.ProjectPath, dbPath, indexRunDiagnostics, indexCancellation.Token);

var writer = new DbWriter(db);
var indexer = new FileIndexer(
Expand Down Expand Up @@ -949,17 +949,27 @@ private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot
StampWorkspacePathCaseSensitivity(writer, projectRoot, diagnostics, cancellationToken);
}

private static void StampCommitScopedFreshHeadMetadata(DbWriter writer, IndexCommandOptions options, string projectRoot, string? currentHeadCommit, List<string>? diagnostics)
private static void StampCommitScopedFreshHeadMetadata(
DbWriter writer,
IndexCommandOptions options,
string projectRoot,
string? currentHeadCommit,
List<string>? diagnostics,
CancellationToken cancellationToken = default)
{
try
{
var coveredHead = !string.IsNullOrWhiteSpace(currentHeadCommit)
&& (options.Commits.Any(commit => GitRefCoversCurrentHead(projectRoot, commit, currentHeadCommit))
|| TryChangedBetweenCoversCurrentHead(options, projectRoot, currentHeadCommit))
&& (options.Commits.Any(commit => GitRefCoversCurrentHead(projectRoot, commit, currentHeadCommit, cancellationToken))
|| TryChangedBetweenCoversCurrentHead(options, projectRoot, currentHeadCommit, cancellationToken))
? currentHeadCommit
: null;
writer.SetMeta(DbContext.CommitScopedFreshHeadShaMetaKey, coveredHead);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Best-effort metadata only; never fail an otherwise-successful index run.
Expand All @@ -968,21 +978,29 @@ private static void StampCommitScopedFreshHeadMetadata(DbWriter writer, IndexCom
}
}

private static bool GitRefCoversCurrentHead(string projectRoot, string refName, string currentHeadCommit)
private static bool GitRefCoversCurrentHead(
string projectRoot,
string refName,
string currentHeadCommit,
CancellationToken cancellationToken)
{
if (currentHeadCommit.StartsWith(refName, StringComparison.OrdinalIgnoreCase))
return true;

var resolvedRef = GitHelper.TryResolveCommit(projectRoot, refName);
var resolvedRef = GitHelper.TryResolveCommit(projectRoot, refName, cancellationToken);
return string.Equals(resolvedRef, currentHeadCommit, StringComparison.OrdinalIgnoreCase);
}

private static bool TryChangedBetweenCoversCurrentHead(IndexCommandOptions options, string projectRoot, string currentHeadCommit)
private static bool TryChangedBetweenCoversCurrentHead(
IndexCommandOptions options,
string projectRoot,
string currentHeadCommit,
CancellationToken cancellationToken)
{
if (options.ChangedBetweenRefs.Count != 2)
return false;

return GitRefCoversCurrentHead(projectRoot, options.ChangedBetweenRefs[1], currentHeadCommit);
return GitRefCoversCurrentHead(projectRoot, options.ChangedBetweenRefs[1], currentHeadCommit, cancellationToken);
}

// Issue #1546: capture the actual case-sensitivity of the workspace filesystem so
Expand Down Expand Up @@ -1014,12 +1032,16 @@ private static void StampWorkspacePathCaseSensitivity(DbWriter writer, string pr
}
}

private static void AddToGitExclude(string projectPath, string dbPath, List<string>? diagnostics)
private static void AddToGitExclude(
string projectPath,
string dbPath,
List<string>? diagnostics,
CancellationToken cancellationToken)
{
try
{
var projectRoot = Path.GetFullPath(projectPath);
var gitDir = GitHelper.ResolveGitCommonDir(projectRoot);
var gitDir = GitHelper.ResolveGitCommonDir(projectRoot, cancellationToken);
if (gitDir == null) return;

var excludeFile = Path.Combine(gitDir, "info", "exclude");
Expand Down Expand Up @@ -1069,6 +1091,10 @@ private static void AddToGitExclude(string projectPath, string dbPath, List<stri
foreach (var pattern in missing)
sw.WriteLine(pattern);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
RecordIndexRunDiagnostic(diagnostics, "git_exclude_metadata_write_failed", ex);
Expand Down
Loading
Loading