difit 3.1.18 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.ja.md +44 -16
  2. package/README.ko.md +44 -16
  3. package/README.md +44 -16
  4. package/README.zh.md +44 -16
  5. package/dist/cli/github.d.ts +65 -0
  6. package/dist/cli/github.js +296 -0
  7. package/dist/cli/github.test.d.ts +1 -0
  8. package/dist/cli/github.test.js +341 -0
  9. package/dist/cli/index.js +42 -1
  10. package/dist/cli/index.test.js +330 -4
  11. package/dist/cli/utils.d.ts +2 -8
  12. package/dist/cli/utils.js +4 -43
  13. package/dist/cli/utils.test.js +50 -67
  14. package/dist/client/assets/{_basePickBy-DyiQWUmK.js → _basePickBy-ChXFkTMC.js} +1 -1
  15. package/dist/client/assets/{_baseUniq-DivSZEOF.js → _baseUniq-Mj_sFFQW.js} +1 -1
  16. package/dist/client/assets/{arc-c0kacVOL.js → arc-BMA6S9F1.js} +1 -1
  17. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-ubymLNEe.js → architectureDiagram-2XIMDMQ5-0uiM_v5K.js} +1 -1
  18. package/dist/client/assets/{blockDiagram-WCTKOSBZ-F9D8w4_S.js → blockDiagram-WCTKOSBZ-CM7ZLL6F.js} +1 -1
  19. package/dist/client/assets/{c4Diagram-IC4MRINW-JE9Kx4yQ.js → c4Diagram-IC4MRINW-DKtCnVwn.js} +1 -1
  20. package/dist/client/assets/channel-D057yzDp.js +1 -0
  21. package/dist/client/assets/{chunk-4BX2VUAB-CYOCoDMc.js → chunk-4BX2VUAB-Wsl8DxEB.js} +1 -1
  22. package/dist/client/assets/{chunk-55IACEB6-PRBuiJg9.js → chunk-55IACEB6-CHm9X5i7.js} +1 -1
  23. package/dist/client/assets/{chunk-FMBD7UC4-C0eJ7JsI.js → chunk-FMBD7UC4-BSa8SHgd.js} +1 -1
  24. package/dist/client/assets/{chunk-JSJVCQXG-QZotPSqo.js → chunk-JSJVCQXG-Cpk76oJ3.js} +1 -1
  25. package/dist/client/assets/{chunk-KX2RTZJC-B8du3tt8.js → chunk-KX2RTZJC-D8YvfZVu.js} +1 -1
  26. package/dist/client/assets/{chunk-NQ4KR5QH-B10ldi5m.js → chunk-NQ4KR5QH-BogviJOv.js} +1 -1
  27. package/dist/client/assets/{chunk-QZHKN3VN-CpwW9rUQ.js → chunk-QZHKN3VN-DwLJYu26.js} +1 -1
  28. package/dist/client/assets/{chunk-WL4C6EOR-DwKPHpbL.js → chunk-WL4C6EOR-BFDpGxW2.js} +1 -1
  29. package/dist/client/assets/classDiagram-VBA2DB6C---D4iOts.js +1 -0
  30. package/dist/client/assets/classDiagram-v2-RAHNMMFH---D4iOts.js +1 -0
  31. package/dist/client/assets/clone-xSR3otEf.js +1 -0
  32. package/dist/client/assets/{cose-bilkent-S5V4N54A-p76yal75.js → cose-bilkent-S5V4N54A-oEosZ_5y.js} +1 -1
  33. package/dist/client/assets/{dagre-KLK3FWXG-CdDyed3V.js → dagre-KLK3FWXG-gFld4u1H.js} +1 -1
  34. package/dist/client/assets/{diagram-E7M64L7V-BaC8dXuW.js → diagram-E7M64L7V-gJq3kSrf.js} +1 -1
  35. package/dist/client/assets/{diagram-IFDJBPK2-BGf8xwJI.js → diagram-IFDJBPK2-BsUm_q22.js} +1 -1
  36. package/dist/client/assets/{diagram-P4PSJMXO-D3j16gBZ.js → diagram-P4PSJMXO-juB-sfcR.js} +1 -1
  37. package/dist/client/assets/{erDiagram-INFDFZHY-DFpDdocf.js → erDiagram-INFDFZHY-Dn77qXAt.js} +1 -1
  38. package/dist/client/assets/{flowDiagram-PKNHOUZH-Cz4mb4IF.js → flowDiagram-PKNHOUZH-DtmvDYdN.js} +1 -1
  39. package/dist/client/assets/{ganttDiagram-A5KZAMGK-CNzY9ua5.js → ganttDiagram-A5KZAMGK-BlDaKLbQ.js} +1 -1
  40. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-DCSxL8EQ.js → gitGraphDiagram-K3NZZRJ6-DeAAeuMS.js} +1 -1
  41. package/dist/client/assets/{graph-BC2BV1-T.js → graph-NX9gBP47.js} +1 -1
  42. package/dist/client/assets/index-VxkpzDXr.css +1 -0
  43. package/dist/client/assets/index-kJdw4DY-.js +98 -0
  44. package/dist/client/assets/{infoDiagram-LFFYTUFH-BKSspZbH.js → infoDiagram-LFFYTUFH-CAaX023c.js} +1 -1
  45. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-DZ2IRYwc.js → ishikawaDiagram-PHBUUO56-CmiTQStv.js} +1 -1
  46. package/dist/client/assets/{journeyDiagram-4ABVD52K-BrjXAkii.js → journeyDiagram-4ABVD52K-B0SHC7mz.js} +1 -1
  47. package/dist/client/assets/{kanban-definition-K7BYSVSG-B1mfOekw.js → kanban-definition-K7BYSVSG-IfRdhzz7.js} +1 -1
  48. package/dist/client/assets/{layout-CWTG02uT.js → layout-l3OdNQhJ.js} +1 -1
  49. package/dist/client/assets/{linear-CGgOKp1d.js → linear-CQ0hx5Qs.js} +1 -1
  50. package/dist/client/assets/{mermaid.core-DTPtVBG7.js → mermaid.core-DqlPTabt.js} +4 -4
  51. package/dist/client/assets/{mindmap-definition-YRQLILUH-DByVRPFT.js → mindmap-definition-YRQLILUH-DIgSmG_f.js} +1 -1
  52. package/dist/client/assets/{pieDiagram-SKSYHLDU-DEgvAxAy.js → pieDiagram-SKSYHLDU-FzM5qoIB.js} +1 -1
  53. package/dist/client/assets/{prism-csharp-DqTrHqwJ.js → prism-csharp-DCfUUOUs.js} +1 -1
  54. package/dist/client/assets/{prism-elixir-DEJaM00V.js → prism-elixir-riuOL1mm.js} +1 -1
  55. package/dist/client/assets/{prism-hcl-HvJ0aPiH.js → prism-hcl-CizuX1s4.js} +1 -1
  56. package/dist/client/assets/{prism-java-DDUFERTh.js → prism-java-DYCKrDUh.js} +1 -1
  57. package/dist/client/assets/{prism-perl-CNA3SNC9.js → prism-perl-BJwBYR3Y.js} +1 -1
  58. package/dist/client/assets/{prism-php-hBQuhE2A.js → prism-php-BMhFuA7y.js} +1 -1
  59. package/dist/client/assets/{prism-ruby-BKap8imy.js → prism-ruby-Bcu0cDEh.js} +1 -1
  60. package/dist/client/assets/{prism-solidity-DHc7LZHq.js → prism-solidity-DDDs3w-w.js} +1 -1
  61. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DTtikTvc.js → quadrantDiagram-337W2JSQ-BBrApyD7.js} +1 -1
  62. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B34R-xD0.js → requirementDiagram-Z7DCOOCP-CLXiwUaA.js} +1 -1
  63. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-Dts1ZXRC.js → sankeyDiagram-WA2Y5GQK-9Y3Ly5qe.js} +1 -1
  64. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-DzM3WhEY.js → sequenceDiagram-2WXFIKYE-DEpX1BA5.js} +1 -1
  65. package/dist/client/assets/{stateDiagram-RAJIS63D-B2dF8YnK.js → stateDiagram-RAJIS63D-Ck3ullwA.js} +1 -1
  66. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-X6UiDsar.js +1 -0
  67. package/dist/client/assets/{timeline-definition-YZTLITO2-BO4OtcEm.js → timeline-definition-YZTLITO2-CMezf3XV.js} +1 -1
  68. package/dist/client/assets/{treemap-KZPCXAKY-DaXnvVRH.js → treemap-KZPCXAKY-DqrcV0gQ.js} +1 -1
  69. package/dist/client/assets/{vennDiagram-LZ73GAT5-AIMhd8Js.js → vennDiagram-LZ73GAT5-eQg945Fz.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-JWTSCODW-Ch6W1f7P.js → xychartDiagram-JWTSCODW-_hqdXeX1.js} +1 -1
  71. package/dist/client/index.html +2 -2
  72. package/dist/server/generated-file-check.js +113 -58
  73. package/dist/server/generated-file-check.test.js +2 -0
  74. package/dist/server/git-diff-tui.d.ts +1 -1
  75. package/dist/server/git-diff-tui.js +7 -5
  76. package/dist/server/git-diff-tui.test.d.ts +1 -0
  77. package/dist/server/git-diff-tui.test.js +60 -0
  78. package/dist/server/git-diff.d.ts +4 -1
  79. package/dist/server/git-diff.js +73 -9
  80. package/dist/server/git-diff.test.js +46 -0
  81. package/dist/server/server.d.ts +3 -0
  82. package/dist/server/server.js +111 -37
  83. package/dist/server/server.test.js +152 -0
  84. package/dist/tui/App.d.ts +1 -0
  85. package/dist/tui/App.js +2 -2
  86. package/dist/types/diff.d.ts +74 -14
  87. package/dist/utils/commentFormatting.d.ts +4 -2
  88. package/dist/utils/commentFormatting.js +57 -19
  89. package/dist/utils/commentImports.d.ts +9 -0
  90. package/dist/utils/commentImports.js +264 -0
  91. package/dist/utils/commentImports.test.d.ts +1 -0
  92. package/dist/utils/commentImports.test.js +197 -0
  93. package/package.json +1 -1
  94. package/dist/client/assets/channel-Ca4c0q8d.js +0 -1
  95. package/dist/client/assets/classDiagram-VBA2DB6C-CJLw9sK7.js +0 -1
  96. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CJLw9sK7.js +0 -1
  97. package/dist/client/assets/clone-D0mDLEir.js +0 -1
  98. package/dist/client/assets/index-DHt9OwVU.css +0 -1
  99. package/dist/client/assets/index-mE8CA51x.js +0 -95
  100. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-ReD0hBzH.js +0 -1
