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
111 changes: 111 additions & 0 deletions .github/workflows/dependabot-lockfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ─────────────────────────────────────────────────────────────────────────────
# Dependabot lockfile
#
# Why this exists
# ---------------
# Dependabot bumps a version in Directory.Packages.props but only updates the
# packages.lock.json of the project that holds the direct <PackageReference>.
# Projects that pick the new version up transitively (via <ProjectReference>)
# keep stale lock files, so ci.yml's `dotnet restore --locked-mode` fails with
# NU1004. This workflow re-evaluates the graph, commits the refreshed lock
# files back to the Dependabot branch, and lets ci.yml run again on the result.
#
# Required repository secret
# --------------------------
# DEPENDABOT_REFRESH_TOKEN
# Fine-grained PAT or GitHub App installation token, scoped to this repo,
# with Contents: Read and write.
#
# GITHUB_TOKEN cannot be used: commits pushed with it do not trigger
# `pull_request` workflows, so ci.yml would never re-run on the refreshed
# commit and the PR would stay red.
# ─────────────────────────────────────────────────────────────────────────────

name: Dependabot lockfile

on:
pull_request:
branches: [ main ]

# Least privilege at the workflow level; the job elevates itself below.
permissions:
contents: read

# One run per PR; a newer push supersedes an in-flight run.
concurrency:
group: dependabot-lockfile-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
refresh-lockfiles:
name: Refresh packages.lock.json

# github.actor is whoever pushed the current head commit. Requiring it to be
# Dependabot means this job does not re-run on the commit it just pushed
# itself (that push uses DEPENDABOT_REFRESH_TOKEN, so the actor changes).
# The remaining conditions make it a no-op for anything that is not a real
# Dependabot PR from an in-repo dependabot/* branch.
if: |
github.actor == 'dependabot[bot]' &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
startsWith(github.event.pull_request.head.ref, 'dependabot/') &&
github.event.pull_request.head.repo.full_name == github.repository

runs-on: ubuntu-latest
timeout-minutes: 15

permissions:
contents: read # the push below uses DEPENDABOT_REFRESH_TOKEN, not GITHUB_TOKEN

steps:
- name: Checkout Dependabot branch
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref }}
# Do not wire GITHUB_TOKEN into git: pushing with it suppresses ci.yml
# on the refreshed commit.
persist-credentials: false

- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: "10.0.x"

# --force-evaluate rewrites lock files instead of validating them.
# RestoreLockedMode is cleared explicitly so the run cannot fail with
# NU1004 if that property is pinned in Directory.Build.props.
- name: Re-evaluate NuGet dependency graph
run: dotnet restore ReviewForge.slnx --force-evaluate -p:RestoreLockedMode=false

- name: Commit and push refreshed lock files
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_REFRESH_TOKEN }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
shell: bash
run: |
set -euo pipefail

if [ -z "${GH_TOKEN}" ]; then
echo "::error::Repository secret DEPENDABOT_REFRESH_TOKEN is not set or is empty."
exit 1
fi

# `dotnet restore` only ever rewrites packages.lock.json among the
# files git tracks, so -u stages exactly what we want and nothing else.
git add -u

if git diff --cached --quiet; then
echo "Lock files are already consistent; nothing to commit."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# [dependabot skip] stops Dependabot from force-pushing over this commit.
# Do NOT add [skip ci] here: ci.yml must run on the refreshed commit.
git commit -m "chore(deps): refresh packages.lock.json [dependabot skip]"

