cloudflare/pint
Publicmirrored from https://github.com/cloudflare/pintAvailable
internal/git/changes.go
653lines · modecode
| 1 | package git |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "log/slog" |
| 9 | "os" |
| 10 | "path" |
| 11 | "regexp" |
| 12 | "slices" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | ) |
| 16 | |
| 17 | type FileStatus rune |
| 18 | |
| 19 | const ( |
| 20 | FileAdded FileStatus = 'A' |
| 21 | FileCopied FileStatus = 'C' |
| 22 | FileDeleted FileStatus = 'D' |
| 23 | FileRenamed FileStatus = 'R' |
| 24 | FileModified FileStatus = 'M' |
| 25 | FileTypeChanged FileStatus = 'T' |
| 26 | ) |
| 27 | |
| 28 | type PathType uint8 |
| 29 | |
| 30 | const ( |
| 31 | Missing PathType = iota |
| 32 | Dir |
| 33 | File |
| 34 | Symlink |
| 35 | ) |
| 36 | |
| 37 | type TypeDiff struct { |
| 38 | Before PathType |
| 39 | After PathType |
| 40 | } |
| 41 | |
| 42 | type LineNumber struct { |
| 43 | Before int |
| 44 | After int |
| 45 | Modified bool |
| 46 | } |
| 47 | |
| 48 | func (ln LineNumber) String() string { |
| 49 | switch { |
| 50 | case ln.Before == 0 && ln.After > 0: |
| 51 | return fmt.Sprintf("+%d", ln.After) |
| 52 | case ln.Before > 0 && ln.After == 0: |
| 53 | return fmt.Sprintf("-%d", ln.Before) |
| 54 | case ln.Before == ln.After: |
| 55 | return strconv.Itoa(ln.Before) |
| 56 | default: |
| 57 | return fmt.Sprintf("%d->%d", ln.Before, ln.After) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | type LineNumbers []LineNumber |
| 62 | |
| 63 | func (lns LineNumbers) String() string { |
| 64 | parts := make([]string, len(lns)) |
| 65 | for i, ln := range lns { |
| 66 | parts[i] = ln.String() |
| 67 | } |
| 68 | return strings.Join(parts, " ") |
| 69 | } |
| 70 | |
| 71 | func (lns LineNumbers) HasAfter(line int) bool { |
| 72 | for _, ln := range lns { |
| 73 | if ln.After == line && ln.Modified { |
| 74 | return true |
| 75 | } |
| 76 | } |
| 77 | return false |
| 78 | } |
| 79 | |
| 80 | func (lns LineNumbers) HasAnyAfter(line int) bool { |
| 81 | for _, ln := range lns { |
| 82 | if ln.After == line { |
| 83 | return true |
| 84 | } |
| 85 | } |
| 86 | return false |
| 87 | } |
| 88 | |
| 89 | func (lns LineNumbers) HasBefore(line int) bool { |
| 90 | for _, ln := range lns { |
| 91 | if ln.Before == line { |
| 92 | return true |
| 93 | } |
| 94 | } |
| 95 | return false |
| 96 | } |
| 97 | |
| 98 | // Nearest finds the closest line to the target within changedLines. |
| 99 | // It prefers lines after the target over lines before and lines within the range, |
| 100 | // but can select nearest line outside of the range if needed. |
| 101 | // Returns (0, false) if there are no usable entries. |
| 102 | func (lns LineNumbers) Nearest(target, rangeFirst, rangeLast int) (int, bool) { |
| 103 | // Prefer modified After lines — these are added/changed lines on the new side of the diff. |
| 104 | if line := lns.nearestFrom(target, rangeFirst, rangeLast, func(ln LineNumber) int { |
| 105 | if ln.After > 0 && ln.Modified { |
| 106 | return ln.After |
| 107 | } |
| 108 | return 0 |
| 109 | }); line > 0 { |
| 110 | return line, false |
| 111 | } |
| 112 | // Fall back to Before lines — deleted lines on the old side of the diff. |
| 113 | if line := lns.nearestFrom(target, rangeFirst, rangeLast, func(ln LineNumber) int { |
| 114 | return ln.Before |
| 115 | }); line > 0 { |
| 116 | return line, true |
| 117 | } |
| 118 | return 0, false |
| 119 | } |
| 120 | |
| 121 | // nearestFrom finds the line closest to target among candidates returned by lineFunc. |
| 122 | // It prefers in-range lines over out-of-range lines, and among each group picks |
| 123 | // the one with the smallest distance to target. |
| 124 | func (lns LineNumbers) nearestFrom(target, rangeFirst, rangeLast int, lineFunc func(LineNumber) int) int { |
| 125 | var bestInRange, bestOutRange int |
| 126 | bestInRangeDist := -1 |
| 127 | bestOutRangeDist := -1 |
| 128 | |
| 129 | for _, ln := range lns { |
| 130 | v := lineFunc(ln) |
| 131 | if v <= 0 { |
| 132 | continue |
| 133 | } |
| 134 | dist := v - target |
| 135 | if dist < 0 { |
| 136 | dist = -dist |
| 137 | } |
| 138 | if v >= rangeFirst && v <= rangeLast { |
| 139 | if bestInRangeDist < 0 || dist < bestInRangeDist { |
| 140 | bestInRange = v |
| 141 | bestInRangeDist = dist |
| 142 | } |
| 143 | } else if bestOutRangeDist < 0 || dist < bestOutRangeDist { |
| 144 | bestOutRange = v |
| 145 | bestOutRangeDist = dist |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // Prefer in-range match, fall back to closest out-of-range match. |
| 150 | if bestInRange > 0 { |
| 151 | return bestInRange |
| 152 | } |
| 153 | return bestOutRange |
| 154 | } |
| 155 | |
| 156 | // BeforeForAfter returns the old (before the change) line number for a given |
| 157 | // new (after the change) line number. |
| 158 | // |
| 159 | // LineNumbers contains explicit mappings for changed, deleted, added, |
| 160 | // and shifted lines. Unchanged lines that were not shifted are not stored. |
| 161 | // |
| 162 | // Returns: |
| 163 | // - Before value if the line is present in LineNumbers. |
| 164 | // - Line itself if no mapping exists (unchanged and unshifted). |
| 165 | func (lns LineNumbers) BeforeForAfter(line int) int { |
| 166 | for _, ln := range lns { |
| 167 | if ln.After == line { |
| 168 | return ln.Before |
| 169 | } |
| 170 | } |
| 171 | return line |
| 172 | } |
| 173 | |
| 174 | type LineRangeSide uint8 |
| 175 | |
| 176 | const ( |
| 177 | LinesBefore LineRangeSide = iota |
| 178 | LinesAfter |
| 179 | LinesBoth |
| 180 | ) |
| 181 | |
| 182 | type BodyDiff struct { |
| 183 | Before []byte |
| 184 | After []byte |
| 185 | Lines LineNumbers |
| 186 | } |
| 187 | |
| 188 | type Path struct { |
| 189 | Name string |
| 190 | SymlinkTarget string |
| 191 | Type PathType |
| 192 | } |
| 193 | |
| 194 | func (p Path) EffectivePath() string { |
| 195 | if p.SymlinkTarget != "" && p.Name != p.SymlinkTarget { |
| 196 | return p.SymlinkTarget |
| 197 | } |
| 198 | return p.Name |
| 199 | } |
| 200 | |
| 201 | type PathDiff struct { |
| 202 | Before Path |
| 203 | After Path |
| 204 | } |
| 205 | |
| 206 | type FileChange struct { |
| 207 | Path PathDiff |
| 208 | Body BodyDiff |
| 209 | Commits []string |
| 210 | Status FileStatus |
| 211 | } |
| 212 | |
| 213 | func Changes(ctx context.Context, cmd CommandRunner, baseBranch string, filter PathFilter) ([]*FileChange, error) { |
| 214 | out, err := cmd(ctx, "log", "--reverse", "--no-merges", "--first-parent", "--format=%H", "--name-status", baseBranch+"..HEAD") |
| 215 | if err != nil { |
| 216 | return nil, fmt.Errorf("failed to get the list of modified files from git: %w", err) |
| 217 | } |
| 218 | |
| 219 | var changes []*FileChange |
| 220 | var commit string |
| 221 | s := bufio.NewScanner(bytes.NewReader(out)) |
| 222 | for s.Scan() { |
| 223 | line := s.Text() |
| 224 | |
| 225 | parts := strings.Split(line, "\t") |
| 226 | // Split always returns at least 1 element slice. |
| 227 | if len(parts) == 1 { |
| 228 | if parts[0] != "" { |
| 229 | commit = parts[0] |
| 230 | } |
| 231 | continue |
| 232 | } |
| 233 | |
| 234 | status := FileStatus(parts[0][0]) |
| 235 | srcPath := parts[1] |
| 236 | dstPath := parts[len(parts)-1] |
| 237 | slog.LogAttrs(context.Background(), slog.LevelDebug, "Git file change", slog.String("change", parts[0]), slog.String("path", dstPath), slog.String("commit", commit)) |
| 238 | |
| 239 | if !filter.IsPathAllowed(dstPath) { |
| 240 | slog.LogAttrs(context.Background(), slog.LevelDebug, "Skipping file due to include/exclude rules", slog.String("path", dstPath)) |
| 241 | continue |
| 242 | } |
| 243 | |
| 244 | // This should never really happen since git doesn't track directories, only files. |
| 245 | if isDir, _ := isDirectoryPath(dstPath); isDir { |
| 246 | slog.LogAttrs(context.Background(), slog.LevelDebug, "Skipping directory entry change", slog.String("path", dstPath)) |
| 247 | continue |
| 248 | } |
| 249 | |
| 250 | // Rest is populated inside the next loop. |
| 251 | change := &FileChange{ // nolint: exhaustruct |
| 252 | Status: status, |
| 253 | Path: PathDiff{ // nolint: exhaustruct |
| 254 | After: Path{ // nolint: exhaustruct |
| 255 | Name: dstPath, |
| 256 | }, |
| 257 | }, |
| 258 | } |
| 259 | |
| 260 | prev := getChangeByPath(changes, srcPath) |
| 261 | slog.LogAttrs( |
| 262 | context.Background(), slog.LevelDebug, "Looking for previous changes", |
| 263 | slog.String("src", srcPath), |
| 264 | slog.String("dst", dstPath), |
| 265 | slog.String("commit", commit), |
| 266 | ) |
| 267 | if prev != nil { |
| 268 | slog.LogAttrs( |
| 269 | context.Background(), slog.LevelDebug, "Found a previous change", |
| 270 | slog.Any("commits", prev.Commits), |
| 271 | slog.String("status", string(prev.Status)), |
| 272 | slog.String("path", prev.Path.Before.Name), |
| 273 | slog.String("target", prev.Path.Before.SymlinkTarget), |
| 274 | slog.Any("type", prev.Path.Before.Type), |
| 275 | ) |
| 276 | change.Commits = append(change.Commits, prev.Commits...) |
| 277 | change.Path.Before = prev.Path.Before |
| 278 | // Remove any changes for "BEFORE" path we might already have |
| 279 | changes = changesWithout(changes, srcPath) |
| 280 | } else { |
| 281 | slog.LogAttrs(context.Background(), slog.LevelDebug, "No previous change found") |
| 282 | switch change.Status { |
| 283 | case FileAdded, FileCopied: |
| 284 | change.Path.Before.Name = "" |
| 285 | change.Path.Before.SymlinkTarget = "" |
| 286 | // If a path changed type we'll see A but we can still query for old type. |
| 287 | change.Path.Before.Type = getTypeForPath(ctx, cmd, commit+"^", srcPath) |
| 288 | if change.Path.Before.Type != Missing { |
| 289 | // If it was a type change then |
| 290 | change.Path.Before.Name = srcPath |
| 291 | change.Path.Before.Type = getTypeForPath(ctx, cmd, commit+"^", srcPath) |
| 292 | } |
| 293 | case FileDeleted, FileRenamed, FileModified, FileTypeChanged: |
| 294 | change.Path.Before.Name = srcPath |
| 295 | change.Path.Before.Type = getTypeForPath(ctx, cmd, commit+"^", srcPath) |
| 296 | change.Path.Before.SymlinkTarget = resolveSymlinkTarget(ctx, cmd, commit+"^", srcPath, change.Path.Before.Type) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | change.Commits = append(change.Commits, commit) |
| 301 | |
| 302 | changes = append(changes, change) |
| 303 | } |
| 304 | |
| 305 | slog.LogAttrs(context.Background(), slog.LevelDebug, "Parsed git log", slog.Int("changes", len(changes))) |
| 306 | |
| 307 | for _, change := range changes { |
| 308 | slog.LogAttrs( |
| 309 | context.Background(), slog.LevelDebug, |
| 310 | "File change", |
| 311 | slog.Any("commits", change.Commits), |
| 312 | slog.String("status", string(change.Status)), |
| 313 | slog.String("before", change.Path.Before.Name), |
| 314 | slog.String("after", change.Path.After.Name), |
| 315 | ) |
| 316 | |
| 317 | if change.Path.Before.Name != "" { |
| 318 | change.Path.Before.Type = getTypeForPath(ctx, cmd, change.Commits[0]+"^", change.Path.Before.Name) |
| 319 | change.Path.Before.SymlinkTarget = resolveSymlinkTarget(ctx, cmd, change.Commits[0]+"^", change.Path.Before.Name, change.Path.Before.Type) |
| 320 | change.Body.Before = getContentAtCommit(ctx, cmd, change.Commits[0]+"^", change.Path.Before.EffectivePath()) |
| 321 | } |
| 322 | |
| 323 | lastCommit := change.Commits[len(change.Commits)-1] |
| 324 | if change.Path.After.Name != "" && change.Status != FileDeleted { |
| 325 | change.Path.After.Type = getTypeForPath(ctx, cmd, lastCommit, change.Path.After.Name) |
| 326 | change.Path.After.SymlinkTarget = resolveSymlinkTarget(ctx, cmd, lastCommit, change.Path.After.Name, change.Path.After.Type) |
| 327 | change.Body.After = getContentAtCommit(ctx, cmd, lastCommit, change.Path.After.EffectivePath()) |
| 328 | } |
| 329 | |
| 330 | slog.LogAttrs( |
| 331 | context.Background(), slog.LevelDebug, |
| 332 | "Updated file change", |
| 333 | slog.Any("commits", change.Commits), |
| 334 | slog.String("before.path", change.Path.Before.Name), |
| 335 | slog.String("before.target", change.Path.Before.SymlinkTarget), |
| 336 | slog.Any("before.type", change.Path.Before.Type), |
| 337 | slog.String("before.body", string(change.Body.Before)), |
| 338 | slog.String("after.path", change.Path.After.Name), |
| 339 | slog.String("after.target", change.Path.After.SymlinkTarget), |
| 340 | slog.Any("after.type", change.Path.After.Type), |
| 341 | slog.String("after.body", string(change.Body.After)), |
| 342 | slog.Any("lines", change.Body.Lines), |
| 343 | ) |
| 344 | |
| 345 | switch { |
| 346 | case change.Path.Before.Type != Missing && change.Path.Before.Type != Symlink && change.Path.After.Type == Symlink: |
| 347 | slog.LogAttrs(context.Background(), slog.LevelDebug, "Path was turned into a symlink", slog.String("path", change.Path.After.Name)) |
| 348 | change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.After), LinesAfter) |
| 349 | case change.Path.Before.Type != Missing && change.Path.After.Type != Missing && change.Path.After.Type != Symlink: |
| 350 | change.Body.Lines, err = getModifiedLines(ctx, cmd, change.Commits, change.Path.Before.EffectivePath(), change.Path.After.EffectivePath()) |
| 351 | if err != nil { |
| 352 | return nil, fmt.Errorf("failed to run git diff for %s: %w", change.Path.After.EffectivePath(), err) |
| 353 | } |
| 354 | slog.LogAttrs(context.Background(), slog.LevelDebug, "File was modified", slog.String("path", change.Path.After.Name), slog.Any("lines", change.Body.Lines)) |
| 355 | case change.Path.Before.Type == Symlink && change.Path.After.Type == Symlink: |
| 356 | slog.LogAttrs(context.Background(), slog.LevelDebug, "Symlink was modified", slog.String("path", change.Path.After.Name)) |
| 357 | // symlink was modified, every source line is modification |
| 358 | change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.After), LinesAfter) |
| 359 | case change.Path.Before.Type == Missing && change.Path.After.Type != Missing: |
| 360 | slog.LogAttrs(context.Background(), slog.LevelDebug, "File was added", slog.String("path", change.Path.After.Name)) |
| 361 | // old file body is empty, meaning that every line was modified |
| 362 | change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.After), LinesAfter) |
| 363 | case change.Path.Before.Type != Missing && change.Path.After.Type == Missing: |
| 364 | slog.LogAttrs(context.Background(), slog.LevelDebug, "File was removed", slog.String("path", change.Path.After.Name)) |
| 365 | // new file body is empty, meaning that every line was modified |
| 366 | change.Body.Lines = MakeLineRangeFromTo(1, CountLines(change.Body.Before), LinesBefore) |
| 367 | case change.Path.Before.Type == Missing && change.Path.After.Type == Missing: |
| 368 | slog.LogAttrs(context.Background(), slog.LevelDebug, "File was added and removed", slog.String("path", change.Path.After.Name)) |
| 369 | // file was added and then removed |
| 370 | change.Body.Lines = []LineNumber{} |
| 371 | } |
| 372 | |
| 373 | if change.Path.Before.Name == change.Path.Before.SymlinkTarget { |
| 374 | change.Path.Before.SymlinkTarget = "" |
| 375 | } |
| 376 | if change.Path.After.Name == change.Path.After.SymlinkTarget { |
| 377 | change.Path.After.SymlinkTarget = "" |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | return changes, nil |
| 382 | } |
| 383 | |
| 384 | func changesWithout(changes []*FileChange, fpath string) []*FileChange { |
| 385 | return slices.DeleteFunc(changes, func(e *FileChange) bool { |
| 386 | return e.Path.After.Name == fpath |
| 387 | }) |
| 388 | } |
| 389 | |
| 390 | func getChangeByPath(changes []*FileChange, fpath string) *FileChange { |
| 391 | for _, c := range changes { |
| 392 | if c.Path.After.Name == fpath { |
| 393 | return c |
| 394 | } |
| 395 | } |
| 396 | return nil |
| 397 | } |
| 398 | |
| 399 | func getModifiedLines(ctx context.Context, cmd CommandRunner, commits []string, beforePath, afterPath string) (LineNumbers, error) { |
| 400 | slog.LogAttrs( |
| 401 | context.Background(), slog.LevelDebug, "Getting list of modified lines", |
| 402 | slog.Any("commits", commits), |
| 403 | slog.String("beforePath", beforePath), |
| 404 | slog.String("afterPath", afterPath), |
| 405 | ) |
| 406 | |
| 407 | output, err := cmd(ctx, "diff", "-M", commits[0]+"^.."+commits[len(commits)-1], "--", beforePath, afterPath) |
| 408 | if err != nil { |
| 409 | return nil, fmt.Errorf("git diff for %s: %w", afterPath, err) |
| 410 | } |
| 411 | |
| 412 | lineNumbers := parseDiff(output, afterPath) |
| 413 | |
| 414 | slog.LogAttrs( |
| 415 | context.Background(), slog.LevelDebug, "List of modified lines", |
| 416 | slog.Any("commits", commits), |
| 417 | slog.String("beforePath", beforePath), |
| 418 | slog.String("afterPath", afterPath), |
| 419 | slog.Any("lines", lineNumbers), |
| 420 | ) |
| 421 | return lineNumbers, nil |
| 422 | } |
| 423 | |
| 424 | var diffHunkRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) |
| 425 | |
| 426 | // parseDiff parses unified diff output from git and returns a list of line |
| 427 | // number mappings between old and new file versions. |
| 428 | // |
| 429 | // A unified diff contains one or more hunks, each starting with a header: |
| 430 | // |
| 431 | // @@ -oldStart,oldCount +newStart,newCount @@ |
| 432 | // |
| 433 | // Lines in the hunk body are prefixed with: |
| 434 | // - " " (space) -- unchanged context line, present in both old and new file. |
| 435 | // - "-" -- line removed from the old file. |
| 436 | // - "+" -- line added in the new file. |
| 437 | // |
| 438 | // When a line is modified, git represents it as a deletion followed by an |
| 439 | // addition. We pair consecutive delete/add sequences to detect modifications: |
| 440 | // - Paired delete+add -> edited line: {Before: oldLine, After: newLine}. |
| 441 | // - Unpaired delete -> removed line: {Before: oldLine, After: 0}. |
| 442 | // - Unpaired add -> added line: {Before: 0, After: newLine}. |
| 443 | // |
| 444 | // Context lines are tracked so shifted lines appear in the output. |
| 445 | // |
| 446 | // When the diff contains multiple files (e.g. renames), only the block |
| 447 | // matching targetPath is processed. |
| 448 | func parseDiff(diff []byte, targetPath string) LineNumbers { |
| 449 | var ( |
| 450 | oldLine, newLine int |
| 451 | lineNumbers = LineNumbers{} |
| 452 | // Pending deleted lines waiting to be paired with additions. |
| 453 | pendingDeletes []int |
| 454 | // Only process hunks from the diff block matching targetPath. |
| 455 | inTargetBlock bool |
| 456 | // True once we've entered a hunk (after @@). Reset on "diff " |
| 457 | // which starts a new file. Inside a hunk, all lines are content |
| 458 | // -- we must not match diff/+++/--- headers. |
| 459 | inHunk bool |
| 460 | ) |
| 461 | |
| 462 | // flushDeletes emits any pending deleted lines that were not paired |
| 463 | // with a subsequent addition. Unpaired deletes are line removals |
| 464 | // (Before set, After=0). This is called when we hit a boundary that |
| 465 | // ends a contiguous delete/add block: a new hunk, a context line, |
| 466 | // or a new diff section. |
| 467 | flushDeletes := func() { |
| 468 | for _, d := range pendingDeletes { |
| 469 | lineNumbers = append(lineNumbers, LineNumber{Before: d, After: 0, Modified: false}) |
| 470 | } |
| 471 | pendingDeletes = pendingDeletes[:0] |
| 472 | } |
| 473 | |
| 474 | sc := bufio.NewScanner(bytes.NewReader(diff)) |
| 475 | for sc.Scan() { |
| 476 | line := sc.Text() |
| 477 | switch { |
| 478 | case strings.HasPrefix(line, "diff "): |
| 479 | flushDeletes() |
| 480 | inTargetBlock = false |
| 481 | inHunk = false |
| 482 | case !inHunk && strings.HasPrefix(line, "+++ "): |
| 483 | // +++ b/path or +++ /dev/null |
| 484 | p := strings.TrimPrefix(line, "+++ b/") |
| 485 | inTargetBlock = p == targetPath |
| 486 | case !inTargetBlock: |
| 487 | continue |
| 488 | case strings.HasPrefix(line, "@@"): |
| 489 | flushDeletes() |
| 490 | pendingDeletes = pendingDeletes[:0] |
| 491 | inHunk = true |
| 492 | matches := diffHunkRe.FindStringSubmatch(line) |
| 493 | if len(matches) >= 4 { |
| 494 | oldLine, _ = strconv.Atoi(matches[1]) |
| 495 | oldLine-- |
| 496 | newLine, _ = strconv.Atoi(matches[3]) |
| 497 | newLine-- |
| 498 | } |
| 499 | |
| 500 | case strings.HasPrefix(line, `\ `): |
| 501 | // Diff metadata like "\ No newline at end of file". |
| 502 | // Not a real file line — skip without touching counters |
| 503 | // or pending state. |
| 504 | continue |
| 505 | |
| 506 | case strings.HasPrefix(line, "-"): |
| 507 | oldLine++ |
| 508 | pendingDeletes = append(pendingDeletes, oldLine) |
| 509 | |
| 510 | case strings.HasPrefix(line, "+"): |
| 511 | // Added line -- if there's a pending delete, pair them as a |
| 512 | // modification (old line replaced by new line). Otherwise it's |
| 513 | // a new line (Before=0). |
| 514 | newLine++ |
| 515 | if len(pendingDeletes) > 0 { |
| 516 | lineNumbers = append(lineNumbers, LineNumber{ |
| 517 | Before: pendingDeletes[0], |
| 518 | After: newLine, |
| 519 | Modified: true, |
| 520 | }) |
| 521 | pendingDeletes = pendingDeletes[1:] |
| 522 | } else { |
| 523 | lineNumbers = append(lineNumbers, LineNumber{ |
| 524 | Before: 0, |
| 525 | After: newLine, |
| 526 | Modified: true, |
| 527 | }) |
| 528 | } |
| 529 | default: |
| 530 | // Context (unchanged) line -- flush any unpaired deletes and |
| 531 | // emit the line mapping only if it is shifted. |
| 532 | flushDeletes() |
| 533 | oldLine++ |
| 534 | newLine++ |
| 535 | if oldLine != newLine { |
| 536 | lineNumbers = append(lineNumbers, LineNumber{Before: oldLine, After: newLine, Modified: false}) |
| 537 | } |
| 538 | } |
| 539 | } |
| 540 | flushDeletes() |
| 541 | |
| 542 | return lineNumbers |
| 543 | } |
| 544 | |
| 545 | func getTypeForPath(ctx context.Context, cmd CommandRunner, commit, fpath string) PathType { |
| 546 | args := []string{"ls-tree", commit, fpath} |
| 547 | out, err := cmd(ctx, args...) |
| 548 | if err != nil { |
| 549 | slog.LogAttrs(context.Background(), slog.LevelDebug, "git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args))) |
| 550 | return Missing |
| 551 | } |
| 552 | |
| 553 | s := bufio.NewScanner(bytes.NewReader(out)) |
| 554 | for s.Scan() { |
| 555 | parts := strings.SplitN(s.Text(), " ", 3) |
| 556 | if len(parts) != 3 { |
| 557 | continue |
| 558 | } |
| 559 | objmode := parts[0] |
| 560 | objtype := parts[1] |
| 561 | |
| 562 | parts = strings.SplitN(parts[2], "\t", 2) |
| 563 | if len(parts) != 2 { |
| 564 | continue |
| 565 | } |
| 566 | objpath := parts[1] |
| 567 | slog.LogAttrs( |
| 568 | context.Background(), slog.LevelDebug, "ls-tree line", |
| 569 | slog.String("mode", objmode), |
| 570 | slog.String("type", objtype), |
| 571 | slog.String("path", objpath), |
| 572 | ) |
| 573 | |
| 574 | // not our file |
| 575 | if objpath != fpath { |
| 576 | continue |
| 577 | } |
| 578 | if objtype == "tree" { |
| 579 | return Dir |
| 580 | } |
| 581 | // not a blob - could be a tree or a tag |
| 582 | if objtype != "blob" { |
| 583 | continue |
| 584 | } |
| 585 | |
| 586 | if objmode == "120000" { |
| 587 | return Symlink |
| 588 | } |
| 589 | |
| 590 | return File |
| 591 | } |
| 592 | |
| 593 | return Missing |
| 594 | } |
| 595 | |
| 596 | // recursively find the final target of a symlink. |
| 597 | func resolveSymlinkTarget(ctx context.Context, cmd CommandRunner, commit, fpath string, pathType PathType) string { |
| 598 | if pathType != Symlink { |
| 599 | return fpath |
| 600 | } |
| 601 | raw := string(getContentAtCommit(ctx, cmd, commit, fpath)) |
| 602 | spath := path.Clean(path.Join(path.Dir(fpath), raw)) |
| 603 | stype := getTypeForPath(ctx, cmd, commit, spath) |
| 604 | return resolveSymlinkTarget(ctx, cmd, commit, spath, stype) |
| 605 | } |
| 606 | |
| 607 | func getContentAtCommit(ctx context.Context, cmd CommandRunner, commit, fpath string) []byte { |
| 608 | args := []string{"cat-file", "blob", fmt.Sprintf("%s:%s", commit, fpath)} |
| 609 | body, err := cmd(ctx, args...) |
| 610 | if err != nil { |
| 611 | slog.LogAttrs(context.Background(), slog.LevelDebug, "git command returned an error", slog.Any("err", err), slog.String("args", fmt.Sprint(args))) |
| 612 | return nil |
| 613 | } |
| 614 | return body |
| 615 | } |
| 616 | |
| 617 | func CountLines(body []byte) int { |
| 618 | var count int |
| 619 | s := bufio.NewScanner(bytes.NewReader(body)) |
| 620 | for s.Scan() { |
| 621 | count++ |
| 622 | } |
| 623 | return count |
| 624 | } |
| 625 | |
| 626 | func MakeLineRangeFromTo(first, last int, side LineRangeSide) LineNumbers { |
| 627 | n := last - first + 1 |
| 628 | if n <= 0 { |
| 629 | return LineNumbers{} |
| 630 | } |
| 631 | lineNumbers := make(LineNumbers, n) |
| 632 | for i := range n { |
| 633 | l := first + i |
| 634 | switch side { |
| 635 | case LinesBefore: |
| 636 | lineNumbers[i] = LineNumber{Before: l, After: 0, Modified: false} |
| 637 | case LinesAfter: |
| 638 | lineNumbers[i] = LineNumber{Before: 0, After: l, Modified: true} |
| 639 | case LinesBoth: |
| 640 | lineNumbers[i] = LineNumber{Before: l, After: l, Modified: false} |
| 641 | } |
| 642 | } |
| 643 | return lineNumbers |
| 644 | } |
| 645 | |
| 646 | func isDirectoryPath(path string) (bool, error) { |
| 647 | fileInfo, err := os.Stat(path) |
| 648 | if err != nil { |
| 649 | return false, err |
| 650 | } |
| 651 | |
| 652 | return fileInfo.IsDir(), err |
| 653 | } |
| 654 | |