pr-check / src / parsers / parseGitShowOutput.ts
parseGitShowOutput.ts
Raw
import { CommitDiff } from '../interfaces/CommitDiff';
import { FileDiff, Line } from '../interfaces/FileDiff';

/**
 * The output of `git show` is a string that looks like this:
 * 
 * commit 9fceb02d0ae598e95dc970b74767e84987207e60 (HEAD -> main, origin/main)
 * Author: John Doe <john.doe@example.com>
 * Date:   Mon Nov 15 08:37:41 2023 -0800
 * 
 *     Fix a bug and improve documentation
 * 
 * diff --git a/file1.txt b/file1.txt
 * index 7f77f51..9c54642 100644
 * --- a/file1.txt
 * +++ b/file1.txt
 * @@ -1,2 +1,2 @@
 * -Hello, world!
 * +Hello, Git!
 * 
 * diff --git a/file2.txt b/file2.txt
 * index 8ad1c76..f414f31 100644
 * --- a/file2.txt
 * +++ b/file2.txt
 * @@ -1,3 +1,4 @@
 *  Hello, world!
 * +This is a new line.
 * 
 * diff --git a/documentation.txt b/documentation.txt
 * index 553c207..762f61c 100644
 * --- a/documentation.txt
 * +++ b/documentation.txt
 * @@ -4,6 +4,7 @@ Title: Documentation
 *  - Section 1
 *  - Section 2
 *  - Section 3
 * +- Section 4
 */

/**
 * Generates an object that represents the output of `git show`.
 * @param showString The output of `git show`
 * @returns An object that represents the output of `git show`
 */
function parseGitCommitShowOutput(showString: string): CommitDiff {
  exitIfGitShowOutputEmpty(showString);

  // Given the format of the output of `git show`, we can split the string by '\n\n'
  // to get the header, title and message, and the diff chunks.
  const componentsOfShowString = showString.split('\n\n');

  const header = componentsOfShowString[0];
  const titleAndMessage = componentsOfShowString[1];
  const rawDiffStrings = componentsOfShowString.slice(2);

  const {
    commitHash,
    author,
    date,
  } = getHeaderFieldsOfGitShowOutput(header);

  const {
    title,
    message,
  } = getTitleAndMessageOfGitShowOutput(titleAndMessage);

  const validFileDiffStrings = rawDiffStrings.filter(isDiffValidForInspection);
  const fileDiffs = validFileDiffStrings.map(parseFileDiff);

  return {
    commitHash,
    author,
    date,
    title,
    message,
    diffs: fileDiffs
  };
}

/**
 * Generates an object that represents the header of the output of `git show`.
 * The output of `git show` must be in the following format:
 * 
 * commit 9fceb02d0ae598e95dc970b74767e84987207e60 (HEAD -> main, origin/main)
 * Author: John Doe <john.doe@example.com>
 * Date:   Mon Nov 15 08:37:41 2023 -0800
 */
function getHeaderFieldsOfGitShowOutput(showStringHeader: string) {
  let lines = showStringHeader.split('\n');
  lines = lines.map(line => line.trim()).filter(line => line !== '');
  
  const commitHash = lines[0].split(' ')[1];

  const START_OF_AUTHOR_LINE = "Author:";
  let author = "could not find author"
  const authorLine = lines[1];
  const position = authorLine.indexOf(START_OF_AUTHOR_LINE);

  if (position !== -1) {
    author = authorLine.substring(position + START_OF_AUTHOR_LINE.length).trim();
  }

  const START_OF_DATE_LINE = "Date:";
  let date = "could not find date"
  const dateLine = lines[2];
  const datePosition = dateLine.indexOf(START_OF_DATE_LINE);

  if (datePosition !== -1) {
    date = dateLine.substring(datePosition + START_OF_DATE_LINE.length).trim();
  }

  return {
    commitHash,
    author,
    date
  };
}

/**
 * Generates an object that represents the title and message in the output of `git show`.
 * @param titleAndMessage The string containing the title and message in the output of `git show`.
 */
