您的位置:首页 > 其它

如何实现一个 Git Diff 解析器

2017-05-19 14:36 1516 查看

代码审阅中一个重要功能是对两个 commit 进行 diff 并展示到页面中,这篇文章将尝试总结其实现过程。

解析 Git Diff

想要展示 diff,首先需要将 Git 提供的 diff 格式解析成结构化数据(比如:JSON)。

基本格式

一个基本的 Git Diff 格式如下:

  1. diff --git a/f1 b/f1

  2. index 6f8a38c..449b072 100644

  3. --- a/f1

  4. +++ b/f1

  5. @@ -1,7 +1,7 @@

  6. 1

  7. 2

  8. 3

  9. -a

  10. +b

  11. 5

  12. 6

  13. 7

第一行是 Git Diff 的 header,进行比较的是 a 版本的 f1(变动前)和 b 版本的 f1(变动后)。

第二行是两个版本的 hash 值以及文件模式(100644 表示是文本文件)。

第三、四行表示进行比较的两个文件, --- 表示变动前的版本, +++ 表示变动后的版本。

第五行是一个 thunk header(可能会有多个),提供变动的”上下文“(context), -1,7 表示接下来展示变动前文件第一至第七行, +1,7 表示接下来展示变动后文件第一至第七行。

接下来的几行就是具体的变动内容。它将两个文件的上下文合并显示在一起,每一行前面是一个标志位, ''(空)表示无变化(是一个上下文行)、 - 表示变动前文件删除的行、 + 表示变动后文件新增的行。可以看出此次变动,文件 f1 的第 4 行内容从 a 变为了 b

扩展 header

在第一行 header 之后有可能包含如下的几种扩展 header:

  1. old mode <mode>

  2. new mode <mode>

  3. deleted file mode <mode>

  4. new file mode <mode>

  5. copy from <path>

  6. copy to <path>

  7. rename from <path>

  8. rename to <path>

  9. similarity index <number>

  10. dissimilarity index <number>

  11. index <hash>..<hash> <mode>

新增、删除、复制、重命名

新增、删除、复制、重命名文件的 Git Diff 格式有些不同,解析时需要特别注意。

新增:

  1. diff --git a/file b/file

  2. new file mode 100644

  3. index 0000000..53bffd7

  4. --- /dev/null

  5. +++ b/file

删除:

  1. diff --git a/file b/file

  2. deleted file mode 100644

  3. index 53bffd7..0000000

  4. --- a/file

  5. +++ /dev/null

复制:

  1. diff --git a/a b/b

  2. copy from a

  3. copy to b

  4. --- a/a

  5. +++ b/b

重命名:

  1. diff --git a/a b/b

  2. rename from a

  3. rename to b

  4. --- a/a

  5. +++ b/b

在新增和删除时, diff--git header 中的两个文件名是一样的,我们需要查看 ---+++ 中的信息,新增或者删除的文件会使用 /dev/null 来表示。

二进制

在 Git Diff 中的二进制文件并不会给出细节(也没法给),而是使用下面的格式来进行表示:

  1. diff --git a/img.png b/img.png

  2. index 268373a..f07dd4c 100644

  3. Binary files a/img.png and b/img.png differ

解析

