[WIP] implement some functionality for commit language graph

bfcbfe59d9a93e837abcbfa2f22f330c1c2dba55

Tucker McKnight <tmcknight@instructure.com> | Sun Aug 02 2026

[WIP] implement some functionality for commit language graph

Adds "added" and "removed" fields to a commit, which has the number
of lines added and deleted in that commit hunk. This is different
from the length of the beforeText and afterText fields, since those
have newlines added to them to balance them out in the side-by-side
view. So the beforeText and afterText always have the same number
of lines in them. added and removed says what the original diff
sizes were.

This is still a WIP and I need to figure out how to have shared
language colors between this and the repo home graph, as well
as implement the "other" length. And also do something for the
average size of a commit -- a moving standard deviation?
js_templates/commits.ts:56
Before
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
88
89
90
91
92


93
94

95


96


97


98

99


100
101
102
103
104
105
106
107
108
109
110
111
112
      )
    ),
    m('div', {class: "container"}, data.patchPage.commits.map((commit) => {
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
      return [

⁣
⁣
⁣
⁣
        m('div', {class: "commit row py-2 my-2"}, [
          m('div', {class: "col-lg-auto col-12 commit-date"}, [
            m('span', {class: "date mb-1 me-2 font-monospace d-inline-block text-nowrap"}, date(commit.date).split(' ').slice(1).join(' ')),
            commit.isMerge ? m('span', {class: 'badge rounded-pill bg-warning me-1'}, 'merge') : null,
            // TODO: if this commit has any tags, list them here
            // m('span', {class: "bg-danger mb-1 badge rounded-pill d-inline-block"}, 'v1.2')
          ]),
⁣
⁣
          m('div', {class: "col-12 col-md"}, [
            m('div', {class: "row mb-1"},
              m('a', {href: nav.commit(commit.hash), class: "commit-msg"}, commit.message.split('\n').slice(0, 1))
            ),
            m('div', {class: "row mb-3 mb-lg-0"}, [
              m('div', {class: "col d-flex flex-direction-row align-items-center flex-wrap"}, [
                m('span', {class: "author me-2"}, commit.author),
                // TODO: if the user has a verified website, put it here
                // m('span', {class: "bg-primary badge rounded-pill me-2"}, 'tuckerm.us'),
                m('span', {class: "copy-sha"}, [
                  m('span', {class: "sha font-monospace me-1"}, commit.hash.slice(0, 6)),
                  m('a', {href: "#"}, 'Copy')
                ])
              ])
            ])
          ]),
          m('div', {class: "col col-lg-4"}, [
            m('div', {class: "row"}, [              m('div', {class: "col-auto language-names pe-1"}, [
⁣
                m('div', 'js'),
                m('div', 'css'),
                m('div', 'md'),
                m('div', 'other'),
              ]),
              m('div', {class: "col ps-0"}, [
                m('div', {class: "flex-grow-1 d-flex"}, [
⁣
⁣
                  m('div', {class: "lang-diff plus-1"}, '+510'),
                  m('div', {class: "lang-diff minus-1"}, '-245')
⁣
                ]),
⁣
⁣
                m('div', {class: "flex-grow-1 d-flex"}, [
⁣
⁣
                  m('div', {class: "lang-diff plus-2 text-white"}, '+314'),
⁣
⁣
                  m('div', {class: "lang-diff minus-2 text-white"}, '-275')
⁣
                ]),
⁣
⁣
                m('div', {class: "flex-grow-1 d-flex"}, [
                  m('div', {class: "lang-diff plus-3 text-white"}, '+45'),
                  m('div', {class: "lang-diff minus-3 text-white"}, '-20')
                ]),
                m('div', {class: "flex-grow-1 d-flex"}, [
                  m('div', {class: "lang-diff plus-4 text-white"}, '+890'),
                  m('div', {class: "lang-diff minus-4 text-white"}, '-789')
                ])
              ])
            ])
          ])
        ]),
      ]
    })),
After
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
88
89
90
91
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
135
136
137
138


139

140


