Use git log --format=raw for getting info about commit parents

c759cb9506dcb4d3cad3277ea864f42567dd3acb

Tucker McKnight <tucker@pangolin.lan> | Sun May 24 2026

Use git log --format=raw for getting info about commit parents

Go back to having each commit know what its own parent is; that is
how git actually works. Branches do not need their own lists of
commits.

Instead of figuring out what the parent of a commit is by checking
which one comes next in the `git log`, use `git log --format=raw`.
That will add a `parent` line for each commit, letting you know
exactly what the parent of that commit is.

The preceeding commit in the log is not always the parent commit.
git log will show them in chronological order based on when the
commit was created. Therefore, commits from different branches
are mixed in together in the chronological git log. Rely on
the `parent` field from `git log --format=raw` instead.
src/dataTypes.ts:14
Before
13
14
15

16
17
  message: string,
  isMerge: boolean,
  author: string,
⁣
  date: Date,
  diffs: Array<{
    fileName: string,
After
13
14
15
16
17
18
  message: string,
  isMerge: boolean,
  author: string,
  parent: string | null,
  date: Date,
  diffs: Array<{
    fileName: string,
src/dataTypes.ts:47
Before
46
47
48
49
50
51
    // to query for the file contents or to search around the existing file list
    // to see if a copy is already available.
    fileList: Map<string, {fileInfo: FileInfo}>,
    commits: Array<Commit>,
  }>,
  tags?: Array<{
    name: string,
After
46
47
48

49
50
    // to query for the file contents or to search around the existing file list
    // to see if a copy is already available.
    fileList: Map<string, {fileInfo: FileInfo}>,
⁣
  }>,
  tags?: Array<{
    name: string,
src/vcses/git/operations.ts:51
Before
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
export const addBranchToCommitsMap = async(branchName: string, repoLocation: string, commits: Repository['commits']): Promise<void> => {
  const totalPatchesCountRes = await exec(`git -C ${repoLocation} rev-list --count ${branchName}`)
  const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
  let previousHash: null | string = null
  let gitLogSubsetCmd = null
  let gitLogSubset = []
  return addBranchToCommitsMapWithSkip(branchName, repoLocation, commits, totalPatchesCount)
}

export const addBranchToCommitsMapWithSkip = async(branchName: string, repoLocation: string, commits: Repository['commits'], totalPatchesCount: number, skip = 0, previousHash = null): Promise<void> => {
  console.log(`addBranchToCommitsMap, skip: ${skip}, total: ${totalPatchesCount}`)
  return new Promise((resolve) => {
    if (skip >= totalPatchesCount) {
      return resolve()
    }

    // const gitLogSubsetRes = await exec(`git -C ${repoLocation} log ${branchName} -p -n 10 --skip ${i}`)
    const gitLogSubsetCmd = childProcess.spawn('git', ['-C', repoLocation, 'log', branchName, '-p', '-n', '10', '--skip', skip.toString()], {
      stdio: [0, "pipe", "inherit"],
    })
    // let gitLogSubset = gitLogSubsetRes.stdout.split("\n")
    let gitLogSubset = []
    gitLogSubsetCmd.stdout.on('data', (data) => {
      gitLogSubset.push(data)
    })

    gitLogSubsetCmd.on('close', () => {
      previousHash = addGitLogSubsetToMap(gitLogSubset.join('').split('\n'), commits, previousHash)
      return addBranchToCommitsMapWithSkip(branchName, repoLocation, commits, totalPatchesCount, skip + 10, previousHash).then(() => {
        return resolve()
      })
    })
  })
}

const addGitLogSubsetToMap = (gitLogSubset: string[], commits: Repository['commits'], previousHash: string | null) => {
  do {
    const nextPatchStart = gitLogSubset.findIndex((line, index) => {
      return (index > 0 && line.startsWith("commit ")) || index === gitLogSubset.length - 1
After
50
51
52

53
54
55
56
57
58

59
60
61
62
63

64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
export const addBranchToCommitsMap = async(branchName: string, repoLocation: string, commits: Repository['commits']): Promise<void> => {
  const totalPatchesCountRes = await exec(`git -C ${repoLocation} rev-list --count ${branchName}`)
  const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
⁣
  let gitLogSubsetCmd = null
  let gitLogSubset = []
  return addBranchToCommitsMapWithSkip(branchName, repoLocation, commits, totalPatchesCount)
}

export const addBranchToCommitsMapWithSkip = async(branchName: string, repoLocation: string, commits: Repository['commits'], totalPatchesCount: number, skip = 0): Promise<void> => {
⁣
  return new Promise((resolve) => {
    if (skip >= totalPatchesCount) {
      return resolve()
    }

⁣
    const gitLogSubsetCmd = childProcess.spawn('git', ['-C', repoLocation, 'log', branchName, '--format=raw', '-p', '-n', '10', '--skip', skip.toString()], {
      stdio: [0, "pipe", "inherit"],
    })

    let gitLogSubset = []
    gitLogSubsetCmd.stdout.on('data', (data) => {
      gitLogSubset.push(data)
    })

    gitLogSubsetCmd.on('close', () => {
      addGitLogSubsetToMap(gitLogSubset.join('').split('\n'), commits)
      return addBranchToCommitsMapWithSkip(branchName, repoLocation, commits, totalPatchesCount, skip + 10).then(() => {
        return resolve()
      })
    })
  })
}

const addGitLogSubsetToMap = (gitLogSubset: string[], commits: Repository['commits']) => {
  do {
    const nextPatchStart = gitLogSubset.findIndex((line, index) => {
      return (index > 0 && line.startsWith("commit ")) || index === gitLogSubset.length - 1
src/vcses/git/operations.ts:93
Before
92
93
94
95
96
97
98

99
100
101
102
103
104
105
106

107


108
109
110


111
112
113
114



115
116
117
118

119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
    gitLogSubset = gitLogSubset.slice(nextPatchStart)

    const hash = currentPatch[0].replace("commit ", "").trim()
    console.log(`doing ${hash}`)
    // set the parent hash of the previous commit, unless we're on the first commit
    if (previousHash !== null) {
      commits.get(previousHash)['parent'] = hash
⁣
    }
    previousHash = hash

    // exit early if the commits map already includes this hash
    if (commits.has(hash)) {
      return
    }

⁣
    let author: string, date: string, isMerge: boolean
⁣
⁣
    [1, 2, 3].forEach((lineNumber) => {
      if (currentPatch[lineNumber].startsWith("Author")) {
        author = currentPatch[lineNumber].replace("Author: ", "").trim()
⁣
⁣
      }
      else if (currentPatch[lineNumber].startsWith("Date")) {
        date = currentPatch[lineNumber].replace("Date: ", "").trim()
      }
⁣
⁣
⁣
      else if (currentPatch[lineNumber].startsWith("Merge")) {
        isMerge = true
      }
    })
⁣
    let diffStart = currentPatch.findIndex((line) => {
      return line.startsWith("diff ")
    })
    // If no line starts with "diff", this
    // is probably a mege commit. Use the last
    // line of the patch + 1, in that case, to just get the full
    // text of the commit
    let messageStart = 4
    if (diffStart === -1) {
      messageStart = 5
      diffStart = currentPatch.length
    }
    // Git log is indent four spaces by default -- remove those.
    const commitMessage = currentPatch.slice(messageStart, diffStart).map(str => str.replace("    ", "")).filter((line) => {
      // git log --porcelain output adds these "Ignore-this:" lines and I'm not sure what they are
      return !line.startsWith('Ignore-this: ')
    }).join("\n").trim()
After
92
93
94
95
96

97
98
99
100

101
102
103


104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121

122
123
124
125
126
127




128

129
130
131
132
133
134
    gitLogSubset = gitLogSubset.slice(nextPatchStart)

    const hash = currentPatch[0].replace("commit ", "").trim()

    // exit early if the commits map already includes this hash
⁣
    if (commits.has(hash)) {
      return
    }

⁣
    const nextBlankLine = currentPatch.findIndex(line => line === "") || currentPatch.length
    const commitInfoSection = currentPatch.slice(0, nextBlankLine)

⁣
⁣
    let author: string, date: string
    let isMerge = false
    let parent: string | null = null

    commitInfoSection.forEach((commitInfoLine) => {
      if (commitInfoLine.startsWith("author")) {
        const lineWords = commitInfoLine.replace("author ", "").trim().split(' ')
        author = lineWords.slice(0, lineWords.length - 2).join(' ')
        date = lineWords[lineWords.length - 2]
      }
      if (commitInfoLine.startsWith("parent")) {
        parent = commitInfoLine.replace("parent ", "").trim()
      }
    })

    // Mark as a merge if more than one line starts with "parent"
    if (commitInfoSection.filter(line => line.startsWith("parent")).length > 1) {
      isMerge = true
⁣
    }

    let diffStart = currentPatch.findIndex((line) => {
      return line.startsWith("diff ")
    })

⁣
⁣
⁣
⁣
    if (diffStart === -1) {
⁣
      diffStart = currentPatch.length
    }
    // Git log is indent four spaces by default -- remove those.
    const commitMessage = currentPatch.slice(nextBlankLine + 1, diffStart).map(str => str.replace("    ", "")).filter((line) => {
      // git log --porcelain output adds these "Ignore-this:" lines and I'm not sure what they are
      return !line.startsWith('Ignore-this: ')
    }).join("\n").trim()
src/vcses/git/operations.ts:139
Before
138
139
140
141
142
143

144
145
146
147
148
149
150
151
152
    commits.set(hash, {
      hash,
      message: commitMessage,
      isMerge: isMerge || false,
      author,
      date: new Date(date),
⁣
      diffs,
      parent: null,
      cachedFiles: new Map(),
    })
  } while (gitLogSubset.length > 1)

  return previousHash
}

export type BlameInfo = {
After
138
139
140
141
142
143
144
145
146
147
148
149


150
151
    commits.set(hash, {
      hash,
      message: commitMessage,
      isMerge: isMerge,
      author,
      date: new Date(parseInt(date) * 1000), // git timestamp is number of seconds,
                                             // js Date() exects milliseconds
      diffs,
      parent,
      cachedFiles: new Map(),
    })
  } while (gitLogSubset.length > 1)
⁣
⁣
}

export type BlameInfo = {