了解了 Git Diff 格式以后,对其进行解析就比较简单了。我们只需要一行一行的进行解析,做些正则匹配、抽取的工作即可。

  1. for (let i = 0, len = lines.length; i < len; i++) {

  2.  const line = lines[i];

  3.  let values;

  4.  if (values = /^diff --git "?(.+)"? "?(.+)"?/.exec(line)) {

  5.    startFile();

  6.    file.fromName = parseFile(values[1]);

  7.    file.toName = parseFile(values[2]);

  8.    continue;

  9.  }

  10.  if (line.indexOf('--- ') == 0) {

  11.    if (!file.oldName) {

  12.      file.oldName = parseFile(line.slice(4));

  13.    }

  14.    continue;

  15.  }

  16.  if (line.indexOf('+++ ') == 0) {

  17.    if (!file.newName) {

  18.      file.newName = parseFile(line.slice(4));

  19.    }

  20.    continue;

  21.  }

  22.  // hunk header

  23.  if (values = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@.*/.exec(line)) {

  24.    startBlock(line, +values[1], +values[2]);

  25.    continue;

  26.  }

  27.  if (line.indexOf('+') == 0 || line.indexOf('-') == 0 || line.indexOf(' ') == 0) {

  28.    createLine(line);

  29.    continue;

  30.  }

  31.  if (values = /^old mode (\d{6})/.exec(line)) {

  32.    file.oldMode = +values[1];

  33.  } else if (values = /^new mode (\d{6})/.exec(line)) {

  34.    file.newMode = +values[1];

  35.  } else if (values = /^deleted file mode (\d{6})/.exec(line)) {

  36.    file.deletedFileMode = +values[1];

  37.    file.isDeleted = true;

  38.  } else if (values = /^new file mode (\d{6})/.exec(line)) {

  39.    file.newFileMode = + values[1];

  40.    file.isNew = true;

  41.  } else if (values = /^copy from "?(.+)"?/.exec(line)) {

  42.    if (!file.oldName) {

  43.      file.oldName = values[1];

  44.    }

  45.    file.isCopy = true;

  46.  } else if (values = /^copy to "?(.+)"?/.exec(line)) {

  47.    if (!file.newName) {

  48.      file.newName = values[1];

  49.    }

  50.    file.isCopy = true;

  51.  } else if (values = /^rename from "?(.+)"?/.exec(line)) {

  52.    if (!file.oldName) {

  53.      file.oldName = values[1];

  54.    }

  55.    file.isRename = true;

  56.  } else if (values = /^rename to "?(.+)"?/.exec(line)) {

  57.    if (!file.newName) {

  58.      file.newName = values[1];

  59.    }

  60.    file.isRename = true;

  61.  } else if (values = /^index ([0-9a-z]+)\.\.([0-9a-z]+)\s*(\d{6})?/.exec(line)) {

  62.    file.checksumBefore = values[1];

  63.    file.checksumAfter = values[2];

  64.    if (values[3]) {

  65.      file.mode = +values[3];

  66.    }

  67.  } else if (values = /^similarity index (\d+)%/.exec(line)) {

  68.    file.unchangedPercentage = values[1];

  69.  } else if (values = /^dissimilarity index (\d+)%/.exec(line)) {

  70.    file.changedPercentage = values[1];

  71.  } else if (values = /^Binary files (.*) and (.*) differ/.exec(line)) {

  72.    file.isBinary = true;

  73.    file.oldName = parseFile(values[1]);

  74.    file.newName = parseFile(values[2]);

  75.    startBlock('Binary file');

  76.  } else if (values = /^GIT binary patch/.exec(line)) {

  77.    file.isBinary = true;

  78.    startBlock(line);

  79.  } else {

  80.    if (values = /^index ([0-9a-z]+),([0-9a-z]+)\.\.([0-9a-z]+)/.exec(line)) {

  81.      file.checksumBefore = [values[2], values[3]];

  82.      file.checksumAfter = values[1];

  83.    } else if (values = /^mode (\d{6}),(\d{6})\.\.(\d{6})/.exec(line)) {

  84.      file.oldMode = [values[2], values[3]];

  85.      file.newMode = values[1];

  86.    } else if (values = /^new file mode (\d{6})/.exec(line)) {

  87.      file.newFileMode = +values[1];

  88.      file.isNew = true;

  89.    } else if (values = /^deleted file mode (\d{6}),(\d{6})/.exec(line)) {

  90.      file.deletedFileMode = +values[1];

  91.      file.isDeleted = true;

  92.    }

  93.  }

  94. }

  95. saveBlock();

  96. saveFile();

对最开始的 Git Diff 进行解析:

  1. diff --git a/f1 b/f1

  2. index 6f8a38c..449b072 100644

  3. --- a/f1

  4. +++ b/f1

  5. @@ -1,7 +1,7 @@

  6. 1

  7. 2

  8. 3

  9. -a

  10. +b

  11. 5

  12. 6

  13. 7

可以得到如下的 JSON 结构(省略了一些字段):

  1. [

  2.  {

  3.    type: 'changed',

  4.    oldName: 'f1',

  5.    newName: 'f1',

  6.    checksumBefore: '6f8a38c',

  7.    checksumAfter: '449b072',

  8.    mode: 100644,

  9.    isBinary: false,

  10.    blocks: [

  11.      {

  12.        header: '@@ -1,7 +1,7 @@',

  13.        lnOld: 8,

  14.        lnNew: 8,

  15.        lines: [

  16.          { type: 'cntx', prefix: ' ', content: '1', newLn: 1, oldLn: 1 },

  17.          { type: 'cntx', prefix: ' ', content: '2', newLn: 2, oldLn: 2 },

  18.          { type: 'cntx', prefix: ' ', content: '3', newLn: 3, oldLn: 3 },

  19.          { type: 'del',  prefix: '-', content: 'a', newLn: 0, oldLn: 4 },

  20.          { type: 'ins',  prefix: '+', content: 'b', newLn: 4, oldLn: 0 },

  21.          { type: 'cntx', prefix: ' ', content: '5', newLn: 5, oldLn: 5 },

  22.          { type: 'cntx', prefix: ' ', content: '6', newLn: 6, oldLn: 6 },

  23.          { type: 'cntx', prefix: ' ', content: '7', newLn: 7, oldLn: 7 }

  24.        ]

  25.      }

  26.    ]

  27.  }

  28. ]

