Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions app/git/GitCommandRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const { execFile } = require("child_process")

const DEFAULT_TIMEOUT = 30000
const DEFAULT_MAX_BUFFER = 1024 * 1024 * 24

function normalizeExecError(error, stderr) {
if (!error) return null

if (error.code === "ENOENT") {
return "Git is not installed or is not available in PATH"
}

return (stderr || error.message || String(error)).trim()
}

function runGit(cwd, args, options = {}) {
return new Promise((resolve) => {
execFile(
"git",
args,
{
cwd,
timeout: options.timeout ?? DEFAULT_TIMEOUT,
maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
windowsHide: true
},
(error, stdout, stderr) => {
const result = {
success: !error,
stdout: stdout || "",
stderr: stderr || "",
error: normalizeExecError(error, stderr),
code: error?.code ?? 0
}

if (options.allowFailure) {
resolve(result)
return
}

resolve(result)
}
)
})
}

async function assertGitAvailable(cwd) {
const result = await runGit(cwd, ["--version"])

if (!result.success) {
throw new Error(result.error || "Git is not available")
}

return result.stdout.trim()
}

async function getRepositoryRoot(cwd) {
const result = await runGit(cwd, ["rev-parse", "--show-toplevel"])

if (!result.success) {
throw new Error("Current project is not a Git repository")
}

return result.stdout.trim()
}

module.exports = {
runGit,
assertGitAvailable,
getRepositoryRoot
}
159 changes: 159 additions & 0 deletions app/git/GitDiffService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const fs = require("fs")
const path = require("path")
const { runGit } = require("./GitCommandRunner.js")

function parseUnifiedDiff(diffText) {
if (!diffText || !diffText.trim()) {
return []
}

const files = []
let currentFile = null
let oldLine = 0
let newLine = 0

diffText.split(/\r?\n/).forEach((line) => {
if (line.startsWith("diff --git ")) {
currentFile = {
header: line,
oldPath: null,
newPath: null,
hunks: [],
lines: []
}
files.push(currentFile)
return
}

if (!currentFile) {
currentFile = {
header: "",
oldPath: null,
newPath: null,
hunks: [],
lines: []
}
files.push(currentFile)
}

if (line.startsWith("--- ")) {
currentFile.oldPath = line.slice(4)
currentFile.lines.push({ type: "meta", content: line, oldLine: null, newLine: null })
return
}

if (line.startsWith("+++ ")) {
currentFile.newPath = line.slice(4)
currentFile.lines.push({ type: "meta", content: line, oldLine: null, newLine: null })
return
}

if (line.startsWith("@@")) {
const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/)
oldLine = match ? Number(match[1]) : 0
newLine = match ? Number(match[2]) : 0
currentFile.hunks.push(line)
currentFile.lines.push({ type: "hunk", content: line, oldLine: null, newLine: null })
return
}

if (line.startsWith("+") && !line.startsWith("+++")) {
currentFile.lines.push({ type: "added", content: line, oldLine: null, newLine })
newLine += 1
return
}

if (line.startsWith("-") && !line.startsWith("---")) {
currentFile.lines.push({ type: "removed", content: line, oldLine, newLine: null })
oldLine += 1
return
}

if (line.startsWith("\\ No newline")) {
currentFile.lines.push({ type: "meta", content: line, oldLine: null, newLine: null })
return
}

currentFile.lines.push({
type: line.startsWith("index ") || line.startsWith("new file ") || line.startsWith("deleted file ") || line.startsWith("similarity ")
? "meta"
: "context",
content: line,
oldLine,
newLine
})

if (!line.startsWith("index ") && !line.startsWith("new file ") && !line.startsWith("deleted file ") && !line.startsWith("similarity ")) {
oldLine += 1
newLine += 1
}
})

return files
}

