difit 3.1.17 → 4.0.0

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 (94) hide show
  1. package/README.ja.md +30 -3
  2. package/README.ko.md +30 -3
  3. package/README.md +30 -3
  4. package/README.zh.md +30 -3
  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 +26 -1
  10. package/dist/cli/index.test.js +206 -4
  11. package/dist/cli/utils.d.ts +2 -8
  12. package/dist/cli/utils.js +43 -56
  13. package/dist/cli/utils.test.js +61 -67
  14. package/dist/client/assets/{_basePickBy-r8KiD0PT.js → _basePickBy-B9N-f0iT.js} +1 -1
  15. package/dist/client/assets/{_baseUniq-WYpg9s_f.js → _baseUniq-tbL7nVvN.js} +1 -1
  16. package/dist/client/assets/{arc-BZWd656X.js → arc-BOY-7mep.js} +1 -1
  17. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-BiaoV1Oc.js → architectureDiagram-2XIMDMQ5-59AvHaSB.js} +1 -1
  18. package/dist/client/assets/{blockDiagram-WCTKOSBZ-T1RU4TI6.js → blockDiagram-WCTKOSBZ-DXIlumQk.js} +1 -1
  19. package/dist/client/assets/{c4Diagram-IC4MRINW-C1aQSMsj.js → c4Diagram-IC4MRINW-BbfZ0uRn.js} +1 -1
  20. package/dist/client/assets/channel-cZXsTJxA.js +1 -0
  21. package/dist/client/assets/{chunk-4BX2VUAB-DFcwtPlK.js → chunk-4BX2VUAB-l7rcB2IW.js} +1 -1
  22. package/dist/client/assets/{chunk-55IACEB6-Bl3vvNDx.js → chunk-55IACEB6-CrZL3qv9.js} +1 -1
  23. package/dist/client/assets/{chunk-FMBD7UC4-B_2obFwM.js → chunk-FMBD7UC4-CrKv7ndg.js} +1 -1
  24. package/dist/client/assets/{chunk-JSJVCQXG-BrSq4jyX.js → chunk-JSJVCQXG-DyBDhAEM.js} +1 -1
  25. package/dist/client/assets/{chunk-KX2RTZJC-18m3UONJ.js → chunk-KX2RTZJC-By5mkZmU.js} +1 -1
  26. package/dist/client/assets/{chunk-NQ4KR5QH-hFDbMzZU.js → chunk-NQ4KR5QH-C30p9xRx.js} +1 -1
  27. package/dist/client/assets/{chunk-QZHKN3VN-CyCFXX2j.js → chunk-QZHKN3VN-DVlhR2wU.js} +1 -1
  28. package/dist/client/assets/{chunk-WL4C6EOR-BDdHa7t1.js → chunk-WL4C6EOR-Cn7a6CO3.js} +1 -1
  29. package/dist/client/assets/classDiagram-VBA2DB6C-B_coIPEy.js +1 -0
  30. package/dist/client/assets/classDiagram-v2-RAHNMMFH-B_coIPEy.js +1 -0
  31. package/dist/client/assets/clone-BjaT2HOk.js +1 -0
  32. package/dist/client/assets/{cose-bilkent-S5V4N54A-D7t718Sq.js → cose-bilkent-S5V4N54A-LyauIk_9.js} +1 -1
  33. package/dist/client/assets/{dagre-KLK3FWXG-DJXcjsV8.js → dagre-KLK3FWXG-DRWb2KE3.js} +1 -1
  34. package/dist/client/assets/{diagram-E7M64L7V-DL8ck_Al.js → diagram-E7M64L7V-ChT6mNWK.js} +1 -1
  35. package/dist/client/assets/{diagram-IFDJBPK2-NTxUWyD3.js → diagram-IFDJBPK2-CqbTduoP.js} +1 -1
  36. package/dist/client/assets/{diagram-P4PSJMXO-CGkcnGxk.js → diagram-P4PSJMXO-Bzv5Z3ri.js} +1 -1
  37. package/dist/client/assets/{erDiagram-INFDFZHY-BqpbHQrZ.js → erDiagram-INFDFZHY-CvXfUZ4L.js} +1 -1
  38. package/dist/client/assets/{flowDiagram-PKNHOUZH-B-DK3_9I.js → flowDiagram-PKNHOUZH-CxmpNUKq.js} +1 -1
  39. package/dist/client/assets/{ganttDiagram-A5KZAMGK-BK1C57ll.js → ganttDiagram-A5KZAMGK-9LpZCsg6.js} +1 -1
  40. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-Duxlcz8R.js → gitGraphDiagram-K3NZZRJ6-C6yZOrQJ.js} +1 -1
  41. package/dist/client/assets/{graph-C7r58m4O.js → graph-bUZ7uHLW.js} +1 -1
  42. package/dist/client/assets/index-BLNN1bfE.js +98 -0
  43. package/dist/client/assets/index-VxkpzDXr.css +1 -0
  44. package/dist/client/assets/{infoDiagram-LFFYTUFH-Bqt-4V9X.js → infoDiagram-LFFYTUFH-Djdy3W21.js} +1 -1
  45. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-B1ZVSkls.js → ishikawaDiagram-PHBUUO56-oOdwCpeS.js} +1 -1
  46. package/dist/client/assets/{journeyDiagram-4ABVD52K-LSEcxqrO.js → journeyDiagram-4ABVD52K-DTb_nGAw.js} +1 -1
  47. package/dist/client/assets/{kanban-definition-K7BYSVSG-CldPadPs.js → kanban-definition-K7BYSVSG-CMtP7pHA.js} +1 -1
  48. package/dist/client/assets/{layout-NpxIVVkp.js → layout-CXr5MatK.js} +1 -1
  49. package/dist/client/assets/{linear-JpKpxaS-.js → linear-pOMS9pjV.js} +1 -1
  50. package/dist/client/assets/{mermaid.core-gANNEmg0.js → mermaid.core-DV5JJ1Ie.js} +4 -4
  51. package/dist/client/assets/{mindmap-definition-YRQLILUH-ewFI1yc5.js → mindmap-definition-YRQLILUH-DN-sbonc.js} +1 -1
  52. package/dist/client/assets/{pieDiagram-SKSYHLDU-CWlAr2t8.js → pieDiagram-SKSYHLDU-tAHCkgh1.js} +1 -1
  53. package/dist/client/assets/{prism-csharp-CxRfePTX.js → prism-csharp-5CQ0RcEE.js} +1 -1
  54. package/dist/client/assets/{prism-elixir-B0H1PC_E.js → prism-elixir-BSOTyVg2.js} +1 -1
  55. package/dist/client/assets/{prism-hcl-Csmcce-t.js → prism-hcl-BYvi1mtM.js} +1 -1
  56. package/dist/client/assets/{prism-java-BRzwomgj.js → prism-java-DMU2FM4X.js} +1 -1
  57. package/dist/client/assets/{prism-perl-DQMRA6u_.js → prism-perl-CpfvaEQk.js} +1 -1
  58. package/dist/client/assets/{prism-php-C6fR1C7-.js → prism-php-SC920LoD.js} +1 -1
  59. package/dist/client/assets/{prism-ruby-CWeh27h1.js → prism-ruby-DZph-YiO.js} +1 -1
  60. package/dist/client/assets/{prism-solidity-3wCU4ra_.js → prism-solidity-qTLbmiAT.js} +1 -1
  61. package/dist/client/assets/{quadrantDiagram-337W2JSQ-D76E3PCD.js → quadrantDiagram-337W2JSQ-B0wODmgR.js} +1 -1
  62. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-C49LvKzR.js → requirementDiagram-Z7DCOOCP-A3aeHC06.js} +1 -1
  63. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-DOvEhLMf.js → sankeyDiagram-WA2Y5GQK-BWa6kZhG.js} +1 -1
  64. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-BR6dsfEq.js → sequenceDiagram-2WXFIKYE-Cx_COX9G.js} +1 -1
  65. package/dist/client/assets/{stateDiagram-RAJIS63D-CHII26YE.js → stateDiagram-RAJIS63D-BXGnN6rZ.js} +1 -1
  66. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-CMw3xNha.js +1 -0
  67. package/dist/client/assets/{timeline-definition-YZTLITO2-DhUTiAsW.js → timeline-definition-YZTLITO2-DbqaUm9k.js} +1 -1
  68. package/dist/client/assets/{treemap-KZPCXAKY-C0Rh3R0y.js → treemap-KZPCXAKY-CfEujPCR.js} +1 -1
  69. package/dist/client/assets/{vennDiagram-LZ73GAT5-CWt3wBDG.js → vennDiagram-LZ73GAT5-CqJE8CAD.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-JWTSCODW-DhwJwxGz.js → xychartDiagram-JWTSCODW-CfdDvzHC.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.d.ts +1 -0
  75. package/dist/server/git-diff.js +33 -6
  76. package/dist/server/git-diff.test.js +45 -0
  77. package/dist/server/server.d.ts +2 -0
  78. package/dist/server/server.js +107 -34
  79. package/dist/server/server.test.js +120 -0
  80. package/dist/types/diff.d.ts +73 -14
  81. package/dist/utils/commentFormatting.d.ts +4 -2
  82. package/dist/utils/commentFormatting.js +57 -19
  83. package/dist/utils/commentImports.d.ts +9 -0
  84. package/dist/utils/commentImports.js +264 -0
  85. package/dist/utils/commentImports.test.d.ts +1 -0
  86. package/dist/utils/commentImports.test.js +197 -0
  87. package/package.json +3 -3
  88. package/dist/client/assets/channel-C081SflL.js +0 -1
  89. package/dist/client/assets/classDiagram-VBA2DB6C-CD8hB8X7.js +0 -1
  90. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CD8hB8X7.js +0 -1
  91. package/dist/client/assets/clone-DL1yO1kL.js +0 -1
  92. package/dist/client/assets/index-DcsVWNsS.css +0 -1
  93. package/dist/client/assets/index-Igyd6olF.js +0 -92
  94. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-DtCFGPiV.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);