对行进行 Diff

得到上面的 JSON 后,实际上我们已经可以将改动渲染为 HTML 了。但是,我们还想对其进行一个优化,那就是我们希望对行与行进行一个 Diff 并进行高亮,从而可以让用户更详细的知道相关的行与行之间的变更点,像下面这样的效果:

决定哪些行需要 Diff

我们想要比较的是那些”修改“过的行,所以,删除的行、新增的行应该保持原样。整个过程如下所示:

  1. blocks = blocks.map(block => {

  2.  let lines = [];

  3.  let oldLines = [];

  4.  let newLines = [];

  5.  block.lines.forEach(line => {

  6.    if (line.type != TYPES.added && (newLines.length > 0 || (line.type != TYPES.deleted && oldLines.length > 0))) {

  7.      diffLine();

  8.    }

  9.    if (line.type == TYPES.normal) {

  10.      lines.push(line);

  11.    } else if (line.type == TYPES.added && oldLines.length == 0) {

  12.      lines.push(line);

  13.    } else if (line.type == TYPES.deleted) {

  14.      oldLines.push(line);

  15.    } else if (line.type == TYPES.added && oldLines.length != 0) {

  16.      newLines.push(line);

  17.    } else {

  18.      diffLine();

  19.    }

  20.  });

  21.  diffLine();

  22.  block.lines = lines;

  23.  return block;

  24. });

  25. function diffLine() {

  26.  const common = Math.min(oldLines.length, newLines.length);

  27.  for (let i = 0; i < common; i += 1) {

  28.    const oldLine = oldLines[i];

  29.    const newLine = newLines[i];

  30.    const diff = diffMatchPatch.diff_main(oldLine.content, newLine.content);

  31.    if (diff && diff.length) {

  32.      oldLine.diff = [];

  33.      newLine.diff = [];

  34.      diff.forEach(item => {

  35.        if (item[0] == DiffMatchPatch.DIFF_INSERT) {

  36.          newLine.diff.push({'tag': TYPES.added, 'content': item[1]});

  37.        } else if (item[0] == DiffMatchPatch.DIFF_DELETE) {

  38.          oldLine.diff.push({'tag': TYPES.deleted, 'content': item[1]});

  39.        } else {

  40.          oldLine.diff.push({'tag': '', 'content': item[1]});

  41.          newLine.diff.push({'tag': '', 'content': item[1]});

  42.        }

  43.      });

  44.    }

  45.    lines.push(oldLine);

  46.    lines.push(newLine);

  47.  }

  48.  lines = lines.concat(oldLines.slice(common));

  49.  lines = lines.concat(newLines.slice(common));

  50.  oldLines = [];

  51.  newLines = [];

  52. }

我们还是使用最开始的 Git Diff,在进行行之间的 Diff 后,会得到这样的 JSON 结构:

  1. [

  2.  {

  3.    type: 'changed',

  4.    oldName: 'f1',

  5.    newName: 'f1',

  6.    checksumBefore: '6f8a38c',

  7.    checksumAfter: '449b072',

  8.    mode: 100644,

  9.    isBinary: false,

  10.    blocks: [

  11.      {

  12.        header: '@@ -1,7 +1,7 @@',

  13.        lnOld: 8,

  14.        lnNew: 8,

  15.        lines: [

  16.          { type: 'cntx', prefix: ' ', content: '1', newLn: 1, oldLn: 1 },

  17.          { type: 'cntx', prefix: ' ', content: '2', newLn: 2, oldLn: 2 },

  18.          { type: 'cntx', prefix: ' ', content: '3', newLn: 3, oldLn: 3 },

  19.          { type: 'del',  prefix: '-', content: 'a', newLn: 0, oldLn: 4, diff: [{tag: 'del', content: 'a'}] },

  20.          { type: 'ins',  prefix: '+', content: 'b', newLn: 4, oldLn: 0, diff: [{tag: 'ins', content: 'b'}] },

  21.          { type: 'cntx', prefix: ' ', content: '5', newLn: 5, oldLn: 5 },

  22.          { type: 'cntx', prefix: ' ', content: '6', newLn: 6, oldLn: 6 },

  23.          { type: 'cntx', prefix: ' ', content: '7', newLn: 7, oldLn: 7 }

  24.        ]

  25.      }

  26.    ]

  27.  }

  28. ]