function buildUntrackedDiff(root, relativePath) {
const abs = path.join(root, relativePath)
const content = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : ""
const lines = content.split(/\r?\n/)
const body = lines.map(line => `+${line}`).join("\n")

return [
`diff --git a/${relativePath} b/${relativePath}`,
"new file mode 100644",
"index 0000000..0000000",
"--- /dev/null",
`+++ b/${relativePath}`,
`@@ -0,0 +1,${lines.length} @@`,
body
].join("\n")
}

async function getWorkingTreeDiff(root, options = {}) {
const filePath = options.path

if (options.untracked && filePath) {
const text = buildUntrackedDiff(root, filePath)
return { text, files: parseUnifiedDiff(text) }
}

const args = ["diff", "--find-renames", "--no-ext-diff", "--unified=80"]
if (options.cached) args.push("--cached")
if (filePath) args.push("--", filePath)

const result = await runGit(root, args, { allowFailure: true })

if (!result.success && result.code !== 1) {
throw new Error(result.error || "Could not read diff")
}

return {
text: result.stdout,
files: parseUnifiedDiff(result.stdout)
}
}

async function getCommitDiff(root, options = {}) {
const args = ["show", "--format=", "--find-renames", "--no-ext-diff", "--unified=80", options.commit]

if (options.path) {
args.push("--", options.path)
}

const result = await runGit(root, args, { allowFailure: true })

if (!result.success && result.code !== 1) {
throw new Error(result.error || "Could not read commit diff")
}

return {
text: result.stdout,
files: parseUnifiedDiff(result.stdout)
}
}

module.exports = {
getWorkingTreeDiff,
getCommitDiff,
parseUnifiedDiff
}
96 changes: 96 additions & 0 deletions app/git/GitGraphBuilder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const { parseRefs } = require("./GitLogParser.js")

function getOrCreateLane(activeLanes, hash) {
let index = activeLanes.indexOf(hash)
if (index !== -1) return index

index = activeLanes.findIndex(value => value === null)
if (index === -1) index = activeLanes.length

activeLanes[index] = hash
return index
}

function buildGraph(commits) {
const activeLanes = []
const nodes = []
const edges = []

commits.forEach((commit, rowIndex) => {
const laneIndex = getOrCreateLane(activeLanes, commit.fullHash)
const parentConnections = []

commit.parents.forEach((parentHash, parentIndex) => {
const parentLane = parentIndex === 0
? laneIndex
: getOrCreateLane(activeLanes, parentHash)

parentConnections.push({
fromLane: laneIndex,
toLane: parentLane,
parentHash
})

edges.push({
from: commit.fullHash,
to: parentHash,
fromLane: laneIndex,
toLane: parentLane,
merge: parentIndex > 0
})
})

if (commit.parents.length === 0) {
activeLanes[laneIndex] = null
} else {
activeLanes[laneIndex] = commit.parents[0]
commit.parents.slice(1).forEach(parentHash => {
getOrCreateLane(activeLanes, parentHash)
})
}

nodes.push({
...commit,
laneIndex,
rowIndex,
connectionsToParents: parentConnections,
activeLanes: activeLanes.slice()
})
})

return {
commits: nodes,
edges,
lanes: Math.max(1, ...nodes.map(node => node.laneIndex + 1), activeLanes.length)
}
}

function parseDecoratedLog(raw) {
return raw
.split("\x1e")
.map(chunk => chunk.trim())
.filter(Boolean)
.map(chunk => {
const fields = chunk.split("\x1f")
const refs = parseRefs(fields[5] || "")

return {
fullHash: fields[0],
shortHash: fields[0]?.slice(0, 8),
parents: fields[1] ? fields[1].split(" ").filter(Boolean) : [],
author: fields[2],
date: fields[3],
message: fields[4] || "",
refs: refs.refs,
branchLabels: refs.branches,
remoteLabels: refs.remotes,
tags: refs.tags,
isHead: refs.head
}
})
}

module.exports = {
buildGraph,
parseDecoratedLog
}
Loading