141
142
143
144
      )
    ),
    m('div', {class: "container"}, data.patchPage.commits.map((commit) => {
      // Map of fileExtension => [lines_deleted, lines_added]
      const languageCounts = new Map<string, [number, number]>()

      commit.diffs.forEach((diff) => {
        const fileParts = diff.fileName.split(".")
        const fileExtension = fileParts[fileParts.length - 1]
        const currentCounts = languageCounts.get(fileExtension) || [0, 0]
        currentCounts[0] = currentCounts[0] + diff.removed
        currentCounts[1] = currentCounts[1] + diff.added
        languageCounts.set(fileExtension, currentCounts)
      })

      let totalDiffSizePerExtension = []
      // see which languages have the largest change
      // totalDiffSizePerExtension = [{extension: 'html', total: '50'}]
      for (const entry of languageCounts) {
        totalDiffSizePerExtension.push({ extension: entry[0], total: entry[1][0] + entry[1][1] })
      }

      totalDiffSizePerExtension = totalDiffSizePerExtension.toSorted((a, b) => {
        return b.total - a.total
      })

      const numOfTopLanguages = Math.min(3, totalDiffSizePerExtension.filter(a => a.total).length)
      const topLanguagePercentages = totalDiffSizePerExtension.slice(0, numOfTopLanguages)
      const otherLanguageDiffs = totalDiffSizePerExtension.slice(numOfTopLanguages).reduce((totals, current) => {
        totals[0] += languageCounts.get(current.extension)[0]
        totals[1] += languageCounts.get(current.extension)[1]
        return totals
      }, [0, 0])

      return [
        m('div', {class: "commit row py-2 my-2"}, [
          m('div', {class: "col-md-auto col-12 commit-date"}, [
            m('span', {class: "date mb-1 me-2 font-monospace d-inline-block text-nowrap"}, date(commit.date).split(' ').slice(1).join(' ')),
            commit.isMerge ? m('span', {class: 'badge rounded-pill bg-warning me-1'}, 'merge') : null,
            // TODO: if this commit has any tags, list them here
            // m('span', {class: "bg-danger mb-1 badge rounded-pill d-inline-block"}, 'v1.2')
          ]),
          m('div', { class: 'col' }, [
            m('div', { class: 'row' }, [
              m('div', {class: "col-12 col-md-8"}, [
                m('div', {class: "row mb-1"},
                  m('a', {href: nav.commit(commit.hash), class: "commit-msg"}, commit.message.split('\n').slice(0, 1))
                ),
                m('div', {class: "row mb-3 mb-lg-0"}, [
                  m('div', {class: "col d-flex flex-direction-row align-items-center flex-wrap"}, [
                    m('span', {class: "author me-2"}, commit.author),
                    // TODO: if the user has a verified website, put it here
                    // m('span', {class: "bg-primary badge rounded-pill me-2"}, 'tuckerm.us'),
⁣
                  ])
⁣
                ])
⁣
⁣
              ]),
              m('div', {class: "col-12 col-md-4 d-flex"}, [
                m('div', { class: "d-flex flex-column" }, topLanguagePercentages.map((topLangPercent) => {
                  return m('div', {class: 'font-monospace small',
⁣
                    style: 'height: 1rem;'
                  }, topLangPercent.extension)
⁣
⁣
⁣
                }).concat(otherLanguageDiffs[0] > 0 || otherLanguageDiffs[1] > 0
                  ? m('div', {
                      class: 'font-monospace small',
                      style: 'height: 1rem;'
                  }, `other ${otherLanguageDiffs[0]} ${otherLanguageDiffs[1]}`)
                  : null,
                )),
                m('div', {
                  class: "d-flex flex-column flex-grow-1"
                }, topLanguagePercentages.map((topLangPercent) => {
                  const langDiffs = languageCounts.get(topLangPercent.extension)
                  // TODO: make the average commit size adjustable
                  const adjustedLength = Math.min(langDiffs[0] + langDiffs[1], 100)
                  return m('div', {
                    style: `height: 1rem; width: ${adjustedLength}%; background: black;`
                  })
                }))
              ]),
            ]),
          ]),
          m('div', {class: 'col-12 col-md-auto'}, [
            m('div', {class: "input-group mb-2 flex-nowrap"}, [
⁣
⁣
              m('span', {class: "font-monospace input-group-text border-info text-white text-bg-dark overflow-scroll"}, commit.hash.slice(0, 6)),              m('button', {'data-copy-text': commit.hash, class: "btn btn-sm btn-info shadow-none copy-button"}, 'Copy')
⁣
⁣
            ])
          ]),
        ]),
      ]
    })),