@@ -6,6 +6,7 @@ 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
+ private normalizeRepositoryRelativePath;
9
10
  parseDiff(targetCommitish: string, baseCommitish: string, ignoreWhitespace?: boolean): Promise<DiffResponse>;
10
11
  private parseUnifiedDiff;
11
12
  private decodeGitPath;
@@ -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,6 +12,22 @@ export class GitDiffParser {
11
12
  this.repoPath = repoPath;
12
13
  this.git = simpleGit(repoPath);
13
14
  }
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
+ }
14
31
  async parseDiff(targetCommitish, baseCommitish, ignoreWhitespace = false) {
15
32
  try {
16
33
  // Validate arguments
@@ -339,12 +356,18 @@ export class GitDiffParser {
339
356
  // For working directory, read directly from filesystem
340
357
  if (ref === 'working' || ref === '.') {
341
358
  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);
359
+ if (filepath.length === 0) {
360
+ throw new Error('Invalid file path');
361
+ }
362
+ const repositoryPath = fs.realpathSync(resolve(this.repoPath));
363
+ const repositoryRoot = `${repositoryPath}${sep}`;
364
+ const absolutePath = fs.realpathSync(resolve(repositoryRoot, filepath.replace(/\\/g, '/')));
365
+ if (!absolutePath.startsWith(repositoryRoot)) {
366
+ throw new Error('File path outside repository');
367
+ }
346
368
  return fs.readFileSync(absolutePath);
347
369
  }
370
+ const normalizedFilepath = this.normalizeRepositoryRelativePath(filepath);
348
371
  // For git refs, we need to use child_process to execute git cat-file
349
372
  // to properly handle binary data
350
373
  const { execFileSync } = await import('child_process');
@@ -352,14 +375,14 @@ export class GitDiffParser {
352
375
  if (ref === 'staged') {
353
376
  // For staged files, use git show :filepath
354
377
  // Using execFileSync to prevent command injection
355
- const buffer = execFileSync('git', ['show', `:${filepath}`], {
378
+ const buffer = execFileSync('git', ['show', `:${normalizedFilepath}`], {
356
379
  maxBuffer: 10 * 1024 * 1024, // 10MB limit
357
380
  });
358
381
  return buffer;
359
382
  }
360
383
  // First, get the blob hash for the file at the given ref
361
384
  // Using execFileSync to prevent command injection
362
- const blobHash = execFileSync('git', ['rev-parse', `${ref}:${filepath}`], {
385
+ const blobHash = execFileSync('git', ['rev-parse', `${ref}:${normalizedFilepath}`], {
363
386
  encoding: 'utf8',
364
387
  maxBuffer: 10 * 1024 * 1024,
365
388
  }).trim();
@@ -376,6 +399,10 @@ export class GitDiffParser {
376
399
  (error.message.includes('ENOBUFS') || error.message.includes('maxBuffer'))) {
377
400
  throw new Error(`Image file ${filepath} is too large to display (over 10MB limit)`);
378
401
  }
402
+ if (error instanceof Error &&
403
+ (error.message === 'Invalid file path' || error.message === 'File path outside repository')) {
404
+ throw error;
405
+ }
379
406
  throw new Error(`Failed to get blob content for ${filepath} at ${ref}: ${error instanceof Error ? error.message : 'Unknown error'}`);
380
407
  }
381
408
  }
@@ -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', () => {
@@ -1044,4 +1070,23 @@ index abc123..def456 100644
1044
1070
  expect(result.isGenerated).toBe(true);
1045
1071
  });
1046
1072
  });
1073
+ describe('parseDiff', () => {
1074
+ it('accepts branch refs with revision suffixes', 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('codex/comment-thread', 'codex/comment-thread^');
1082
+ expect(gitRevparse).toHaveBeenNthCalledWith(1, ['codex/comment-thread']);
1083
+ expect(gitRevparse).toHaveBeenNthCalledWith(2, ['codex/comment-thread^']);
1084
+ expect(gitDiff).toHaveBeenCalledWith(['abcdef1...1234567', '--no-ext-diff', '--color=never']);
1085
+ expect(response).toEqual({
1086
+ commit: 'abcdef1...1234567',
1087
+ files: [],
1088
+ isEmpty: true,
1089
+ });
1090
+ });
1091
+ });
1047
1092
  });