@@ -1,57 +1,114 @@
1
+ const EXACT_FILENAMES = new Set([
2
+ 'package-lock.json',
3
+ 'pnpm-lock.yaml',
4
+ 'yarn.lock',
5
+ 'Gemfile.lock',
6
+ 'Pipfile.lock',
7
+ 'composer.lock',
8
+ 'Cargo.lock',
9
+ 'poetry.lock',
10
+ 'go.sum',
11
+ 'go.mod',
12
+ 'pubspec.lock',
13
+ 'flake.lock',
14
+ 'Package.resolved',
15
+ 'packages.lock.json',
16
+ '.terraform.lock.hcl',
17
+ 'bun.lockb',
18
+ 'gradle.lockfile',
19
+ 'uv.lock',
20
+ 'pdm.lock',
21
+ ]);
22
+ const FILENAME_SUFFIXES = [
23
+ '.min.js',
24
+ '.min.css',
25
+ '.bundle.js',
26
+ '.bundle.css',
27
+ '-min.js',
28
+ '.map',
29
+ '.pb.go',
30
+ '.pb.rb',
31
+ '.pb.py',
32
+ '.pb.js',
33
+ '.pb.cc',
34
+ '.pb.h',
35
+ '.pb2.py',
36
+ '_pb2.py',
37
+ '.grpc.pb.go',
38
+ '_string.go',
39
+ '.graphql.ts',
40
+ '.graphql.js',
41
+ '.openapi.ts',
42
+ '.openapi.js',
43
+ '.msw.ts',
44
+ '.zod.ts',
45
+ '.api.ts',
46
+ '.g.dart',
47
+ '.freezed.dart',
48
+ '.g.cs',
49
+ '.designer.cs',
50
+ '_ide_helper.php',
51
+ ];
52
+ const ROOT_PATH_PREFIXES = ['vendor/', 'node_modules/', 'dist/', 'build/', 'out/'];
53
+ const PATH_SEGMENTS = ['/generated/', '/gen/', '/__generated__/'];
54
+ function matchesAnySuffix(fileName, suffixes) {
55
+ return suffixes.some((suffix) => fileName.endsWith(suffix));
56
+ }
57
+ function isWordCharacter(char) {
58
+ const code = char.charCodeAt(0);
59
+ return ((code >= 48 && code <= 57) ||
60
+ (code >= 65 && code <= 90) ||
61
+ (code >= 97 && code <= 122) ||
62
+ code === 95);
63
+ }
64
+ function isWordString(text) {
65
+ if (text.length === 0) {
66
+ return false;
67
+ }
68
+ for (const char of text) {
69
+ if (!isWordCharacter(char)) {
70
+ return false;
71
+ }
72
+ }
73
+ return true;
74
+ }
75
+ function hasGeneratedSuffix(fileName, marker) {
76
+ const markerIndex = fileName.lastIndexOf(marker);
77
+ if (markerIndex === -1) {
78
+ return false;
79
+ }
80
+ return isWordString(fileName.slice(markerIndex + marker.length));
81
+ }
1
82
  // Layer 1: Path/Filename Patterns