src/dataTypes.ts:18
Before
17
18
19


20
21
    lineNumber: number,
    beforeText: string,
    afterText: string,
⁣
⁣
  }>
}
After
17
18
19
20
21
22
23
    lineNumber: number,
    beforeText: string,
    afterText: string,
    added: number,
    removed: number,
  }>
}
src/helpers.ts:51
Before
50
51
52

53
54
55
56
57
58


59
        if (str.startsWith(' ')) { return str.replace(" ", "") }
      }).join("\n")

⁣
      const changeObject = (lastHunkBefore.length > 1000 || lastHunkAfter.length > 1000)
        ? []
        : Diff.diffWordsWithSpace(lastHunkBefore, lastHunkAfter)
      let beforeText = ""
      let afterText = ""

⁣
⁣
      changeObject.forEach((obj) => {
        const numNewLines = obj.value.split('').filter(char => char === '\n').length
After
50
51
52
53
54
55
56
57
58
59
60
61
62
        if (str.startsWith(' ')) { return str.replace(" ", "") }
      }).join("\n")

      // TODO: don't split on \n again here, do this earlier? More efficiently?
      const changeObject = (lastHunkBefore.split('\n').length > 1000 || lastHunkAfter.split('\n').length > 1000)
        ? []
        : Diff.diffWordsWithSpace(lastHunkBefore, lastHunkAfter)
      let beforeText = ""
      let afterText = ""
      let added = 0
      let removed = 0

      changeObject.forEach((obj) => {
        const numNewLines = obj.value.split('').filter(char => char === '\n').length
src/helpers.ts:63
Before
62
63
64


65
66
67

68
69
        if (!obj.added && !obj.removed) {
          beforeText = beforeText + escape(obj.value)
          afterText = afterText + escape(obj.value)
⁣
⁣
        }
        if (obj.added) {
          afterText = afterText + "<mark>" + escape(obj.value) + "</mark>"
⁣
          if (numNewLines > 0) {
            let insertAt = beforeText.lastIndexOf("\n")
            insertAt = insertAt === -1 ? 0 : insertAt
After
62
63
64
65
66
67
68
69
70
71
72
        if (!obj.added && !obj.removed) {
          beforeText = beforeText + escape(obj.value)
          afterText = afterText + escape(obj.value)
          added += obj.value.split('\n').length
          removed += obj.value.split('\n').length
        }
        if (obj.added) {
          afterText = afterText + "<mark>" + escape(obj.value) + "</mark>"
          added += obj.value.split('\n').length
          if (numNewLines > 0) {
            let insertAt = beforeText.lastIndexOf("\n")
            insertAt = insertAt === -1 ? 0 : insertAt
src/helpers.ts:75
Before
74
75
76

77
78
        }
        if (obj.removed) {
          beforeText = beforeText + "<mark>" + escape(obj.value) + "</mark>"
⁣
          if (numNewLines > 0) {
            let insertAt = afterText.lastIndexOf("\n")
            insertAt = insertAt === -1 ? 0 : insertAt
After
74
75
76
77
78
79
        }
        if (obj.removed) {
          beforeText = beforeText + "<mark>" + escape(obj.value) + "</mark>"
          removed += obj.value.split('\n').length
          if (numNewLines > 0) {
            let insertAt = afterText.lastIndexOf("\n")
            insertAt = insertAt === -1 ? 0 : insertAt
src/helpers.ts:89
Before
88
89
90


91
92
        lineNumber: parseInt(lines[previousHunk].match(lineNumberRegex)[1]),
        beforeText,
        afterText,
⁣
⁣
      })
      previousFilename = ''
      previousHunk = index
After
88
89
90
91
92
93
94
        lineNumber: parseInt(lines[previousHunk].match(lineNumberRegex)[1]),
        beforeText,
        afterText,
        added,
        removed,
      })
      previousFilename = ''
      previousHunk = index