git push \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
"HEAD:${HEAD_REF}"
2 changes: 1 addition & 1 deletion src/ReviewForge.Core/Analysis/DiffExclusions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public static class DiffExclusions
{
public static bool IsExcluded(string path, IReadOnlyList<string> globs)
{
var normalized = path.Replace('\\', '/').TrimStart('/');
var normalized = RepoPath.Normalize(path);
return globs.Any(g => Matches(normalized.Split('/'), g.Split('/')));
}

Expand Down
111 changes: 89 additions & 22 deletions src/ReviewForge.Core/Analysis/DiffIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ void FlushSection()

// A "diff --git" header is a hard section boundary: recognized even when the
// previous file's hunk is still open (real git emits it right after hunk content).
if (line.StartsWith("diff --git a/", StringComparison.Ordinal))
// Either path may be bare or C-style-quoted (DiffPathParser).
if (line.StartsWith("diff --git ", StringComparison.Ordinal))
{
FlushRun();
FlushSection();
Expand Down Expand Up @@ -136,22 +137,29 @@ void FlushSection()
}

FlushRun();
if (line.StartsWith("+++ b/", StringComparison.Ordinal))
if (line.StartsWith("+++ /dev/null", StringComparison.Ordinal))
{
currentFile = line[6..].ToString();
currentFile = null; // deleted file
inHunk = false;
}
else if (line.StartsWith("+++ ", StringComparison.Ordinal))
{
// A +++ header signals content intent even when its path is undecodable: the
// file is then NOT registered, so a manifest match fails closed at the
// prepare-repository scope guard instead of shrinking scope silently.
sawContent = true;
if (!index._ChangedLines.ContainsKey(currentFile))
currentFile = null;
if (DiffPathParser.TryDecodeToken(line[4..], out var decoded) && DiffPathParser.TryStripBPrefix(decoded, out var rel))
{
index._ChangedLines[currentFile] = [];
currentFile = rel;
if (!index._ChangedLines.ContainsKey(currentFile))
{
index._ChangedLines[currentFile] = [];
}
}

inHunk = false;
}
else if (line.StartsWith("+++ /dev/null", StringComparison.Ordinal))
{
currentFile = null; // deleted file
inHunk = false;
}
else if (sectionFile is not null && line.StartsWith("Binary files ", StringComparison.Ordinal))
{
sectionBinary = true;
Expand All @@ -160,7 +168,7 @@ void FlushSection()
else if (sectionFile is not null && line.StartsWith("rename to ", StringComparison.Ordinal))
{
sectionRename = true;
sectionFile = line["rename to ".Length..].ToString();
sectionFile = DiffPathParser.TryDecodeToken(line["rename to ".Length..], out var renamed) ? renamed : null;
}
}

Expand Down Expand Up @@ -211,27 +219,86 @@ internal static bool TryParseHunkNewStart(ReadOnlySpan<char> line, out int newSt
return true;
}

/// <summary>Extracts the destination path from "diff --git a/OLD b/NEW", or null.</summary>
/// <summary>Extracts the destination path from "diff --git a/OLD b/NEW", where either
/// path may be bare or C-style-quoted; returns null when unparseable.</summary>
internal static string? DiffGitNewPath(ReadOnlySpan<char> line)
{
var rest = line["diff --git a/".Length..];
var bIdx = rest.IndexOf(" b/", StringComparison.Ordinal);
return bIdx < 0 ? null : rest[(bIdx + 3)..].ToString();
const string prefix = "diff --git ";
if (!line.StartsWith(prefix, StringComparison.Ordinal))
{
return null;
}

var rest = line[prefix.Length..];
if (!DiffPathParser.TryReadToken(rest, out _, out var consumed))
{
return null;
}

rest = rest[consumed..];
if (rest.IsEmpty || rest[0] != ' ')
{
return null;
}

return DiffPathParser.TryDecodeToken(rest[1..], out var decoded) && DiffPathParser.TryStripBPrefix(decoded, out var rel)
? rel
: null;
}

/// <summary>Extracts the destination path from "Binary files a/OLD and b/NEW differ", or null.</summary>
/// <summary>Extracts the destination path from "Binary files a/OLD and b/NEW differ",
/// where either path may be bare or C-style-quoted; returns null when unparseable.</summary>
internal static string? BinaryNewPath(ReadOnlySpan<char> line)
{
var rest = line["Binary files ".Length..];
var andIdx = rest.IndexOf(" and b/", StringComparison.Ordinal);
if (andIdx < 0)
const string prefix = "Binary files ";
if (!line.StartsWith(prefix, StringComparison.Ordinal))
{
return null;
}

var tail = rest[(andIdx + " and b/".Length)..];
var differIdx = tail.LastIndexOf(" differ", StringComparison.Ordinal);
return (differIdx >= 0 ? tail[..differIdx] : tail).ToString();
var rest = line[prefix.Length..];
if (!DiffPathParser.TryReadToken(rest, out _, out var consumed))
{
return null;
}

rest = rest[consumed..];
const string and = " and ";
if (!rest.StartsWith(and, StringComparison.Ordinal))
{
return null;
}

rest = rest[and.Length..];
if (rest.IsEmpty)
{
return null;
}

if (rest[0] == '"')
{
if (!DiffPathParser.TryReadToken(rest, out var quoted, out var quotedLen)
|| !DiffPathParser.TryDecodeToken(quoted, out var decoded))
{
return null;
}

// git appends " differ" after the quoted token.
var tail = rest[quotedLen..];
if (!tail.IsEmpty && !tail.StartsWith(" differ", StringComparison.Ordinal))
{
return null;
}

return DiffPathParser.TryStripBPrefix(decoded, out var rel) ? rel : null;
}

var bareTail = rest;
var differIdx = bareTail.LastIndexOf(" differ", StringComparison.Ordinal);
var bare = differIdx >= 0 ? bareTail[..differIdx] : bareTail;
return DiffPathParser.TryDecodeToken(bare, out var bareDecoded) && DiffPathParser.TryStripBPrefix(bareDecoded, out var bareRel)
? bareRel
: null;
}

/// <summary>Number of coalesced ranges for a file (test seam — asserts coalescing).</summary>
Expand Down
Loading
Loading