2
- const PATH_PATTERNS = [
3
- // Lock files (all languages)
4
- /package-lock\.json$/,
5
- /pnpm-lock\.yaml$/,
6
- /yarn\.lock$/,
7
- /Gemfile\.lock$/,
8
- /Pipfile\.lock$/,
9
- /composer\.lock$/,
10
- /Cargo\.lock$/,
11
- /poetry\.lock$/,
12
- /go\.sum$/,
13
- /go\.mod$/,
14
- /pubspec\.lock$/,
15
- /flake\.lock$/,
16
- /Package\.resolved$/,
17
- /packages\.lock\.json$/,
18
- /\.terraform\.lock\.hcl$/,
19
- /bun\.lockb$/,
20
- /gradle\.lockfile$/,
21
- /uv\.lock$/,
22
- /pdm\.lock$/,
23
- // Minified / Bundled
24
- /\.min\.(js|css)$/,
25
- /\.bundle\.(js|css)$/,
26
- /-min\.js$/,
27
- /\.map$/,
28
- // Generated naming conventions
29
- /\.generated\.\w+$/,
30
- /\.gen\.\w+$/,
31
- /\.pb\.(go|rb|py|js|cc|h)$/, // protobuf
32
- /\.pb2\.py$/, // protobuf python
33
- /_pb2\.py$/,
34
- /\.grpc\.pb\.go$/,
35
- /_string\.go$/, // go generate stringer
36
- /\.graphql\.(ts|js)$/, // GraphQL codegen
37
- /\.openapi\.(ts|js)$/,
38
- /mock_.*\.go$/, // mockgen
39
- /mocks\/.*\.go$/,
40
- /\.msw\.ts$/,
41
- /\.zod\.ts$/,
42
- /\.api\.ts$/,
43
- /\.g\.dart$/,
44
- /\.freezed\.dart$/,
45
- /\.g\.cs$/,
46
- /\.designer\.cs$/,
47
- /_ide_helper\.php$/,
48
- // Directories
49
- /^vendor\//,
50
- /^node_modules\//,
51
- /^(dist|build|out)\//,
52
- /\/generated\//,
53
- /\/gen\//,
54
- /\/__generated__\//, // Relay
83
+ const PATH_MATCHERS = [
84
+ {
85
+ description: 'exact-filename',
86
+ matches: (_parsedPath, fileName) => EXACT_FILENAMES.has(fileName),
87
+ },
88
+ {
89
+ description: 'minified-or-generated-suffix',
90
+ matches: (_parsedPath, fileName) => matchesAnySuffix(fileName, FILENAME_SUFFIXES),
91
+ },
92
+ {
93
+ description: 'generated-extension',
94
+ matches: (_parsedPath, fileName) => hasGeneratedSuffix(fileName, '.generated.') || hasGeneratedSuffix(fileName, '.gen.'),
95
+ },
96
+ {
97
+ description: 'mockgen-go',
98
+ matches: (_parsedPath, fileName) => fileName.startsWith('mock_') && fileName.endsWith('.go'),
99
+ },
100
+ {
101
+ description: 'mocks-go-directory',
102
+ matches: (parsedPath, fileName) => parsedPath.includes('mocks/') && fileName.endsWith('.go'),
103
+ },
104
+ {
105
+ description: 'root-generated-directory',
106
+ matches: (parsedPath) => ROOT_PATH_PREFIXES.some((prefix) => parsedPath.startsWith(prefix)),
107
+ },
108
+ {
109
+ description: 'generated-path-segment',
110
+ matches: (parsedPath) => PATH_SEGMENTS.some((segment) => parsedPath.includes(segment)),
111
+ },
55
112
  ];
56
113
  // Layer 2: Universal Header Patterns
57
114
  const UNIVERSAL_PATTERNS = [
@@ -172,14 +229,12 @@ export function isGeneratedFile(parsedPath, getHeaderLines) {
172
229
  // We check if the filename matches any known generated patterns
173
230
  // or if the path is in a known generated directory
174
231
  const fileName = parsedPath.split('/').pop() || '';
175
- for (const pattern of PATH_PATTERNS) {
176
- // Some patterns match the whole path (directories), others just the filename
177
- const textToCheck = pattern.source.includes('/') ? parsedPath : fileName;
178
- if (pattern.test(textToCheck)) {
232
+ for (const matcher of PATH_MATCHERS) {
233
+ if (matcher.matches(parsedPath, fileName)) {
179
234
  return {
180
235
  isGenerated: true,
181
236
  reason: 'path',
182
- matchedPattern: pattern.source,
237
+ matchedPattern: matcher.description,
183
238
  };
184
239
  }
185
240
  }
@@ -20,6 +20,8 @@ describe('isGeneratedFile', () => {
20
20
  expect(isGeneratedFile('api.gen.go').isGenerated).toBe(true);
21
21
  expect(isGeneratedFile('service.pb.go').isGenerated).toBe(true);
22
22
  expect(isGeneratedFile('schema.graphql.ts').isGenerated).toBe(true);
23
+ expect(isGeneratedFile('mock_service.go').isGenerated).toBe(true);
24
+ expect(isGeneratedFile('src/mocks/service.go').isGenerated).toBe(true);
23
25
  });
24
26
  it('detects generated directories', () => {
25
27
  expect(isGeneratedFile('node_modules/package/index.js').isGenerated).toBe(true);
@@ -1,2 +1,2 @@
1
1
  import type { FileDiff } from '../types/diff.js';
2
- export declare function loadGitDiff(targetCommitish: string, baseCommitish: string, repoPath?: string): Promise<FileDiff[]>;
2
+ export declare function loadGitDiff(targetCommitish: string, baseCommitish: string, repoPath?: string, contextLines?: number): Promise<FileDiff[]>;
@@ -1,6 +1,6 @@
1
1
  import simpleGit from 'simple-git';
2
2
  import { validateDiffArguments, createCommitRangeString } from '../cli/utils.js';
3
- export async function loadGitDiff(targetCommitish, baseCommitish, repoPath) {
3
+ export async function loadGitDiff(targetCommitish, baseCommitish, repoPath, contextLines) {
4
4
  // Validate arguments
5
5
  const validation = validateDiffArguments(targetCommitish, baseCommitish);
6
6
  if (!validation.valid) {
@@ -44,34 +44,36 @@ export async function loadGitDiff(targetCommitish, baseCommitish, repoPath) {
44
44
  const path = pathParts.join('\t');
45
45
  return { status, path };
46
46
  });
47
+ const contextArgs = contextLines !== undefined ? [`-U${contextLines}`] : [];
47
48
  // Get diff for each file individually
48
49
  const fileDiffs = await Promise.all(fileChanges.map(async ({ status, path }) => {
49
50
  let fileDiff = '';
50
51
  // Handle individual file diffs (base is always a regular commit)
51
52
  if (targetCommitish === 'working') {
52
53
  // Show unstaged changes (working vs staged)
53
- fileDiff = await git.diff(['--', path]);
54
+ fileDiff = await git.diff([...contextArgs, '--', path]);
54
55
  }
55
56
  else if (targetCommitish === 'staged') {
56
57
  // Show staged changes against base commit
57
- fileDiff = await git.diff(['--cached', baseCommitish, '--', path]);
58
+ fileDiff = await git.diff(['--cached', baseCommitish, ...contextArgs, '--', path]);
58
59
  }
59
60
  else if (targetCommitish === '.') {
60
61
  // Show all uncommitted changes against base commit
61
- fileDiff = await git.diff([baseCommitish, '--', path]);
62
+ fileDiff = await git.diff([baseCommitish, ...contextArgs, '--', path]);
62
63
  }
63
64
  else {
64
65
  try {
65
66
  // Both are regular commits: standard commit-to-commit comparison
66
67
  fileDiff = await git.diff([
67
68
  createCommitRangeString(baseCommitish, targetCommitish),
69
+ ...contextArgs,
68
70
  '--',
69
71
  path,
70
72
  ]);
71
73
  }
72
74
  catch {
73
75
  // For new files or if parent doesn't exist
74
- fileDiff = await git.diff([targetCommitish, '--', path]);
76
+ fileDiff = await git.diff([targetCommitish, ...contextArgs, '--', path]);
75
77
  }
76
78
  }
77
79
  const lines = fileDiff.split('\n');
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { loadGitDiff } from './git-diff-tui.js';
3
+ const mockDiff = vi.hoisted(() => vi.fn());
4
+ const mockSimpleGit = vi.hoisted(() => vi.fn(() => ({ diff: mockDiff })));
5
+ vi.mock('simple-git', () => ({
6
+ default: mockSimpleGit,
7
+ }));
8
+ describe('loadGitDiff', () => {
9
+ beforeEach(() => {
10
+ mockDiff.mockReset();
11
+ mockSimpleGit.mockClear();
12
+ });
13
+ it.each([
14
+ {
15
+ name: 'working tree diffs',
16
+ targetCommitish: 'working',
17
+ baseCommitish: 'staged',
18
+ expectedListArgs: ['--name-status'],
19
+ expectedFileArgs: ['-U5', '--', 'src/file.ts'],
20
+ },
21
+ {
22
+ name: 'staged diffs',
23
+ targetCommitish: 'staged',
24
+ baseCommitish: 'HEAD',
25
+ expectedListArgs: ['--cached', 'HEAD', '--name-status'],
26
+ expectedFileArgs: ['--cached', 'HEAD', '-U5', '--', 'src/file.ts'],
27
+ },
28
+ {
29
+ name: 'working tree against a base commit',
30
+ targetCommitish: '.',
31
+ baseCommitish: 'HEAD',
32
+ expectedListArgs: ['HEAD', '--name-status'],
33
+ expectedFileArgs: ['HEAD', '-U5', '--', 'src/file.ts'],
34
+ },
35
+ {
36
+ name: 'commit comparisons',
37
+ targetCommitish: 'HEAD',
38
+ baseCommitish: 'HEAD^',
39
+ expectedListArgs: ['HEAD^...HEAD', '--name-status'],
40
+ expectedFileArgs: ['HEAD^...HEAD', '-U5', '--', 'src/file.ts'],
41
+ },
42
+ ])('passes context lines for $name', async ({ targetCommitish, baseCommitish, expectedListArgs, expectedFileArgs }) => {
43
+ mockDiff
44
+ .mockResolvedValueOnce('M\tsrc/file.ts')
45
+ .mockResolvedValueOnce('@@ -1 +1 @@\n-old line\n+new line\n');
46
+ const result = await loadGitDiff(targetCommitish, baseCommitish, '/repo', 5);
47
+ expect(mockSimpleGit).toHaveBeenCalledWith('/repo');
48
+ expect(mockDiff).toHaveBeenNthCalledWith(1, expectedListArgs);
49
+ expect(mockDiff).toHaveBeenNthCalledWith(2, expectedFileArgs);
50
+ expect(result).toEqual([
51
+ {
52
+ path: 'src/file.ts',
53
+ status: 'M',
54
+ diff: '@@ -1 +1 @@\n-old line\n+new line\n',
55
+ additions: 1,
56
+ deletions: 1,
57
+ },
58
+ ]);
59
+ });
60
+ });
@@ -6,7 +6,8 @@ export declare class GitDiffParser {
6
6
  private static readonly RESOLVED_COMMIT_CACHE_TTL_MS;
7
7
  private static readonly GENERATED_HEADER_SCAN_BYTES;
8
8
  constructor(repoPath?: string);
9
- parseDiff(targetCommitish: string, baseCommitish: string, ignoreWhitespace?: boolean): Promise<DiffResponse>;
9
+ private normalizeRepositoryRelativePath;
10
+ parseDiff(targetCommitish: string, baseCommitish: string, ignoreWhitespace?: boolean, contextLines?: number): Promise<DiffResponse>;
10
11
  private parseUnifiedDiff;
11
12
  private decodeGitPath;
12
13
  private extractPathFromLine;
@@ -26,6 +27,7 @@ export declare class GitDiffParser {
26
27
  resolveCommitish(commitish: string): Promise<string>;
27
28
  clearResolvedCommitCache(): void;
28
29
  getDefaultBranch(): Promise<string | null>;
30
+ getOriginDefaultBranch(): Promise<string | null>;
29
31
  getRevisionOptions(currentBase?: string, currentTarget?: string): Promise<{
30
32
  branches: Array<{
31
33
  name: string;
@@ -36,6 +38,7 @@ export declare class GitDiffParser {
36
38
  shortHash: string;
37
39
  message: string;
38
40
  }>;
41
+ originDefaultBranch?: string;
39
42
  resolvedBase?: string;
40
43
  resolvedTarget?: string;
41
44
  }>;
@@ -1,4 +1,5 @@
1
1
  import { simpleGit } from 'simple-git';
2
+ import { isAbsolute, resolve, sep } from 'path';
2
3
  import { validateDiffArguments, shortHash, createCommitRangeString } from '../cli/utils.js';
3
4
  import { isGeneratedFile } from './generated-file-check.js';
4
5
  export class GitDiffParser {
@@ -11,7 +12,23 @@ export class GitDiffParser {
11
12
  this.repoPath = repoPath;
12
13
  this.git = simpleGit(repoPath);
13
14
  }
14
- async parseDiff(targetCommitish, baseCommitish, ignoreWhitespace = false) {
15
+ normalizeRepositoryRelativePath(filepath) {
16
+ if (filepath.length === 0) {
17
+ throw new Error('Invalid file path');
18
+ }
19
+ const normalizedFilepath = filepath.replace(/\\/g, '/');
20
+ const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
21
+ if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
22
+ throw new Error('File path outside repository');
23
+ }
24
+ const repositoryPath = resolve(this.repoPath);
25
+ const resolvedPath = resolve(repositoryPath, normalizedFilepath);
26
+ if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
27
+ throw new Error('File path outside repository');
28
+ }
29
+ return normalizedFilepath;
30
+ }
31
+ async parseDiff(targetCommitish, baseCommitish, ignoreWhitespace = false, contextLines) {
15
32
  try {
16
33
  // Validate arguments
17
34
  const validation = validateDiffArguments(targetCommitish, baseCommitish);
@@ -48,6 +65,9 @@ export class GitDiffParser {
48
65
  if (ignoreWhitespace) {
49
66
  diffArgs.push('-w');
50
67
  }
68
+ if (contextLines !== undefined) {
69
+ diffArgs.push(`-U${contextLines}`);
70
+ }
51
71
  // Ignore external diff-tools to unify output.
52
72
  // https://github.com/yoshiko-pg/difit/issues/19
53
73
  diffArgs.push('--no-ext-diff', '--color=never');
@@ -339,12 +359,18 @@ export class GitDiffParser {
339
359
  // For working directory, read directly from filesystem
340
360
  if (ref === 'working' || ref === '.') {
341
361
  const fs = await import('fs');
342
- const path = await import('path');
343
- const absolutePath = path.isAbsolute(filepath)
344
- ? filepath
345
- : path.resolve(this.repoPath, filepath);
362
+ if (filepath.length === 0) {
363
+ throw new Error('Invalid file path');
364
+ }
365
+ const repositoryPath = fs.realpathSync(resolve(this.repoPath));
366
+ const repositoryRoot = `${repositoryPath}${sep}`;
367
+ const absolutePath = fs.realpathSync(resolve(repositoryRoot, filepath.replace(/\\/g, '/')));
368
+ if (!absolutePath.startsWith(repositoryRoot)) {
369
+ throw new Error('File path outside repository');
370
+ }
346
371
  return fs.readFileSync(absolutePath);
347
372
  }
373
+ const normalizedFilepath = this.normalizeRepositoryRelativePath(filepath);
348
374
  // For git refs, we need to use child_process to execute git cat-file
349
375
  // to properly handle binary data
350
376
  const { execFileSync } = await import('child_process');
@@ -352,14 +378,14 @@ export class GitDiffParser {
352
378
  if (ref === 'staged') {
353
379
  // For staged files, use git show :filepath
354
380
  // Using execFileSync to prevent command injection
355
- const buffer = execFileSync('git', ['show', `:${filepath}`], {
381
+ const buffer = execFileSync('git', ['show', `:${normalizedFilepath}`], {
356
382
  maxBuffer: 10 * 1024 * 1024, // 10MB limit
357
383
  });
358
384
  return buffer;
359
385
  }
360
386
  // First, get the blob hash for the file at the given ref
361
387
  // Using execFileSync to prevent command injection
362
- const blobHash = execFileSync('git', ['rev-parse', `${ref}:${filepath}`], {
388
+ const blobHash = execFileSync('git', ['rev-parse', `${ref}:${normalizedFilepath}`], {
363
389
  encoding: 'utf8',
364
390
  maxBuffer: 10 * 1024 * 1024,
365
391
  }).trim();
@@ -376,6 +402,10 @@ export class GitDiffParser {
376
402
  (error.message.includes('ENOBUFS') || error.message.includes('maxBuffer'))) {
377
403
  throw new Error(`Image file ${filepath} is too large to display (over 10MB limit)`);
378
404
  }
405
+ if (error instanceof Error &&
406
+ (error.message === 'Invalid file path' || error.message === 'File path outside repository')) {
407
+ throw error;
408
+ }
379
409
  throw new Error(`Failed to get blob content for ${filepath} at ${ref}: ${error instanceof Error ? error.message : 'Unknown error'}`);
380
410
  }
381
411
  }
@@ -451,11 +481,39 @@ export class GitDiffParser {
451
481
  }
452
482
  return null;
453
483
  }
484
+ async getOriginDefaultBranch() {
485
+ try {
486
+ const result = await this.git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
487
+ const match = result.trim().match(/refs\/remotes\/origin\/(.+)/);
488
+ if (match) {
489
+ return `origin/${match[1]}`;
490
+ }
491
+ }
492
+ catch {
493
+ const commonDefaults = ['main', 'master'];
494
+ for (const defaultName of commonDefaults) {
495
+ try {
496
+ await this.git.raw([
497
+ 'show-ref',
498
+ '--verify',
499
+ '--quiet',
500
+ `refs/remotes/origin/${defaultName}`,
501
+ ]);
502
+ return `origin/${defaultName}`;
503
+ }
504
+ catch {
505
+ // Ignore missing refs and continue checking common defaults.
506
+ }
507
+ }
508
+ }
509
+ return null;
510
+ }
454
511
  async getRevisionOptions(currentBase, currentTarget) {
455
- const [branchResult, logResult, defaultBranch] = await Promise.all([
512
+ const [branchResult, logResult, defaultBranch, originDefaultBranch] = await Promise.all([
456
513
  this.git.branchLocal(),
457
514
  this.git.log({ maxCount: 20 }),
458
515
  this.getDefaultBranch(),
516
+ this.getOriginDefaultBranch(),
459
517
  ]);
460
518
  const branches = Object.entries(branchResult.branches).map(([name, data]) => ({
461
519
  name,
@@ -499,6 +557,12 @@ export class GitDiffParser {
499
557
  // If resolution fails, leave undefined
500
558
  }
501
559
  }
502
- return { branches, commits, resolvedBase, resolvedTarget };
560
+ return {
561
+ branches,
562
+ commits,
563
+ originDefaultBranch: originDefaultBranch ?? undefined,
564
+ resolvedBase,
565
+ resolvedTarget,
566
+ };
503
567
  }
504
568
  }
@@ -23,12 +23,14 @@ vi.mock('fs', async (importOriginal) => {
23
23
  return {
24
24
  ...actual,
25
25
  readFileSync: vi.fn(),
26
+ realpathSync: vi.fn((path) => path),
26
27
  };
27
28
  });
28
29
  describe('GitDiffParser', () => {
29
30
  let parser;
30
31
  let mockExecFileSync;
31
32
  let mockReadFileSync;
33
+ let mockRealpathSync;
32
34
  beforeEach(async () => {
33
35
  parser = new GitDiffParser('/test/repo');
34
36
  vi.clearAllMocks();
@@ -37,6 +39,8 @@ describe('GitDiffParser', () => {
37
39
  const fs = await import('fs');
38
40
  mockExecFileSync = childProcess.execFileSync;
39
41
  mockReadFileSync = fs.readFileSync;
42
+ mockRealpathSync = fs.realpathSync;
43
+ mockRealpathSync.mockImplementation((path) => path);
40
44
  });
41
45
  afterEach(() => {
42
46
  vi.restoreAllMocks();
@@ -102,6 +106,28 @@ describe('GitDiffParser', () => {
102
106
  });
103
107
  await expect(parser.getBlobContent('missing.txt', 'HEAD')).rejects.toThrow('Failed to get blob content for missing.txt at HEAD: fatal: Path does not exist');
104
108
  });
109
+ it('rejects working tree paths outside the repository', async () => {
110
+ await expect(parser.getBlobContent('../outside.txt', 'working')).rejects.toThrow('File path outside repository');
111
+ expect(mockReadFileSync).not.toHaveBeenCalled();
112
+ });
113
+ it('rejects absolute working tree paths', async () => {
114
+ await expect(parser.getBlobContent('/etc/passwd', 'working')).rejects.toThrow('File path outside repository');
115
+ expect(mockReadFileSync).not.toHaveBeenCalled();
116
+ });
117
+ it('rejects working tree symlinks that resolve outside the repository', async () => {
118
+ mockRealpathSync.mockImplementation((path) => {
119
+ if (path === '/test/repo/link.txt') {
120
+ return '/etc/passwd';
121
+ }
122
+ return path;
123
+ });
124
+ await expect(parser.getBlobContent('link.txt', 'working')).rejects.toThrow('File path outside repository');
125
+ expect(mockReadFileSync).not.toHaveBeenCalled();
126
+ });
127
+ it('rejects git ref paths outside the repository before invoking git', async () => {
128
+ await expect(parser.getBlobContent('../outside.txt', 'HEAD')).rejects.toThrow('File path outside repository');
129
+ expect(mockExecFileSync).not.toHaveBeenCalled();
130
+ });
105
131
  });
106
132
  describe('parseFileBlock with binary files', () => {
107
133
  it('parses added binary file correctly', () => {
@@ -1045,6 +1071,26 @@ index abc123..def456 100644
1045
1071
  });
1046
1072
  });
1047
1073
  describe('parseDiff', () => {
1074
+ it('passes context lines through to git diff', async () => {
1075
+ const gitDiff = parser.git.diff;
1076
+ const gitRevparse = parser.git.revparse;
1077
+ gitRevparse
1078
+ .mockResolvedValueOnce('1234567890abcdef1234567890abcdef12345678')
1079
+ .mockResolvedValueOnce('abcdef1234567890abcdef1234567890abcdef12');
1080
+ gitDiff.mockResolvedValue('');
1081
+ const response = await parser.parseDiff('HEAD', 'HEAD~1', false, 5);
1082
+ expect(gitDiff).toHaveBeenCalledWith([
1083
+ 'abcdef1...1234567',
1084
+ '-U5',
1085
+ '--no-ext-diff',
1086
+ '--color=never',
1087
+ ]);
1088
+ expect(response).toEqual({
1089
+ commit: 'abcdef1...1234567',
1090
+ files: [],
1091
+ isEmpty: true,
1092
+ });
1093
+ });
1048
1094
  it('accepts branch refs with revision suffixes', async () => {
1049
1095
  const gitDiff = parser.git.diff;
1050
1096
  const gitRevparse = parser.git.revparse;
@@ -1,5 +1,6 @@
1
1
  import { type Server } from 'http';
2
2
  import { type DiffMode } from '../types/watch.js';
3
+ import { type CommentImport } from '@/types/diff.js';
3
4
  interface ServerOptions {
4
5
  targetCommitish?: string;
5
6
  baseCommitish?: string;
@@ -10,9 +11,11 @@ interface ServerOptions {
10
11
  mode?: string;
11
12
  ignoreWhitespace?: boolean;
12
13
  clearComments?: boolean;
14
+ commentImports?: CommentImport[];
13
15
  keepAlive?: boolean;
14
16
  diffMode?: DiffMode;
15
17
  repoPath?: string;
18
+ contextLines?: number;
16
19
  }
17
20
  export declare function startServer(options: ServerOptions): Promise<{
18
21
  port: number;