比较过的行会拥有 diff 字段,表示 Diff 的详细信息。

Diff 算法

上面过程的关键是 diffMatchPatch.diff_main 方法,这个方法会比较两个字符串,生成 Diff 信息。

比如: diffMatchPatch.diff_main('Apples are a fruit','Bananas are also fruit') 会得到如下结果:

  1. /* -1 表示删除

  2. *  1 表示插入

  3. *  0 表示相等

  4. */

  5. [

  6.  [ -1, 'Apple' ],

  7.  [ 1, 'Banana' ],

  8.  [ 0, 's are a' ],

  9.  [ 1, 'lso' ],

  10.  [ 0, ' fruit' ]

  11. ]

Diff 的本质实际上是一个 LCS(Longest Common Subsequece)问题,最基本的算法具有 O(MxN) 的复杂度(M、N 分别是两个字符串的长度),可以参考:https://en.wikipedia.org/wiki/Longest_common_subsequence_problem

原始算法在遇到超长字符串时就会特别慢。比如:Diff 到构建后的代码、写到 HTML 中的一行内嵌脚本,这些代码基本都是一行很长的代码,在进行 Diff 时需要运行很长时间,经常发生超时然后无法 Diff 问题。

优化

当然,我们是有办法来进行优化的。这篇文章:https://neil.fraser.name/writing/diff/ 总结的非常完整。这里我简单进行介绍,大家感兴趣可以直接阅读原文。

相等判断

如果两个字符串相等,直接就不需要再 Diff 了,这个应该是很清楚的:

  1. if (text1 == text2) return null;

相同前缀、后缀判断

在比较两个字符串时,有很多时候会有相同的前缀、后缀,先将这些前缀、后缀找到,会缩短最后实际需要 Diff 的内容。

比如下面的两个字符串:

  1. Text 1: The cat in the hat.

  2. Text 2: The dog in the hat.

在进行前缀、后缀判断后,可以缩短到对下面的字符串进行 Diff:

  1. Text 1: cat

  2. Text 2: dog

在搜索前缀、后缀时我们可以使用二分查找来进一步加速,可以有 O(log n) 的复杂度。

单纯的插入、删除

仅仅做了插入或者删除是变更中很常见的情况。比如下面的文本:

  1. Text 1: The cat in the hat.          |  Text 1: The cat in the hat.

  2. Text 2: The furry cat in the hat.    |  Text 2: The cat.

剔除前缀、后缀后得到:

  1. Text 1:                              |  Text 1:  in the hat

  2. Text 2: furry                        |  Text 2:

Text1 中的空字符串表示 Text2 中的内容就是一个插入,插入后可以得到 Text2

Text2 中的空字符串表示 Text1 中的内容就是一个删除,删除后可以得到 Text2

子串包含

考虑下面的文本:

  1. Text 1: The cat in the hat.

  2. Text 2: The happy cat in the black hat.

剔除前缀、后缀后得到:

  1. Text 1: cat in the

  2. Text 2: happy cat in the black

这时如果 Text1Text2 的子串,不进行 Diff 算法,我们就已经知道 Diff 信息了,必然是在子串的前、后插入了内容。

Diff 优化

在进行上面的一些前置操作后,就可以进行实际的 Diff 了。

基本的算法会比较耗时,实际上有一篇论文专门提出了优化过的算法,算法过程比较复杂,还有各种数学证明,这里就不详细说明了,感兴趣的可以参考:http://www.xmailserver.org/diff2.pdf

更好的方法

实际上进行了这种种的优化,我们再回到最初的需求:想让用户能更好的 Diff 代码。

但是,那些一行非常长的代码是真正的代码吗?实际中遇到有内嵌在 HTML 中的一行脚本有一万多个字符。。

当一行代码超过 80 个字符已经是超长了(而且应该已经违反团队的编码规范),对于这种超长代码实际上我们完全可以跳过行 Diff:

  1. const threshold = 80;

  2. const len = Math.max(oldLine.content.length, newLine.content.length);

  3. if (len <= threshold) {

  4.  const diff = diffMatchPatch.diff_main(oldLine.content, newLine.content);

  5.  // ...

  6. }

所以,在仔细分析、理解需求后,很多事情,其实根本不用去做

参考资料

题图:https://unsplash.com/photos/pi_Ju6KoQIc By @Tran Mau Tri Tam


内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: