diffity 0.1.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.
- package/.claude/settings.local.json +11 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/development.md +156 -0
- package/package.json +32 -0
- package/packages/cli/build.js +38 -0
- package/packages/cli/package.json +51 -0
- package/packages/cli/src/agent.ts +187 -0
- package/packages/cli/src/db.ts +58 -0
- package/packages/cli/src/index.ts +196 -0
- package/packages/cli/src/review-routes.ts +150 -0
- package/packages/cli/src/server.ts +370 -0
- package/packages/cli/src/session.ts +48 -0
- package/packages/cli/src/threads.ts +238 -0
- package/packages/cli/tsconfig.json +13 -0
- package/packages/git/package.json +24 -0
- package/packages/git/src/commits.ts +28 -0
- package/packages/git/src/diff.ts +97 -0
- package/packages/git/src/exec.ts +35 -0
- package/packages/git/src/index.ts +5 -0
- package/packages/git/src/repo.ts +63 -0
- package/packages/git/src/status.ts +9 -0
- package/packages/git/src/types.ts +12 -0
- package/packages/git/tsconfig.json +9 -0
- package/packages/parser/package.json +26 -0
- package/packages/parser/src/index.ts +12 -0
- package/packages/parser/src/parse.ts +299 -0
- package/packages/parser/src/types.ts +52 -0
- package/packages/parser/src/word-diff.ts +155 -0
- package/packages/parser/tests/fixtures/binary-deleted.diff +4 -0
- package/packages/parser/tests/fixtures/binary-file.diff +4 -0
- package/packages/parser/tests/fixtures/binary-modified.diff +3 -0
- package/packages/parser/tests/fixtures/copied-file.diff +12 -0
- package/packages/parser/tests/fixtures/deleted-file.diff +9 -0
- package/packages/parser/tests/fixtures/empty.diff +0 -0
- package/packages/parser/tests/fixtures/hunk-with-context.diff +12 -0
- package/packages/parser/tests/fixtures/mode-change-with-content.diff +10 -0
- package/packages/parser/tests/fixtures/mode-change.diff +3 -0
- package/packages/parser/tests/fixtures/multi-file.diff +22 -0
- package/packages/parser/tests/fixtures/new-file.diff +9 -0
- package/packages/parser/tests/fixtures/no-newline.diff +10 -0
- package/packages/parser/tests/fixtures/renamed-file.diff +12 -0
- package/packages/parser/tests/fixtures/single-file-additions.diff +11 -0
- package/packages/parser/tests/fixtures/single-file-deletions.diff +11 -0
- package/packages/parser/tests/fixtures/single-file-mixed.diff +15 -0
- package/packages/parser/tests/fixtures/single-file-multi-hunk.diff +22 -0
- package/packages/parser/tests/fixtures/spaces-in-path.diff +9 -0
- package/packages/parser/tests/fixtures/submodule.diff +7 -0
- package/packages/parser/tests/fixtures/unicode-content.diff +11 -0
- package/packages/parser/tests/parse.test.ts +312 -0
- package/packages/parser/tests/word-diff-integration.test.ts +52 -0
- package/packages/parser/tests/word-diff.test.ts +121 -0
- package/packages/parser/tsconfig.json +10 -0
- package/packages/skills/diffity-resolve/SKILL.md +55 -0
- package/packages/skills/diffity-review/SKILL.md +74 -0
- package/packages/skills/diffity-start/SKILL.md +25 -0
- package/packages/ui/index.html +13 -0
- package/packages/ui/package.json +35 -0
- package/packages/ui/public/brand.svg +12 -0
- package/packages/ui/public/favicon.svg +15 -0
- package/packages/ui/src/app.tsx +14 -0
- package/packages/ui/src/components/comment-bubble.tsx +78 -0
- package/packages/ui/src/components/comment-form-row.tsx +58 -0
- package/packages/ui/src/components/comment-form.tsx +78 -0
- package/packages/ui/src/components/comment-line-number.tsx +60 -0
- package/packages/ui/src/components/comment-thread.tsx +209 -0
- package/packages/ui/src/components/commit-list.tsx +100 -0
- package/packages/ui/src/components/dashboard.tsx +84 -0
- package/packages/ui/src/components/diff-line.tsx +90 -0
- package/packages/ui/src/components/diff-page.tsx +332 -0
- package/packages/ui/src/components/diff-stats.tsx +20 -0
- package/packages/ui/src/components/diff-view.tsx +278 -0
- package/packages/ui/src/components/expand-row.tsx +45 -0
- package/packages/ui/src/components/file-block.tsx +536 -0
- package/packages/ui/src/components/file-tree-item.tsx +84 -0
- package/packages/ui/src/components/file-tree.tsx +72 -0
- package/packages/ui/src/components/general-comments.tsx +174 -0
- package/packages/ui/src/components/hunk-block-split.tsx +357 -0
- package/packages/ui/src/components/hunk-block.tsx +161 -0
- package/packages/ui/src/components/hunk-header.tsx +144 -0
- package/packages/ui/src/components/hunk-with-gap.tsx +113 -0
- package/packages/ui/src/components/icons/arrow-down-icon.tsx +7 -0
- package/packages/ui/src/components/icons/arrow-up-icon.tsx +7 -0
- package/packages/ui/src/components/icons/check-circle-icon.tsx +8 -0
- package/packages/ui/src/components/icons/check-icon.tsx +9 -0
- package/packages/ui/src/components/icons/chevron-down-icon.tsx +11 -0
- package/packages/ui/src/components/icons/chevron-icon.tsx +20 -0
- package/packages/ui/src/components/icons/chevron-up-down-icon.tsx +7 -0
- package/packages/ui/src/components/icons/chevron-up-icon.tsx +11 -0
- package/packages/ui/src/components/icons/comment-icon.tsx +9 -0
- package/packages/ui/src/components/icons/copy-icon.tsx +10 -0
- package/packages/ui/src/components/icons/eye-icon.tsx +10 -0
- package/packages/ui/src/components/icons/eye-off-icon.tsx +12 -0
- package/packages/ui/src/components/icons/file-icon.tsx +7 -0
- package/packages/ui/src/components/icons/folder-icon.tsx +19 -0
- package/packages/ui/src/components/icons/git-branch-icon.tsx +13 -0
- package/packages/ui/src/components/icons/keyboard-icon.tsx +13 -0
- package/packages/ui/src/components/icons/moon-icon.tsx +9 -0
- package/packages/ui/src/components/icons/plus-icon.tsx +9 -0
- package/packages/ui/src/components/icons/search-icon.tsx +10 -0
- package/packages/ui/src/components/icons/sidebar-icon.tsx +10 -0
- package/packages/ui/src/components/icons/spinner.tsx +7 -0
- package/packages/ui/src/components/icons/split-view-icon.tsx +10 -0
- package/packages/ui/src/components/icons/sun-icon.tsx +17 -0
- package/packages/ui/src/components/icons/trash-icon.tsx +11 -0
- package/packages/ui/src/components/icons/undo-icon.tsx +9 -0
- package/packages/ui/src/components/icons/unified-view-icon.tsx +12 -0
- package/packages/ui/src/components/icons/x-icon.tsx +10 -0
- package/packages/ui/src/components/line-number-cell.tsx +18 -0
- package/packages/ui/src/components/markdown-content.tsx +139 -0
- package/packages/ui/src/components/orphaned-threads.tsx +80 -0
- package/packages/ui/src/components/overview-file-list.tsx +57 -0
- package/packages/ui/src/components/render-expansion-rows.tsx +47 -0
- package/packages/ui/src/components/shortcut-modal.tsx +93 -0
- package/packages/ui/src/components/sidebar.tsx +80 -0
- package/packages/ui/src/components/skeleton.tsx +9 -0
- package/packages/ui/src/components/stale-diff-banner.tsx +21 -0
- package/packages/ui/src/components/summary-bar.tsx +39 -0
- package/packages/ui/src/components/toolbar.tsx +246 -0
- package/packages/ui/src/components/ui/badge.tsx +17 -0
- package/packages/ui/src/components/ui/confirm-dialog.tsx +52 -0
- package/packages/ui/src/components/ui/icon-button.tsx +23 -0
- package/packages/ui/src/components/ui/status-badge.tsx +57 -0
- package/packages/ui/src/components/ui/thread-badge.tsx +35 -0
- package/packages/ui/src/components/word-diff.tsx +126 -0
- package/packages/ui/src/hooks/use-comment-actions.ts +97 -0
- package/packages/ui/src/hooks/use-commits.ts +12 -0
- package/packages/ui/src/hooks/use-copy.ts +18 -0
- package/packages/ui/src/hooks/use-diff-staleness.ts +58 -0
- package/packages/ui/src/hooks/use-diff.ts +12 -0
- package/packages/ui/src/hooks/use-highlighter.ts +190 -0
- package/packages/ui/src/hooks/use-info.ts +12 -0
- package/packages/ui/src/hooks/use-keyboard.ts +55 -0
- package/packages/ui/src/hooks/use-line-selection.ts +157 -0
- package/packages/ui/src/hooks/use-overview.ts +12 -0
- package/packages/ui/src/hooks/use-review-threads.ts +12 -0
- package/packages/ui/src/hooks/use-search-params.ts +26 -0
- package/packages/ui/src/hooks/use-theme.ts +34 -0
- package/packages/ui/src/hooks/use-thread-navigation.ts +43 -0
- package/packages/ui/src/lib/api.ts +232 -0
- package/packages/ui/src/lib/cn.ts +6 -0
- package/packages/ui/src/lib/context-expansion.ts +122 -0
- package/packages/ui/src/lib/diff-utils.ts +268 -0
- package/packages/ui/src/lib/dom-utils.ts +13 -0
- package/packages/ui/src/lib/file-tree.ts +122 -0
- package/packages/ui/src/lib/query-client.ts +10 -0
- package/packages/ui/src/lib/render-content.tsx +23 -0
- package/packages/ui/src/lib/syntax-token.ts +4 -0
- package/packages/ui/src/main.tsx +14 -0
- package/packages/ui/src/queries/commits.ts +9 -0
- package/packages/ui/src/queries/diff.ts +9 -0
- package/packages/ui/src/queries/file.ts +10 -0
- package/packages/ui/src/queries/info.ts +9 -0
- package/packages/ui/src/queries/overview.ts +9 -0
- package/packages/ui/src/styles/app.css +178 -0
- package/packages/ui/src/types/comment.ts +61 -0
- package/packages/ui/src/vite-env.d.ts +1 -0
- package/packages/ui/tests/context-expansion.test.ts +279 -0
- package/packages/ui/tests/diff-utils.test.ts +409 -0
- package/packages/ui/tsconfig.json +14 -0
- package/packages/ui/vite.config.ts +23 -0
- package/scripts/build-skills.ts +26 -0
- package/scripts/build.ts +15 -0
- package/scripts/dev.ts +32 -0
- package/scripts/lib/transformers/claude-code.ts +11 -0
- package/scripts/lib/transformers/codex.ts +17 -0
- package/scripts/lib/transformers/cursor.ts +17 -0
- package/scripts/lib/transformers/index.ts +3 -0
- package/scripts/lib/utils.ts +70 -0
- package/scripts/link-dev.ts +54 -0
- package/skills/diffity-resolve/SKILL.md +55 -0
- package/skills/diffity-review/SKILL.md +74 -0
- package/skills/diffity-start/SKILL.md +27 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { exec } from './exec.js';
|
|
2
|
+
import type { Commit } from './types.js';
|
|
3
|
+
|
|
4
|
+
interface CommitQuery {
|
|
5
|
+
count: number;
|
|
6
|
+
skip?: number;
|
|
7
|
+
search?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getRecentCommits(query: CommitQuery): Commit[] {
|
|
11
|
+
const { count, skip = 0, search } = query;
|
|
12
|
+
|
|
13
|
+
const args = [`-n ${count}`, `--skip=${skip}`, '--format="%H|%h|%s|%cr"'];
|
|
14
|
+
if (search) {
|
|
15
|
+
args.push(`--grep=${search}`, '-i');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const output = exec(`git log ${args.join(' ')}`);
|
|
19
|
+
|
|
20
|
+
if (!output) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return output.split('\n').map((line) => {
|
|
25
|
+
const [hash, shortHash, message, relativeDate] = line.split('|');
|
|
26
|
+
return { hash, shortHash, message, relativeDate };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { exec, execLarge, execLines, execWithStdin } from './exec.js';
|
|
2
|
+
|
|
3
|
+
export function getDiff(args: string[] = []): string {
|
|
4
|
+
const cmd = ['git', 'diff', ...args].join(' ');
|
|
5
|
+
return execLarge(cmd);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getUntrackedFiles(): string[] {
|
|
9
|
+
return execLines('git ls-files --others --exclude-standard');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getUntrackedDiff(files: string[]): string {
|
|
13
|
+
const diffs: string[] = [];
|
|
14
|
+
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
try {
|
|
17
|
+
execLarge(`git diff --no-index -- /dev/null "${file}"`);
|
|
18
|
+
} catch (err: unknown) {
|
|
19
|
+
const error = err as { stdout?: string; status?: number };
|
|
20
|
+
if (error.status === 1 && error.stdout) {
|
|
21
|
+
diffs.push(error.stdout);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return diffs.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveRef(ref: string, extraArgs: string[] = []): string {
|
|
30
|
+
switch (ref) {
|
|
31
|
+
case 'staged': {
|
|
32
|
+
return getDiff(['--staged', ...extraArgs]);
|
|
33
|
+
}
|
|
34
|
+
case 'unstaged': {
|
|
35
|
+
return getDiff(extraArgs);
|
|
36
|
+
}
|
|
37
|
+
case 'working': {
|
|
38
|
+
return getDiff(['HEAD', ...extraArgs]);
|
|
39
|
+
}
|
|
40
|
+
case 'untracked': {
|
|
41
|
+
const files = getUntrackedFiles();
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
return getUntrackedDiff(files);
|
|
46
|
+
}
|
|
47
|
+
case 'work': {
|
|
48
|
+
let raw = getDiff(['HEAD', ...extraArgs]);
|
|
49
|
+
const untrackedFiles = getUntrackedFiles();
|
|
50
|
+
if (untrackedFiles.length > 0) {
|
|
51
|
+
raw += '\n' + getUntrackedDiff(untrackedFiles);
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
default: {
|
|
56
|
+
return getDiff([ref, ...extraArgs]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function revertFile(filePath: string, isUntracked: boolean): void {
|
|
62
|
+
if (isUntracked) {
|
|
63
|
+
exec(`rm "${filePath}"`);
|
|
64
|
+
} else {
|
|
65
|
+
exec(`git checkout HEAD -- "${filePath}"`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function revertHunk(patch: string): void {
|
|
70
|
+
execWithStdin('git apply --reverse --unidiff-zero', patch);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getDiffStat(args: string[] = []): string {
|
|
74
|
+
const cmd = ['git', 'diff', '--stat', ...args].join(' ');
|
|
75
|
+
try {
|
|
76
|
+
return execLarge(cmd);
|
|
77
|
+
} catch {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getMergeBase(a: string, b: string): string {
|
|
83
|
+
return exec(`git merge-base ${a} ${b}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getFileContent(path: string, ref = 'HEAD'): string {
|
|
87
|
+
return exec(`git show ${ref}:${path}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getFileLineCount(path: string, ref = 'HEAD'): number | null {
|
|
91
|
+
try {
|
|
92
|
+
const content = exec(`git show ${ref}:${path}`);
|
|
93
|
+
return content.split('\n').length;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execSync, type StdioOptions } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const STDIO: StdioOptions = ['pipe', 'pipe', 'pipe'];
|
|
4
|
+
|
|
5
|
+
export function execWithStdin(cmd: string, input: string): string {
|
|
6
|
+
return execSync(cmd, {
|
|
7
|
+
encoding: 'utf-8',
|
|
8
|
+
stdio: STDIO,
|
|
9
|
+
input,
|
|
10
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function exec(cmd: string): string {
|
|
15
|
+
return execSync(cmd, {
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
stdio: STDIO,
|
|
18
|
+
}).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function execLarge(cmd: string): string {
|
|
22
|
+
return execSync(cmd, {
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: STDIO,
|
|
25
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function execLines(cmd: string): string[] {
|
|
30
|
+
const output = exec(cmd);
|
|
31
|
+
if (!output) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return output.split('\n');
|
|
35
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { Commit, RepoInfo } from './types.js';
|
|
2
|
+
export { isGitRepo, getRepoRoot, getRepoName, getCurrentBranch, getRepoInfo, getHeadHash, getDiffityDir, getDiffityDirPath, isActionableRef } from './repo.js';
|
|
3
|
+
export { getDiff, getDiffStat, getUntrackedFiles, getUntrackedDiff, getFileContent, getFileLineCount, getMergeBase, resolveRef, revertFile, revertHunk } from './diff.js';
|
|
4
|
+
export { getStagedFiles, getUnstagedFiles } from './status.js';
|
|
5
|
+
export { getRecentCommits } from './commits.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { exec } from './exec.js';
|
|
7
|
+
import type { RepoInfo } from './types.js';
|
|
8
|
+
|
|
9
|
+
export function isGitRepo(): boolean {
|
|
10
|
+
try {
|
|
11
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getRepoRoot(): string {
|
|
19
|
+
return exec('git rev-parse --show-toplevel');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getRepoName(): string {
|
|
23
|
+
const root = getRepoRoot();
|
|
24
|
+
return root.split('/').pop() || root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getCurrentBranch(): string {
|
|
28
|
+
try {
|
|
29
|
+
return exec('git rev-parse --abbrev-ref HEAD');
|
|
30
|
+
} catch {
|
|
31
|
+
return 'HEAD';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getRepoInfo(): RepoInfo {
|
|
36
|
+
return {
|
|
37
|
+
name: getRepoName(),
|
|
38
|
+
branch: getCurrentBranch(),
|
|
39
|
+
root: getRepoRoot(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getHeadHash(): string {
|
|
44
|
+
return exec('git rev-parse HEAD');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDiffityDirPath(): string {
|
|
48
|
+
const repoRoot = getRepoRoot();
|
|
49
|
+
const hash = createHash('sha256').update(repoRoot).digest('hex').slice(0, 12);
|
|
50
|
+
return join(homedir(), '.diffity', hash);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getDiffityDir(): string {
|
|
54
|
+
const dir = getDiffityDirPath();
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ACTIONABLE_REFS = new Set(['work', 'staged', 'unstaged', 'working', 'untracked']);
|
|
60
|
+
|
|
61
|
+
export function isActionableRef(ref?: string): boolean {
|
|
62
|
+
return !!ref && ACTIONABLE_REFS.has(ref);
|
|
63
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diffity/parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vitest": "^4.1.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiffFile,
|
|
3
|
+
DiffHunk,
|
|
4
|
+
DiffLine,
|
|
5
|
+
DiffLineType,
|
|
6
|
+
FileStatus,
|
|
7
|
+
ParsedDiff,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import { computeWordDiff } from './word-diff.js';
|
|
10
|
+
|
|
11
|
+
const DIFF_HEADER_RE = /^diff --git a\/(.*) b\/(.*)$/;
|
|
12
|
+
const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
13
|
+
const OLD_FILE_RE = /^--- (.+)$/;
|
|
14
|
+
const NEW_FILE_RE = /^\+\+\+ (.+)$/;
|
|
15
|
+
const SIMILARITY_RE = /^similarity index (\d+)%$/;
|
|
16
|
+
const RENAME_FROM_RE = /^rename from (.+)$/;
|
|
17
|
+
const RENAME_TO_RE = /^rename to (.+)$/;
|
|
18
|
+
const COPY_FROM_RE = /^copy from (.+)$/;
|
|
19
|
+
const COPY_TO_RE = /^copy to (.+)$/;
|
|
20
|
+
const OLD_MODE_RE = /^old mode (\d+)$/;
|
|
21
|
+
const NEW_MODE_RE = /^new mode (\d+)$/;
|
|
22
|
+
const NEW_FILE_MODE_RE = /^new file mode (\d+)$/;
|
|
23
|
+
const DELETED_FILE_MODE_RE = /^deleted file mode (\d+)$/;
|
|
24
|
+
const BINARY_RE = /^Binary files (.+) and (.+) differ$/;
|
|
25
|
+
const NO_NEWLINE_RE = /^\$/;
|
|
26
|
+
|
|
27
|
+
function stripPrefix(path: string): string {
|
|
28
|
+
if (path === '/dev/null') {
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
if (path.startsWith('a/') || path.startsWith('b/')) {
|
|
32
|
+
return path.slice(2);
|
|
33
|
+
}
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function attachWordDiffs(hunk: DiffHunk): void {
|
|
38
|
+
const lines = hunk.lines;
|
|
39
|
+
let i = 0;
|
|
40
|
+
|
|
41
|
+
while (i < lines.length) {
|
|
42
|
+
if (lines[i].type === 'delete') {
|
|
43
|
+
const deleteStart = i;
|
|
44
|
+
while (i < lines.length && lines[i].type === 'delete') {
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
const deleteEnd = i;
|
|
48
|
+
|
|
49
|
+
const addStart = i;
|
|
50
|
+
while (i < lines.length && lines[i].type === 'add') {
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
const addEnd = i;
|
|
54
|
+
|
|
55
|
+
const deleteCount = deleteEnd - deleteStart;
|
|
56
|
+
const addCount = addEnd - addStart;
|
|
57
|
+
|
|
58
|
+
if (deleteCount > 0 && addCount > 0) {
|
|
59
|
+
const pairCount = Math.min(deleteCount, addCount);
|
|
60
|
+
for (let p = 0; p < pairCount; p++) {
|
|
61
|
+
const delLine = lines[deleteStart + p];
|
|
62
|
+
const addLine = lines[addStart + p];
|
|
63
|
+
const segments = computeWordDiff(delLine.content, addLine.content);
|
|
64
|
+
delLine.wordDiff = segments;
|
|
65
|
+
addLine.wordDiff = segments;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseDiff(raw: string): ParsedDiff {
|
|
75
|
+
const lines = raw.split('\n');
|
|
76
|
+
const files: DiffFile[] = [];
|
|
77
|
+
let currentFile: DiffFile | null = null;
|
|
78
|
+
let currentHunk: DiffHunk | null = null;
|
|
79
|
+
let oldLineNum = 0;
|
|
80
|
+
let newLineNum = 0;
|
|
81
|
+
let i = 0;
|
|
82
|
+
|
|
83
|
+
while (i < lines.length) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
|
|
86
|
+
const diffMatch = line.match(DIFF_HEADER_RE);
|
|
87
|
+
if (diffMatch) {
|
|
88
|
+
if (currentFile) {
|
|
89
|
+
files.push(currentFile);
|
|
90
|
+
}
|
|
91
|
+
currentFile = {
|
|
92
|
+
oldPath: diffMatch[1],
|
|
93
|
+
newPath: diffMatch[2],
|
|
94
|
+
status: 'modified',
|
|
95
|
+
hunks: [],
|
|
96
|
+
additions: 0,
|
|
97
|
+
deletions: 0,
|
|
98
|
+
isBinary: false,
|
|
99
|
+
};
|
|
100
|
+
currentHunk = null;
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!currentFile) {
|
|
106
|
+
i++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const oldModeMatch = line.match(OLD_MODE_RE);
|
|
111
|
+
if (oldModeMatch) {
|
|
112
|
+
currentFile.oldMode = oldModeMatch[1];
|
|
113
|
+
i++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const newModeMatch = line.match(NEW_MODE_RE);
|
|
118
|
+
if (newModeMatch) {
|
|
119
|
+
currentFile.newMode = newModeMatch[1];
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const newFileModeMatch = line.match(NEW_FILE_MODE_RE);
|
|
125
|
+
if (newFileModeMatch) {
|
|
126
|
+
currentFile.newMode = newFileModeMatch[1];
|
|
127
|
+
currentFile.status = 'added';
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const deletedFileModeMatch = line.match(DELETED_FILE_MODE_RE);
|
|
133
|
+
if (deletedFileModeMatch) {
|
|
134
|
+
currentFile.oldMode = deletedFileModeMatch[1];
|
|
135
|
+
currentFile.status = 'deleted';
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const similarityMatch = line.match(SIMILARITY_RE);
|
|
141
|
+
if (similarityMatch) {
|
|
142
|
+
currentFile.similarityIndex = parseInt(similarityMatch[1], 10);
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const renameFromMatch = line.match(RENAME_FROM_RE);
|
|
148
|
+
if (renameFromMatch) {
|
|
149
|
+
currentFile.oldPath = renameFromMatch[1];
|
|
150
|
+
currentFile.status = 'renamed';
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const renameToMatch = line.match(RENAME_TO_RE);
|
|
156
|
+
if (renameToMatch) {
|
|
157
|
+
currentFile.newPath = renameToMatch[1];
|
|
158
|
+
currentFile.status = 'renamed';
|
|
159
|
+
i++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const copyFromMatch = line.match(COPY_FROM_RE);
|
|
164
|
+
if (copyFromMatch) {
|
|
165
|
+
currentFile.oldPath = copyFromMatch[1];
|
|
166
|
+
currentFile.status = 'copied';
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const copyToMatch = line.match(COPY_TO_RE);
|
|
172
|
+
if (copyToMatch) {
|
|
173
|
+
currentFile.newPath = copyToMatch[1];
|
|
174
|
+
currentFile.status = 'copied';
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const oldFileMatch = line.match(OLD_FILE_RE);
|
|
180
|
+
if (oldFileMatch) {
|
|
181
|
+
const path = stripPrefix(oldFileMatch[1]);
|
|
182
|
+
currentFile.oldPath = path;
|
|
183
|
+
if (path === '/dev/null') {
|
|
184
|
+
currentFile.status = 'added';
|
|
185
|
+
}
|
|
186
|
+
i++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const newFileMatch = line.match(NEW_FILE_RE);
|
|
191
|
+
if (newFileMatch) {
|
|
192
|
+
const path = stripPrefix(newFileMatch[1]);
|
|
193
|
+
currentFile.newPath = path;
|
|
194
|
+
if (path === '/dev/null') {
|
|
195
|
+
currentFile.status = 'deleted';
|
|
196
|
+
}
|
|
197
|
+
i++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const binaryMatch = line.match(BINARY_RE);
|
|
202
|
+
if (binaryMatch) {
|
|
203
|
+
currentFile.isBinary = true;
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const hunkMatch = line.match(HUNK_HEADER_RE);
|
|
209
|
+
if (hunkMatch) {
|
|
210
|
+
currentHunk = {
|
|
211
|
+
header: line,
|
|
212
|
+
oldStart: parseInt(hunkMatch[1], 10),
|
|
213
|
+
oldCount: hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1,
|
|
214
|
+
newStart: parseInt(hunkMatch[3], 10),
|
|
215
|
+
newCount: hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1,
|
|
216
|
+
context: hunkMatch[5]?.trim() || undefined,
|
|
217
|
+
lines: [],
|
|
218
|
+
};
|
|
219
|
+
currentFile.hunks.push(currentHunk);
|
|
220
|
+
oldLineNum = currentHunk.oldStart;
|
|
221
|
+
newLineNum = currentHunk.newStart;
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (NO_NEWLINE_RE.test(line)) {
|
|
227
|
+
if (currentHunk && currentHunk.lines.length > 0) {
|
|
228
|
+
currentHunk.lines[currentHunk.lines.length - 1].noNewline = true;
|
|
229
|
+
}
|
|
230
|
+
i++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (currentHunk) {
|
|
235
|
+
const prefix = line[0];
|
|
236
|
+
const content = line.slice(1);
|
|
237
|
+
|
|
238
|
+
if (prefix === '+') {
|
|
239
|
+
const diffLine: DiffLine = {
|
|
240
|
+
type: 'add',
|
|
241
|
+
content,
|
|
242
|
+
oldLineNumber: null,
|
|
243
|
+
newLineNumber: newLineNum,
|
|
244
|
+
};
|
|
245
|
+
currentHunk.lines.push(diffLine);
|
|
246
|
+
currentFile.additions++;
|
|
247
|
+
newLineNum++;
|
|
248
|
+
} else if (prefix === '-') {
|
|
249
|
+
const diffLine: DiffLine = {
|
|
250
|
+
type: 'delete',
|
|
251
|
+
content,
|
|
252
|
+
oldLineNumber: oldLineNum,
|
|
253
|
+
newLineNumber: null,
|
|
254
|
+
};
|
|
255
|
+
currentHunk.lines.push(diffLine);
|
|
256
|
+
currentFile.deletions++;
|
|
257
|
+
oldLineNum++;
|
|
258
|
+
} else if (prefix === ' ') {
|
|
259
|
+
const diffLine: DiffLine = {
|
|
260
|
+
type: 'context',
|
|
261
|
+
content: content || '',
|
|
262
|
+
oldLineNumber: oldLineNum,
|
|
263
|
+
newLineNumber: newLineNum,
|
|
264
|
+
};
|
|
265
|
+
currentHunk.lines.push(diffLine);
|
|
266
|
+
oldLineNum++;
|
|
267
|
+
newLineNum++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
i++;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (currentFile) {
|
|
275
|
+
files.push(currentFile);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
for (const hunk of file.hunks) {
|
|
280
|
+
attachWordDiffs(hunk);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let totalAdditions = 0;
|
|
285
|
+
let totalDeletions = 0;
|
|
286
|
+
for (const file of files) {
|
|
287
|
+
totalAdditions += file.additions;
|
|
288
|
+
totalDeletions += file.deletions;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
files,
|
|
293
|
+
stats: {
|
|
294
|
+
totalAdditions,
|
|
295
|
+
totalDeletions,
|
|
296
|
+
filesChanged: files.length,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type FileStatus = 'added' | 'deleted' | 'modified' | 'renamed' | 'copied';
|
|
2
|
+
|
|
3
|
+
export type LineDiffType = 'equal' | 'insert' | 'delete';
|
|
4
|
+
|
|
5
|
+
export interface WordDiffSegment {
|
|
6
|
+
text: string;
|
|
7
|
+
type: LineDiffType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type DiffLineType = 'add' | 'delete' | 'context';
|
|
11
|
+
|
|
12
|
+
export interface DiffLine {
|
|
13
|
+
type: DiffLineType;
|
|
14
|
+
content: string;
|
|
15
|
+
oldLineNumber: number | null;
|
|
16
|
+
newLineNumber: number | null;
|
|
17
|
+
noNewline?: boolean;
|
|
18
|
+
wordDiff?: WordDiffSegment[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiffHunk {
|
|
22
|
+
header: string;
|
|
23
|
+
oldStart: number;
|
|
24
|
+
oldCount: number;
|
|
25
|
+
newStart: number;
|
|
26
|
+
newCount: number;
|
|
27
|
+
context?: string;
|
|
28
|
+
lines: DiffLine[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DiffFile {
|
|
32
|
+
oldPath: string;
|
|
33
|
+
newPath: string;
|
|
34
|
+
status: FileStatus;
|
|
35
|
+
hunks: DiffHunk[];
|
|
36
|
+
additions: number;
|
|
37
|
+
deletions: number;
|
|
38
|
+
isBinary: boolean;
|
|
39
|
+
oldMode?: string;
|
|
40
|
+
newMode?: string;
|
|
41
|
+
similarityIndex?: number;
|
|
42
|
+
oldFileLineCount?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ParsedDiff {
|
|
46
|
+
files: DiffFile[];
|
|
47
|
+
stats: {
|
|
48
|
+
totalAdditions: number;
|
|
49
|
+
totalDeletions: number;
|
|
50
|
+
filesChanged: number;
|
|
51
|
+
};
|
|
52
|
+
}
|