| 1 |
using System.Text.RegularExpressions; |
| 2 |
using Blog.Models; |
| 3 |
using LibGit2Sharp; |
| 4 |
using Microsoft.Extensions.Options; |
| 5 |
|
| 6 |
namespace Blog.Services; |
| 7 |
|
| 8 |
|
| 9 |
public sealed class GitOptions |
| 10 |
{ |
| 11 |
public const string Section = "Git"; |
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
public string RepositoryRoot { get; set; } = "/home/git"; |
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
public string? CloneUrl { get; set; } = "ssh://git@git.bes.is/{repo}"; |
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
public int MaxDiffLines { get; set; } = 3000; |
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
public int MaxDiffCharacters { get; set; } = 250_000; |
| 40 |
|
| 41 |
|
| 42 |
public int MaxBlobBytes { get; set; } = 512 * 1024; |
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
public int MaxRawBytes { get; set; } = 25 * 1024 * 1024; |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
public sealed partial class GitService(IOptions<GitOptions> options, ILogger<GitService> logger) |
| 68 |
{ |
| 69 |
static GitService() |
| 70 |
{ |
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
GlobalSettings.SetOwnerValidation(false); |
| 84 |
} |
| 85 |
|
| 86 |
private readonly GitOptions _options = options.Value; |
| 87 |
|
| 88 |
|
| 89 |
public string Root { get; } = Expand(options.Value.RepositoryRoot); |
| 90 |
|
| 91 |
public string? CloneUrl(string repository) => |
| 92 |
_options.CloneUrl is { Length: > 0 } url ? url.Replace("{repo}", repository, StringComparison.Ordinal) : null; |
| 93 |
|
| 94 |
|
| 95 |
public GitIndexView ListRepositories() |
| 96 |
{ |
| 97 |
if (!Directory.Exists(Root)) |
| 98 |
{ |
| 99 |
|
| 100 |
|
| 101 |
|
| 102 |
logger.LogWarning("The git repository root {Root} is missing or out of reach", Root); |
| 103 |
return new GitIndexView([], $"{Root} is not there, or this process cannot reach it."); |
| 104 |
} |
| 105 |
|
| 106 |
var found = new List<GitRepoSummary>(); |
| 107 |
var candidates = 0; |
| 108 |
|
| 109 |
try |
| 110 |
{ |
| 111 |
foreach (var directory in Directory.EnumerateDirectories(Root)) |
| 112 |
{ |
| 113 |
var name = Path.GetFileName(directory); |
| 114 |
if (!IsRepositoryName(name)) continue; |
| 115 |
|
| 116 |
candidates++; |
| 117 |
|
| 118 |
try |
| 119 |
{ |
| 120 |
using var repository = Open(directory); |
| 121 |
if (repository is null) continue; |
| 122 |
found.Add(Summarise(repository, name)); |
| 123 |
} |
| 124 |
catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException) |
| 125 |
{ |
| 126 |
|
| 127 |
logger.LogWarning(exception, "Could not read the repository at {Directory}", directory); |
| 128 |
} |
| 129 |
} |
| 130 |
} |
| 131 |
catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) |
| 132 |
{ |
| 133 |
|
| 134 |
|
| 135 |
logger.LogError(exception, "Could not list the git repository root {Root}", Root); |
| 136 |
return new GitIndexView([], $"{Root} cannot be read by the user this site runs as."); |
| 137 |
} |
| 138 |
|
| 139 |
if (found.Count == 0 && candidates > 0) |
| 140 |
{ |
| 141 |
|
| 142 |
|
| 143 |
|
| 144 |
|
| 145 |
|
| 146 |
logger.LogWarning( |
| 147 |
"None of the {Candidates} directories under {Root} could be opened as a repository", |
| 148 |
candidates, |
| 149 |
Root); |
| 150 |
|
| 151 |
return new GitIndexView( |
| 152 |
[], |
| 153 |
$"Nothing under {Root} could be opened as a repository, though there " |
| 154 |
+ $"{(candidates == 1 ? "is 1 directory" : $"are {candidates} directories")} there. " |
| 155 |
+ "The user this site runs as most likely cannot read into them."); |
| 156 |
} |
| 157 |
|
| 158 |
return new GitIndexView( |
| 159 |
found.OrderBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray(), |
| 160 |
null); |
| 161 |
} |
| 162 |
|
| 163 |
public GitRepoSummary? GetRepository(string name) => |
| 164 |
WithRepository(name, repository => Summarise(repository, name)); |
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
public GitLogPage? GetLog(string name, string? reference, string? path, int skip, int take) => |
| 171 |
WithRepository(name, repository => |
| 172 |
{ |
| 173 |
if (Resolve(repository, reference) is not { } commit) return null; |
| 174 |
|
| 175 |
var tips = RefsByCommit(repository); |
| 176 |
|
| 177 |
var filter = new CommitFilter { IncludeReachableFrom = commit, SortBy = CommitSortStrategies.Time }; |
| 178 |
var commits = string.IsNullOrEmpty(path) |
| 179 |
? repository.Commits.QueryBy(filter) |
| 180 |
: repository.Commits.QueryBy(path, filter).Select(entry => entry.Commit); |
| 181 |
|
| 182 |
|
| 183 |
|
| 184 |
var page = commits.Skip(skip).Take(take + 1).ToArray(); |
| 185 |
|
| 186 |
return new GitLogPage( |
| 187 |
page.Take(take) |
| 188 |
.Select(item => ToCommit(item, tips.GetValueOrDefault(item.Sha) ?? [])) |
| 189 |
.ToArray(), |
| 190 |
page.Length > take); |
| 191 |
}); |
| 192 |
|
| 193 |
|
| 194 |
public GitCommitView? GetCommit(string name, string sha) => |
| 195 |
WithRepository(name, repository => |
| 196 |
{ |
| 197 |
if (Resolve(repository, sha) is not { } commit) return null; |
| 198 |
|
| 199 |
var parent = commit.Parents.FirstOrDefault(); |
| 200 |
var compare = new CompareOptions { Similarity = SimilarityOptions.Renames }; |
| 201 |
|
| 202 |
using var patch = repository.Diff.Compare<Patch>(parent?.Tree, commit.Tree, compare); |
| 203 |
|
| 204 |
return new GitCommitView( |
| 205 |
ToCommit(commit, RefsByCommit(repository).GetValueOrDefault(commit.Sha) ?? []), |
| 206 |
ToDiff(patch)); |
| 207 |
}); |
| 208 |
|
| 209 |
|
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
public GitPathView? GetPath(string name, string? reference, string? path) => |
| 214 |
WithRepository(name, repository => |
| 215 |
{ |
| 216 |
if (Resolve(repository, reference) is not { } commit) return null; |
| 217 |
|
| 218 |
var head = reference is { Length: > 0 } ? reference : DefaultReference(repository); |
| 219 |
var info = ToCommit(commit); |
| 220 |
path = path?.Trim('/'); |
| 221 |
|
| 222 |
if (string.IsNullOrEmpty(path)) |
| 223 |
{ |
| 224 |
return new GitPathView(info, head, "", Entries(commit.Tree, ""), null); |
| 225 |
} |
| 226 |
|
| 227 |
if (commit[path] is not { } entry) return new GitPathView(info, head, path, null, null); |
| 228 |
|
| 229 |
return entry.TargetType switch |
| 230 |
{ |
| 231 |
TreeEntryTargetType.Tree => |
| 232 |
new GitPathView(info, head, path, Entries((Tree)entry.Target, path), null), |
| 233 |
TreeEntryTargetType.Blob => |
| 234 |
new GitPathView(info, head, path, null, ToBlob((Blob)entry.Target, path)), |
| 235 |
|
| 236 |
_ => new GitPathView(info, head, path, null, null) |
| 237 |
}; |
| 238 |
}); |
| 239 |
|
| 240 |
|
| 241 |
public GitRawBlob? GetRawBlob(string name, string? reference, string path) => |
| 242 |
WithRepository(name, repository => |
| 243 |
{ |
| 244 |
path = path.Trim('/'); |
| 245 |
if (Resolve(repository, reference) is not { } commit) return null; |
| 246 |
if (commit[path]?.Target is not Blob blob) return null; |
| 247 |
if (blob.Size > _options.MaxRawBytes) return null; |
| 248 |
|
| 249 |
using var content = blob.GetContentStream(); |
| 250 |
using var buffer = new MemoryStream(); |
| 251 |
content.CopyTo(buffer); |
| 252 |
|
| 253 |
return new GitRawBlob(buffer.ToArray(), blob.IsBinary, Path.GetFileName(path)); |
| 254 |
}); |
| 255 |
|
| 256 |
|
| 257 |
public GitRefsView? GetRefs(string name) => |
| 258 |
WithRepository(name, repository => |
| 259 |
{ |
| 260 |
var head = repository.Info.IsHeadDetached ? null : repository.Head.FriendlyName; |
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
var branches = repository.Branches |
| 265 |
.Where(branch => !branch.IsRemote && branch.Tip is not null) |
| 266 |
.Select(branch => new GitRef(branch.FriendlyName, false, branch.Tip.Sha, ToCommit(branch.Tip), null)) |
| 267 |
|
| 268 |
.OrderByDescending(branch => branch.Name == head) |
| 269 |
.ThenByDescending(branch => branch.Tip!.Committer.When) |
| 270 |
.ToArray(); |
| 271 |
|
| 272 |
var tags = repository.Tags |
| 273 |
.Select(tag => new GitRef( |
| 274 |
tag.FriendlyName, |
| 275 |
true, |
| 276 |
tag.Target.Sha, |
| 277 |
tag.PeeledTarget is Commit peeled ? ToCommit(peeled) : null, |
| 278 |
tag.Annotation?.Message)) |
| 279 |
.OrderByDescending(tag => tag.Tip?.Committer.When ?? DateTimeOffset.MinValue) |
| 280 |
.ToArray(); |
| 281 |
|
| 282 |
return new GitRefsView(branches, tags); |
| 283 |
}); |
| 284 |
|
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
|
| 289 |
|
| 290 |
private string? Resolve(string name) |
| 291 |
{ |
| 292 |
if (!IsRepositoryName(name)) return null; |
| 293 |
|
| 294 |
var path = Path.GetFullPath(Path.Combine(Root, name)); |
| 295 |
if (Path.GetDirectoryName(path) != Path.GetFullPath(Root).TrimEnd(Path.DirectorySeparatorChar)) return null; |
| 296 |
|
| 297 |
return Directory.Exists(path) ? path : null; |
| 298 |
} |
| 299 |
|
| 300 |
|
| 301 |
private T? WithRepository<T>(string name, Func<Repository, T?> read) where T : class |
| 302 |
{ |
| 303 |
if (Resolve(name) is not { } directory) return null; |
| 304 |
|
| 305 |
try |
| 306 |
{ |
| 307 |
using var repository = Open(directory); |
| 308 |
return repository is null ? null : read(repository); |
| 309 |
} |
| 310 |
catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException) |
| 311 |
{ |
| 312 |
logger.LogWarning(exception, "Could not read the repository {Name}", name); |
| 313 |
return null; |
| 314 |
} |
| 315 |
} |
| 316 |
|
| 317 |
|
| 318 |
|
| 319 |
|
| 320 |
|
| 321 |
private Repository? Open(string directory) |
| 322 |
{ |
| 323 |
if (Repository.IsValid(directory)) return new Repository(directory); |
| 324 |
|
| 325 |
var dotGit = Path.Combine(directory, ".git"); |
| 326 |
if (Repository.IsValid(dotGit)) return new Repository(dotGit); |
| 327 |
|
| 328 |
|
| 329 |
|
| 330 |
|
| 331 |
|
| 332 |
if (File.Exists(Path.Combine(directory, "HEAD")) && Directory.Exists(Path.Combine(directory, "objects"))) |
| 333 |
{ |
| 334 |
logger.LogWarning( |
| 335 |
"{Directory} looks like a bare repository, but libgit2 will not open it. Check that " |
| 336 |
+ "the user this site runs as can read it", |
| 337 |
directory); |
| 338 |
} |
| 339 |
|
| 340 |
return null; |
| 341 |
} |
| 342 |
|
| 343 |
private GitRepoSummary Summarise(Repository repository, string name) |
| 344 |
{ |
| 345 |
var tip = repository.Head.Tip; |
| 346 |
|
| 347 |
return new GitRepoSummary( |
| 348 |
name, |
| 349 |
name.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name, |
| 350 |
Description(repository), |
| 351 |
repository.Config.Get<string>("gitweb.owner")?.Value, |
| 352 |
repository.Info.IsHeadDetached || tip is null ? null : repository.Head.FriendlyName, |
| 353 |
tip is null ? null : ToCommit(tip)); |
| 354 |
} |
| 355 |
|
| 356 |
|
| 357 |
|
| 358 |
|
| 359 |
|
| 360 |
|
| 361 |
private string? Description(Repository repository) |
| 362 |
{ |
| 363 |
var path = Path.Combine(repository.Info.Path, "description"); |
| 364 |
if (!File.Exists(path)) return null; |
| 365 |
|
| 366 |
try |
| 367 |
{ |
| 368 |
var text = File.ReadAllText(path).Trim(); |
| 369 |
return text.Length == 0 || text.StartsWith("Unnamed repository", StringComparison.Ordinal) ? null : text; |
| 370 |
} |
| 371 |
catch (IOException exception) |
| 372 |
{ |
| 373 |
logger.LogWarning(exception, "Could not read {Path}", path); |
| 374 |
return null; |
| 375 |
} |
| 376 |
} |
| 377 |
|
| 378 |
|
| 379 |
|
| 380 |
|
| 381 |
|
| 382 |
private static Commit? Resolve(Repository repository, string? reference) |
| 383 |
{ |
| 384 |
if (string.IsNullOrWhiteSpace(reference)) return repository.Head.Tip; |
| 385 |
|
| 386 |
try |
| 387 |
{ |
| 388 |
return Peel(repository.Lookup(reference)); |
| 389 |
} |
| 390 |
catch (LibGit2SharpException) |
| 391 |
{ |
| 392 |
|
| 393 |
return null; |
| 394 |
} |
| 395 |
} |
| 396 |
|
| 397 |
private static Commit? Peel(GitObject? item) => item switch |
| 398 |
{ |
| 399 |
Commit commit => commit, |
| 400 |
TagAnnotation tag => Peel(tag.Target), |
| 401 |
_ => null |
| 402 |
}; |
| 403 |
|
| 404 |
private static string DefaultReference(Repository repository) => |
| 405 |
repository.Info.IsHeadDetached ? repository.Head.Tip?.Sha ?? "HEAD" : repository.Head.FriendlyName; |
| 406 |
|
| 407 |
|
| 408 |
|
| 409 |
|
| 410 |
|
| 411 |
private static Dictionary<string, List<string>> RefsByCommit(Repository repository) |
| 412 |
{ |
| 413 |
var tips = new Dictionary<string, List<string>>(StringComparer.Ordinal); |
| 414 |
|
| 415 |
void Add(string? sha, string label) |
| 416 |
{ |
| 417 |
if (sha is null) return; |
| 418 |
if (!tips.TryGetValue(sha, out var names)) tips[sha] = names = []; |
| 419 |
if (!names.Contains(label)) names.Add(label); |
| 420 |
} |
| 421 |
|
| 422 |
|
| 423 |
foreach (var branch in repository.Branches.Where(branch => !branch.IsRemote)) |
| 424 |
{ |
| 425 |
Add(branch.Tip?.Sha, branch.FriendlyName); |
| 426 |
} |
| 427 |
foreach (var tag in repository.Tags) Add((tag.PeeledTarget as Commit)?.Sha, tag.FriendlyName); |
| 428 |
|
| 429 |
return tips; |
| 430 |
} |
| 431 |
|
| 432 |
private static GitCommitInfo ToCommit(Commit commit, IReadOnlyList<string>? refs = null) => new( |
| 433 |
commit.Sha, |
| 434 |
commit.MessageShort, |
| 435 |
|
| 436 |
commit.Message.Length > commit.MessageShort.Length |
| 437 |
? commit.Message[commit.MessageShort.Length..].Trim('\n', '\r') |
| 438 |
: "", |
| 439 |
ToSignature(commit.Author), |
| 440 |
ToSignature(commit.Committer), |
| 441 |
commit.Parents.Select(parent => parent.Sha).ToArray(), |
| 442 |
refs ?? []); |
| 443 |
|
| 444 |
private static GitSignature ToSignature(Signature signature) => |
| 445 |
new(signature.Name, signature.Email, signature.When); |
| 446 |
|
| 447 |
|
| 448 |
private static IReadOnlyList<GitTreeEntry> Entries(Tree tree, string prefix) => |
| 449 |
tree.Select(entry => |
| 450 |
{ |
| 451 |
var target = entry.Target; |
| 452 |
|
| 453 |
return new GitTreeEntry( |
| 454 |
entry.Name, |
| 455 |
prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}", |
| 456 |
Kind(entry.Mode), |
| 457 |
|
| 458 |
|
| 459 |
target is Blob blob ? blob.Size : 0, |
| 460 |
target.Sha); |
| 461 |
}) |
| 462 |
.OrderBy(entry => entry.Kind == GitEntryKind.Directory ? 0 : 1) |
| 463 |
.ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) |
| 464 |
.ToArray(); |
| 465 |
|
| 466 |
private static GitEntryKind Kind(Mode mode) => mode switch |
| 467 |
{ |
| 468 |
Mode.Directory => GitEntryKind.Directory, |
| 469 |
Mode.ExecutableFile => GitEntryKind.Executable, |
| 470 |
Mode.SymbolicLink => GitEntryKind.Symlink, |
| 471 |
Mode.GitLink => GitEntryKind.Submodule, |
| 472 |
_ => GitEntryKind.File |
| 473 |
}; |
| 474 |
|
| 475 |
private GitBlob ToBlob(Blob blob, string path) |
| 476 |
{ |
| 477 |
if (blob.IsBinary) return new GitBlob(path, blob.Sha, blob.Size, true, false, null); |
| 478 |
if (blob.Size > _options.MaxBlobBytes) return new GitBlob(path, blob.Sha, blob.Size, false, true, null); |
| 479 |
|
| 480 |
|
| 481 |
|
| 482 |
|
| 483 |
|
| 484 |
var text = blob.GetContentText().ReplaceLineEndings("\n"); |
| 485 |
if (text.EndsWith('\n')) text = text[..^1]; |
| 486 |
|
| 487 |
return new GitBlob(path, blob.Sha, blob.Size, false, false, text); |
| 488 |
} |
| 489 |
|
| 490 |
private GitDiff ToDiff(Patch patch) |
| 491 |
{ |
| 492 |
var files = new List<GitDiffFile>(); |
| 493 |
var lineBudget = _options.MaxDiffLines; |
| 494 |
var characterBudget = _options.MaxDiffCharacters; |
| 495 |
var truncated = false; |
| 496 |
|
| 497 |
foreach (var change in patch) |
| 498 |
{ |
| 499 |
var hunks = (IReadOnlyList<GitDiffHunk>)[]; |
| 500 |
var skipped = false; |
| 501 |
|
| 502 |
if (change.IsBinaryComparison) |
| 503 |
{ |
| 504 |
|
| 505 |
} |
| 506 |
else if (lineBudget <= 0 || characterBudget <= 0) |
| 507 |
{ |
| 508 |
skipped = true; |
| 509 |
} |
| 510 |
else |
| 511 |
{ |
| 512 |
var parsed = ParsePatch(change.Patch, lineBudget, characterBudget); |
| 513 |
hunks = parsed.Hunks; |
| 514 |
lineBudget -= parsed.Lines; |
| 515 |
characterBudget -= parsed.Characters; |
| 516 |
truncated |= parsed.Cut; |
| 517 |
} |
| 518 |
|
| 519 |
files.Add(new GitDiffFile( |
| 520 |
change.Path, |
| 521 |
change.OldPath == change.Path ? null : change.OldPath, |
| 522 |
Change(change.Status), |
| 523 |
change.LinesAdded, |
| 524 |
change.LinesDeleted, |
| 525 |
change.IsBinaryComparison, |
| 526 |
skipped, |
| 527 |
hunks)); |
| 528 |
} |
| 529 |
|
| 530 |
return new GitDiff( |
| 531 |
files, |
| 532 |
files.Sum(file => file.Added), |
| 533 |
files.Sum(file => file.Deleted), |
| 534 |
truncated || files.Any(file => file.Skipped)); |
| 535 |
} |
| 536 |
|
| 537 |
private static GitChange Change(ChangeKind kind) => kind switch |
| 538 |
{ |
| 539 |
ChangeKind.Added => GitChange.Added, |
| 540 |
ChangeKind.Deleted => GitChange.Deleted, |
| 541 |
ChangeKind.Renamed => GitChange.Renamed, |
| 542 |
ChangeKind.Copied => GitChange.Copied, |
| 543 |
ChangeKind.TypeChanged => GitChange.TypeChanged, |
| 544 |
_ => GitChange.Modified |
| 545 |
}; |
| 546 |
|
| 547 |
|
| 548 |
|
| 549 |
|
| 550 |
|
| 551 |
|
| 552 |
|
| 553 |
|
| 554 |
|
| 555 |
private static (IReadOnlyList<GitDiffHunk> Hunks, int Lines, int Characters, bool Cut) ParsePatch( |
| 556 |
string patch, |
| 557 |
int lineBudget, |
| 558 |
int characterBudget) |
| 559 |
{ |
| 560 |
var hunks = new List<GitDiffHunk>(); |
| 561 |
List<GitDiffLine>? lines = null; |
| 562 |
var header = ""; |
| 563 |
int oldNumber = 0, newNumber = 0, used = 0, written = 0; |
| 564 |
var cut = false; |
| 565 |
|
| 566 |
void Flush() |
| 567 |
{ |
| 568 |
if (lines is { Count: > 0 }) hunks.Add(new GitDiffHunk(header, lines)); |
| 569 |
lines = null; |
| 570 |
} |
| 571 |
|
| 572 |
foreach (var raw in patch.Split('\n')) |
| 573 |
{ |
| 574 |
var line = raw.TrimEnd('\r'); |
| 575 |
|
| 576 |
if (line.StartsWith("@@", StringComparison.Ordinal)) |
| 577 |
{ |
| 578 |
Flush(); |
| 579 |
|
| 580 |
if (HunkHeader().Match(line) is not { Success: true } match) continue; |
| 581 |
|
| 582 |
oldNumber = int.Parse(match.Groups["old"].ValueSpan); |
| 583 |
newNumber = int.Parse(match.Groups["new"].ValueSpan); |
| 584 |
header = line; |
| 585 |
lines = []; |
| 586 |
continue; |
| 587 |
} |
| 588 |
|
| 589 |
|
| 590 |
|
| 591 |
if (lines is null || line.Length == 0) continue; |
| 592 |
|
| 593 |
if (used >= lineBudget || written >= characterBudget) |
| 594 |
{ |
| 595 |
cut = true; |
| 596 |
break; |
| 597 |
} |
| 598 |
|
| 599 |
var text = line[1..]; |
| 600 |
written += text.Length; |
| 601 |
switch (line[0]) |
| 602 |
{ |
| 603 |
case '+': |
| 604 |
lines.Add(new GitDiffLine('+', null, newNumber++, text)); |
| 605 |
used++; |
| 606 |
break; |
| 607 |
case '-': |
| 608 |
lines.Add(new GitDiffLine('-', oldNumber++, null, text)); |
| 609 |
used++; |
| 610 |
break; |
| 611 |
case ' ': |
| 612 |
lines.Add(new GitDiffLine(' ', oldNumber++, newNumber++, text)); |
| 613 |
used++; |
| 614 |
break; |
| 615 |
case '\\': |
| 616 |
|
| 617 |
lines.Add(new GitDiffLine('\\', null, null, text.Trim())); |
| 618 |
break; |
| 619 |
} |
| 620 |
} |
| 621 |
|
| 622 |
Flush(); |
| 623 |
return (hunks, used, written, cut); |
| 624 |
} |
| 625 |
|
| 626 |
|
| 627 |
|
| 628 |
|
| 629 |
|
| 630 |
private static bool IsRepositoryName(string name) => |
| 631 |
name.Length is > 0 and <= 100 && RepositoryName().IsMatch(name); |
| 632 |
|
| 633 |
private static string Expand(string path) => |
| 634 |
path.StartsWith("~/", StringComparison.Ordinal) |
| 635 |
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]) |
| 636 |
: path; |
| 637 |
|
| 638 |
[GeneratedRegex(@"^@@ -(?<old>\d+)(?:,\d+)? \+(?<new>\d+)(?:,\d+)? @@")] |
| 639 |
private static partial Regex HunkHeader(); |
| 640 |
|
| 641 |
[GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9._+-]*$")] |
| 642 |
private static partial Regex RepositoryName(); |
| 643 |
} |