@@ -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,6 +11,7 @@ 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;
@@ -7,6 +7,7 @@ import open from 'open';
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = dirname(__filename);
9
9
  import { formatCommentsOutput } from '../utils/commentFormatting.js';
10
+ import { serializeCommentImports } from '../utils/commentImports.js';
10
11
  import { normalizeDiffViewMode } from '../utils/diffMode.js';
11
12
  import { resolveEditorOption } from '../utils/editorOptions.js';
12
13
  import { getFileExtension } from '../utils/fileUtils.js';
@@ -17,6 +18,12 @@ export async function startServer(options) {
17
18
  const app = express();
18
19
  const repositoryPath = resolve(options.repoPath ?? process.cwd());
19
20
  const repositoryId = createHash('sha256').update(repositoryPath).digest('hex');
21
+ const initialCommentImports = options.commentImports || [];
22
+ const initialBaseCommitish = options.baseCommitish ?? '';
23
+ const initialTargetCommitish = options.targetCommitish ?? '';
24
+ const commentImportId = initialCommentImports.length > 0
25
+ ? createHash('sha256').update(serializeCommentImports(initialCommentImports)).digest('hex')
26
+ : undefined;
20
27
  const parser = new GitDiffParser(repositoryPath);
21
28
  const fileWatcher = new FileWatcherService();
22
29
  const generatedStatusCache = new Map();
@@ -55,10 +62,28 @@ export async function startServer(options) {
55
62
  // Track current revisions for cache invalidation
56
63
  let currentBaseCommitish = options.baseCommitish ?? '';
57
64
  let currentTargetCommitish = options.targetCommitish ?? '';
65
+ function parseRepositoryRelativePath(filepath) {
66
+ if (typeof filepath !== 'string' || filepath.length === 0) {
67
+ return { ok: false, error: 'Invalid file path' };
68
+ }
69
+ const normalizedFilepath = filepath.replace(/\\/g, '/');
70
+ const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
71
+ if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
72
+ return { ok: false, error: 'File path outside repository' };
73
+ }
74
+ const resolvedPath = resolve(repositoryPath, normalizedFilepath);
75
+ if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
76
+ return { ok: false, error: 'File path outside repository' };
77
+ }
78
+ return { ok: true, path: normalizedFilepath };
79
+ }
58
80
  app.get('/api/diff', async (req, res) => {
59
81
  const ignoreWhitespace = req.query.ignoreWhitespace === 'true';
60
82
  const requestedBase = req.query.base || options.baseCommitish || '';
61
83
  const requestedTarget = req.query.target || options.targetCommitish || '';
84
+ const shouldIncludeCommentImports = initialCommentImports.length > 0 &&
85
+ (Boolean(options.stdinDiff) ||
86
+ (requestedBase === initialBaseCommitish && requestedTarget === initialTargetCommitish));
62
87
  // Check if revisions or whitespace setting changed
63
88
  const revisionsChanged = requestedBase !== currentBaseCommitish || requestedTarget !== currentTargetCommitish;
64
89
  const whitespaceChanged = ignoreWhitespace !== currentIgnoreWhitespace;
@@ -106,6 +131,8 @@ export async function startServer(options) {
106
131
  requestedTargetCommitish,
107
132
  clearComments: options.clearComments,
108
133
  repositoryId,
134
+ commentImports: shouldIncludeCommentImports ? initialCommentImports : undefined,
135
+ commentImportId: shouldIncludeCommentImports ? commentImportId : undefined,
109
136
  });
110
137
  });
111
138
  app.get(/^\/api\/generated-status\/(.*)$/, async (req, res) => {
@@ -114,22 +141,12 @@ export async function startServer(options) {
114
141
  return;
115
142
  }
116
143
  try {
117
- const filepath = req.params[0];
118
- if (typeof filepath !== 'string' || filepath.length === 0) {
119
- res.status(400).json({ error: 'Invalid file path' });
120
- return;
121
- }
122
- const normalizedFilepath = filepath.replace(/\\/g, '/');
123
- const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
124
- if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
125
- res.status(400).json({ error: 'File path outside repository' });
126
- return;
127
- }
128
- const resolvedPath = resolve(repositoryPath, normalizedFilepath);
129
- if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
130
- res.status(400).json({ error: 'File path outside repository' });
144
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
145
+ if (!filepathResult.ok) {
146
+ res.status(400).json({ error: filepathResult.error });
131
147
  return;
132
148
  }
149
+ const normalizedFilepath = filepathResult.path;
133
150
  const ref = req.query.ref || currentTargetCommitish || 'HEAD';
134
151
  const cacheKey = `${ref}:${normalizedFilepath}`;
135
152
  const now = Date.now();
@@ -187,10 +204,22 @@ export async function startServer(options) {
187
204
  res.status(404).json({ error: 'Line count not available for stdin diff' });
188
205
  return;
189
206
  }
190
- const filepath = req.params[0];
207
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
208
+ if (!filepathResult.ok) {
209
+ res.status(400).json({ error: filepathResult.error });
210
+ return;
211
+ }
212
+ const filepath = filepathResult.path;
191
213
  const oldRef = req.query.oldRef;
214
+ const oldPathResult = req.query.oldPath
215
+ ? parseRepositoryRelativePath(req.query.oldPath)
216
+ : { ok: true, path: filepath };
217
+ if (!oldPathResult.ok) {
218
+ res.status(400).json({ error: oldPathResult.error });
219
+ return;
220
+ }
192
221
  const newRef = req.query.newRef;
193
- const oldPath = req.query.oldPath || filepath;
222
+ const oldPath = oldPathResult.path;
194
223
  const result = {};
195
224
  if (oldRef) {
196
225
  try {
@@ -222,7 +251,12 @@ export async function startServer(options) {
222
251
  res.status(404).json({ error: 'Blob content not available for stdin diff' });
223
252
  return;
224
253
  }
225
- const filepath = req.params[0];
254
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
255
+ if (!filepathResult.ok) {
256
+ res.status(400).json({ error: filepathResult.error });
257
+ return;
258
+ }
259
+ const filepath = filepathResult.path;
226
260
  const ref = req.query.ref || 'HEAD';
227
261
  const blob = await parser.getBlobContent(filepath, ref);
228
262
  // Determine content type based on file extension
@@ -254,18 +288,59 @@ export async function startServer(options) {
254
288
  res.status(404).json({ error: 'File not found' });
255
289
  }
256
290
  });
257
- // Store comments for final output
258
- let finalComments = [];
259
- // Parse comments from request body (handles both JSON and text/plain)
291
+ let finalThreads = [];
292
+ function normalizeComment(comment) {
293
+ return {
294
+ id: comment.id,
295
+ file: comment.file,
296
+ line: comment.line,
297
+ side: comment.side,
298
+ createdAt: comment.timestamp,
299
+ updatedAt: comment.timestamp,
300
+ codeContent: comment.codeContent,
301
+ messages: [
302
+ {
303
+ id: comment.id,
304
+ body: comment.body,
305
+ author: comment.author,
306
+ createdAt: comment.timestamp,
307
+ updatedAt: comment.timestamp,
308
+ },
309
+ ],
310
+ };
311
+ }
312
+ function normalizeThreadPayload(thread) {
313
+ if ('file' in thread && 'line' in thread) {
314
+ return thread;
315
+ }
316
+ return {
317
+ id: thread.id,
318
+ file: thread.filePath,
319
+ line: typeof thread.position.line === 'number'
320
+ ? thread.position.line
321
+ : [thread.position.line.start, thread.position.line.end],
322
+ side: thread.position.side,
323
+ createdAt: thread.createdAt,
324
+ updatedAt: thread.updatedAt,
325
+ codeContent: thread.codeSnapshot?.content,
326
+ messages: thread.messages,
327
+ };
328
+ }
260
329
  function parseCommentsPayload(body) {
261
330
  const payload = typeof body === 'string'
262
331
  ? JSON.parse(body)
263
332
  : body;
264
- return payload.comments || [];
333
+ if (Array.isArray(payload.threads)) {
334
+ return payload.threads.map(normalizeThreadPayload);
335
+ }
336
+ if (Array.isArray(payload.comments)) {
337
+ return payload.comments.map(normalizeComment);
338
+ }
339
+ return [];
265
340
  }
266
341
  app.post('/api/comments', (req, res) => {
267
342
  try {
268
- finalComments = parseCommentsPayload(req.body);
343
+ finalThreads = parseCommentsPayload(req.body);
269
344
  res.json({ success: true });
270
345
  }
271
346
  catch (error) {
@@ -274,8 +349,9 @@ export async function startServer(options) {
274
349
  }
275
350
  });
276
351
  app.get('/api/comments-output', (_req, res) => {
277
- if (finalComments.length > 0) {
278
- const output = formatCommentsOutput(finalComments);
352
+ res.type('text/plain');
353
+ if (finalThreads.length > 0) {
354
+ const output = formatCommentsOutput(finalThreads);
279
355
  res.send(output);
280
356
  }
281
357
  else {
@@ -292,12 +368,12 @@ export async function startServer(options) {
292
368
  res.status(400).json({ error: 'Invalid request payload' });
293
369
  return;
294
370
  }
295
- const repoRoot = resolve(options.repoPath ?? process.cwd());
296
- const resolvedPath = resolve(repoRoot, filePath);
297
- if (resolvedPath !== repoRoot && !resolvedPath.startsWith(`${repoRoot}${sep}`)) {
298
- res.status(400).json({ error: 'File path outside repository' });
371
+ const filepathResult = parseRepositoryRelativePath(filePath);
372
+ if (!filepathResult.ok) {
373
+ res.status(400).json({ error: filepathResult.error });
299
374
  return;
300
375
  }
376
+ const resolvedPath = resolve(repositoryPath, filepathResult.path);
301
377
  const editorInput = typeof editor === 'string' ? editor : (process.env.DIFIT_EDITOR ?? process.env.EDITOR);
302
378
  const resolvedEditor = resolveEditorOption(editorInput);
303
379
  if (resolvedEditor.protocol === null) {
@@ -324,7 +400,7 @@ export async function startServer(options) {
324
400
  else {
325
401
  args.push(resolvedPath);
326
402
  }
327
- args.push(repoRoot);
403
+ args.push(repositoryPath);
328
404
  return await new Promise((resolvePromise) => {
329
405
  const child = spawn(resolvedEditor.cliCommand, args, { stdio: 'ignore', detached: true });
330
406
  child.once('error', (error) => {
@@ -357,8 +433,8 @@ export async function startServer(options) {
357
433
  });
358
434
  // Function to output comments when server shuts down
359
435
  function outputFinalComments() {
360
- if (finalComments.length > 0) {
361
- console.log(formatCommentsOutput(finalComments));
436
+ if (finalThreads.length > 0) {
437
+ console.log(formatCommentsOutput(finalThreads));
362
438
  }
363
439
  }
364
440
  // SSE endpoint for file watching
@@ -413,9 +489,6 @@ export async function startServer(options) {
413
489
  // Find client files relative to the CLI executable location
414
490
  const distPath = join(__dirname, '..', 'client');
415
491
  app.use(express.static(distPath));
416
- app.get('/{*splat}', (_req, res) => {
417
- res.sendFile(join(distPath, 'index.html'));
418
- });
419
492
  }
420
493
  else {
421
494
  app.get('/', (_req, res) => {