function getTitleAndMessageOfGitShowOutput(titleAndMessage: string) {
  let lines = titleAndMessage.split('\n');
  lines = lines.map(line => line.trim());
  const title = lines[0];
  const message = lines.slice(1).join('\n');
  return {
    title,
    message
  };
}

/**
 * Generates an object for a git diff chunk.
 * @param diffString A string that represents a git diff chunk.
 * @returns An object that represents a git diff chunk.
 */
function parseFileDiff(fileDiff: string): FileDiff {
  if (fileDiff === "" || !fileDiff.startsWith("diff")) {
    throw new Error("Invalid diff chunk. The diff chunk should be a non-empty string, and start with the word 'diff'.");
  }

  let lines = fileDiff.split('\n');
  
  const componentsOfFirstLine = lines[0].split(' ');
  const filePath = componentsOfFirstLine[2]
  const fileName = filePath.split('/').at(-1)
  if (!fileName) {
    throw new Error("Invalid diff chunk. No file name found.");
  }

  const hunks = getDiffHunks(fileDiff);

  const linesFromAllHunks: Line[] = [];
  for (const hunk of hunks) {
    const hunkLines = parseDiffHunk(hunk);
    linesFromAllHunks.push(...hunkLines);
  }

  return {
    fileName,
    lines: linesFromAllHunks
  };
}

/**
 * Takes a string with one or more git diff hunks,
 * and returns each hunk as a string in a string array.
 * Within each file diff, there is one or more diff hunks.
 * Each diff hunk starts with a line that starts with '@@'.
 */
function getDiffHunks(fileDiff: string): string[] {
  const hunkArrays: string[][] = [];
  let currHunk: string[] | null = null;
  const lines = fileDiff.split('\n');
  for (const line of lines) {
    // If we encounter @@, start a new hunk and push current hunk to hunks array
    // If we encounter anything else, add it to the current hunk
    if (line.startsWith("@@")) {
      currHunk = [line]
      hunkArrays.push(currHunk);
    }
    else if (currHunk === null) {
      continue
    } else {
      currHunk.push(line);
    }
  }

  const hunkStrings = hunkArrays.map(hunk => hunk.join('\n'));
  
  return hunkStrings
}

/**
 * Within each file diff, there is one or more diff hunks.
 * Each diff hunk starts with a line that starts with '@@'.
 */
function parseDiffHunk(diffHunk: string): Line[] {  
  const lines = diffHunk.split('\n');
  const hunkHeader = lines[0]
  const hunkHeaderParts = hunkHeader.trim().split(' ');
  
  const startLineNum = parseInt(hunkHeaderParts[2].split(',')[0].slice(1), 10);

  // Get the diff lines and calculate the ending output line
  let diffLineStrings = lines.slice(1);
  diffLineStrings = adjustIndentation(diffLineStrings);
  diffLineStrings = diffLineStrings.filter(line => !(line.trim() == ""));

  const hunkLines = diffLineStrings.map((line, index) => {
    return {
      lineNum: startLineNum + index,
      content: line
    }
  });

  return hunkLines;
}

function isDiffValidForInspection(diffString: string): boolean {
  // Only diffs with a '+++' line are valid for inspection.
  // Otherwise, they are diffs for empty files, which we don't want to inspect.
  return diffString.includes("+++")
}

function adjustIndentation(lines: string[]): string[] {
  // Find the minimum indentation across all lines
  const minIndentation = Math.min(
    ...lines
      .filter(line => line.trim())  // Ignore empty lines
      .map(line => line.search(/\S/))  // Find the position of the first non-whitespace character
  );

  // Subtract this minimum indentation from each line
  return lines.map(line => {
    // Only adjust lines that are not empty
    if (line.trim()) {
      return line.slice(minIndentation);
    } else {
      return line;  // Return empty lines unmodified
    }
  });
}

// Throw error and exit if git show output is empty
function exitIfGitShowOutputEmpty(gitShowString: string) {
  if (gitShowString === "") {
    throw new Error("Git show output empty. Exiting.");
  }
}

export { parseGitCommitShowOutput }