├── .gitignore ├── README.md ├── bin ├── gitgraph.js └── gitlost.js ├── lib ├── graph.js └── server.js ├── package.json └── web ├── client.js ├── graph.css └── graph.html /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | log 4 | web/viz.js 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Synopsis 2 | 3 | **GitLost** is a Git Repository graph visualizer and manipulator that runs in the browser. 4 | 5 | ## Installation 6 | 7 | 1. Install [Node.js](https://nodejs.org/en/download/) 8 | 1. Run the install command from PowerShell: `npm install -g gitlost` 9 | 1. Associate Node.js as the default program for running _*.js_ files. 10 | * Open Windows Control panel in the following location: `Control Panel\Programs\Default Programs\Set Associations` 11 | * Scroll down to the _.js_ entry. Click to highlight, then click the `Change program` button (upper-right) 12 | * From the App Selection window that opens, scroll-down and click the `More Apps ↓` link. Keep scrolling and then click the `Look for another app on this PC` link. 13 | * Navigate your Program Files directly to the install location for **Node.exe**. This will most likekly be located at `C:\Program Files\nodejs\node.exe` if you choose the x64 install in step #1. 14 | 1. Run `gitlost` from a PowerShell window 15 | 1. **ENJOY!** 16 | 17 | ## Usage 18 | 19 | [TODO - Add Usage and command tips] 20 | 21 | -------------------------------------------------------------------------------- /bin/gitgraph.js: -------------------------------------------------------------------------------- 1 | var gitlost = require('../lib/graph'); 2 | settings = JSON.parse(process.argv.slice(2).join(' ') || '{}'); 3 | gitlost.graph(settings).then(function (dot) { 4 | console.log(dot); 5 | }) 6 | -------------------------------------------------------------------------------- /bin/gitlost.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var opn = require('opn'); 3 | var server = require("../lib/server.js"); 4 | 5 | server.listen(6776, 'localhost', null, () => { 6 | console.log(server.address()); 7 | opn("http://"+server.address().address+":"+server.address().port+"/"); 8 | }); 9 | -------------------------------------------------------------------------------- /lib/graph.js: -------------------------------------------------------------------------------- 1 | var child_process = require('child_process'); 2 | var md5 = require('md5'); 3 | 4 | var carot = '^'; 5 | if (process.platform === 'win32') { 6 | // stupid cmd.exe... 7 | carot = '^^'; 8 | } 9 | 10 | /* 11 | * Memoize results until the watch endpoint returns a change, 12 | * then stupidly invalidate everything per repo. 13 | */ 14 | var cmd_memo = {}; 15 | 16 | function invalidate_cmd_memo(repo) { 17 | cmd_memo[repo] = {}; 18 | } 19 | 20 | /* 21 | * Function returns a function to create a closure and make last_promise private. 22 | * Create the start of a promise chain. Everytime the queue_cmd function is called 23 | * a new promise is added to the end of the chain and passed to the caller and also 24 | * saved as last_promise for further chaining. If the process throws, then a new 25 | * chain is started. 26 | */ 27 | var queue_cmd = (function () { 28 | var last_promise = Promise.resolve() 29 | return function (repo, cmd, catch_error) { 30 | if (last_promise.PromiseStatus) {} 31 | last_promise = last_promise.then(function () { 32 | return new Promise(function (resolve, reject) { 33 | console.log('/* ' + JSON.stringify(repo) + " : " + cmd + ' */'); 34 | if (cmd_memo[repo] && cmd_memo[repo][cmd]) { 35 | resolve(cmd_memo[repo][cmd]); 36 | } else { 37 | child_process.exec(cmd, {cwd: repo}, function (err, stdout, stderr) { 38 | if (err) { 39 | console.log(err); 40 | reject(err); 41 | } else { 42 | var result = stdout.replace(/\n+$/, ''); 43 | if (!cmd_memo[repo]) cmd_memo[repo] = {}; 44 | cmd_memo[repo][cmd] = result; 45 | resolve(result); 46 | } 47 | }); 48 | } 49 | }); 50 | }).catch(function (err) { 51 | console.log(err); 52 | }); 53 | return last_promise; 54 | } 55 | }()); 56 | 57 | function hsl_to_rgb(h, s, l){ 58 | var r, g, b; 59 | 60 | if(s == 0){ 61 | r = g = b = l; // achromatic 62 | }else{ 63 | var hue2rgb = function hue2rgb(p, q, t){ 64 | if(t < 0) t += 1; 65 | if(t > 1) t -= 1; 66 | if(t < 1/6) return p + (q - p) * 6 * t; 67 | if(t < 1/2) return q; 68 | if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; 69 | return p; 70 | } 71 | 72 | var q = l < 0.5 ? l * (1 + s) : l + s - l * s; 73 | var p = 2 * l - q; 74 | r = hue2rgb(p, q, h + 1/3); 75 | g = hue2rgb(p, q, h); 76 | b = hue2rgb(p, q, h - 1/3); 77 | } 78 | 79 | return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 80 | } 81 | 82 | var color_hash = function(text) { 83 | text = text.match(/[^\/]+$/)[0]; 84 | hash = parseInt('0x' + md5(text).slice(-5)); 85 | var h = (((hash >> 9) & 0x1ff) ^ 0x109) / 512; // map [0,127] to ~[ 0, 1) 86 | var s = ((((hash >> 5) & 0x3f) ^ 0x2f) + 36) / 100; // map [0, 63] to [.36, .99] 87 | var l = ((hash & 0x1f) + 24) / 100; // map [0, 31] to [.24, .55] 88 | rgb = hsl_to_rgb(h, s, l); 89 | rgb = (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]; 90 | //console.log('/* ' + hash.toString(16) + ' - ' + [h, s, l] + '*/') 91 | return '#' + ('000000' + rgb.toString(16)).slice(-6) 92 | }; 93 | 94 | 95 | // get repo info in order, then create digraph 96 | // nested thens are for logical clarity 97 | function graph(repo, settings) { 98 | settings = settings || {}; 99 | settings.draw_type = settings.draw_type || 'dot'; 100 | settings.branches = settings.branches || ['master']; 101 | if (settings.branches.indexOf('HEAD') === -1) { 102 | settings.branches.push('HEAD'); 103 | } 104 | settings.bgcolor = settings.bgcolor || {'master': '#eeeeee', 'origin/master': '#eeeeee'}; 105 | settings.color = settings.color || {}; 106 | settings.fontcolor = settings.fontcolor || {}; 107 | console.log(settings.branches); 108 | if (settings.include_forward === undefined) settings.include_forward = false; 109 | settings.rankdir = settings.rankdir || 'LR'; 110 | var vars = { 111 | dot: '' 112 | }; 113 | return Promise.all([queue_cmd(repo, 'git tag -l'), queue_cmd(repo, 'git branch -a')]) 114 | .then(function (branches) { 115 | branches = branches.join('\n'); 116 | settings.branches = settings.branches.filter(function (branch) { 117 | return branches.indexOf(branch) > -1; 118 | }); 119 | return queue_cmd(repo, 'git merge-base --octopus ' + settings.branches.join(' ')); 120 | }) 121 | // get earliest commit in common to all selected branches 122 | // maybe expand list of branches 123 | .then(function (base_commit) { 124 | vars.base_commit = base_commit.replace('\n', ''); 125 | if (settings.include_forward) { 126 | return queue_cmd(repo, 'git for-each-ref --contains ' + base_commit + ' --format="%(refname:short)"') 127 | .then(function (branches) { 128 | branches.split('\n').forEach(function (branch) { 129 | if (settings.branches.indexOf(branch) === -1) { 130 | settings.branches.push(branch); 131 | } 132 | }); 133 | return settings.branches; 134 | }) 135 | } else { 136 | return settings.branches; 137 | } 138 | }) 139 | // get commit list reachable from each branch until merge base parent 140 | // get parents and commit message title and create objects 141 | .then(function (branches) { 142 | settings.branches = branches; 143 | var rev_list_cmds = settings.branches.map(function (branch) { 144 | return queue_cmd(repo, 'git rev-list --max-parents=0 ' + branch) 145 | .then(function (first_rev) { 146 | var first_carot = first_rev == vars.base_commit 147 | ? '' 148 | : carot; 149 | return queue_cmd(repo, 'git rev-list --parents --first-parent --pretty=oneline ' + branch + ' ' + first_carot + vars.base_commit + first_carot) 150 | .then(function (rev_list) { 151 | return { 152 | branch: branch, 153 | commits: rev_list.split('\n') 154 | .filter(function (rev_line) { 155 | return rev_line !== ''; 156 | }) 157 | .map(function (rev_line) { 158 | var rev_parts = rev_line.match(/(([0-9a-z]{40}\s)+)(.*)/); 159 | var commits = rev_parts[1].match(/[0-9a-f]{40}/g); 160 | return { 161 | commit: commits[0], 162 | parents: commits.slice(1), 163 | title: rev_parts[3].replace(/"/g, '\\"').replace(/\//g, '\\/') 164 | } 165 | }) 166 | }; 167 | }); 168 | }) 169 | }); 170 | // queue_cmd already gaurantees sequential execution 171 | return Promise.all(rev_list_cmds); 172 | }) 173 | // get all commits' parents, create objects and sort 174 | .then(function (rev_lists) { 175 | vars.rev_lists = rev_lists; 176 | return queue_cmd(repo, 'git for-each-ref refs --format="%(refname) %(refname:short) %(objectname) %(*objectname)"') 177 | .then(function (refs) { 178 | return refs.split('\n') 179 | .filter(function (ref_line) { 180 | return ref_line !== ''; 181 | }) 182 | .map(function (ref_line) { 183 | var ref_split = ref_line.split(' '); 184 | return { 185 | ref_name: ref_split[0], 186 | ref_short: ref_split[1], 187 | ref_commit: ref_split[3] || ref_split[2] 188 | }; 189 | }) 190 | .sort(function (a, b) { 191 | return ((ax = settings.branches.indexOf(a.ref_short)) !== -1 ? ax : settings.branches.length) - ((bx = settings.branches.indexOf(b.ref_short)) !== -1 ? bx : settings.branches.length); 192 | }); 193 | }); 194 | }) 195 | .then(function (refs) { 196 | vars.refs = refs; 197 | return Promise.all([queue_cmd(repo, 'git symbolic-ref --quiet HEAD', true), queue_cmd(repo, 'git rev-parse HEAD')]); 198 | }) 199 | .then(function (HEAD) { 200 | vars.HEAD = HEAD; 201 | 202 | if (!vars.HEAD[0]) { 203 | vars.refs.push({ 204 | ref_name: 'HEAD', 205 | ref_short: 'HEAD', 206 | ref_commit: vars.HEAD[1] 207 | }); 208 | } 209 | 210 | var commits_used = new Set(); 211 | var commits_unused = new Set(); 212 | 213 | var dot = 'digraph GitLost {\n'; 214 | dot += ' graph [layout=' + settings.draw_type + ' splines=splines rankdir=' + settings.rankdir + ' bgcolor="#ffffff" title="GitLost"]\n'; 215 | dot += ' node [shape=box style="rounded,filled" fixedsize=true width=0.6 height=0.4 fontcolor="#ffffff" fontname=Consolas fontsize=10]\n'; 216 | dot += ' edge [penwidth=4 arrowhead=normal arrowsize=0.1 color="#808080"]\n\n'; 217 | 218 | var dot_edges = ''; 219 | vars.rev_lists.forEach(function (rev_list) { 220 | var color = settings.color[rev_list.branch] || color_hash(rev_list.branch); 221 | var bgcolor = settings.bgcolor[rev_list.branch] || '#ffffff'; 222 | var dot_nodes = ''; 223 | //var dot_edges = ''; 224 | dot += ' subgraph "cluster_' + rev_list.branch + '" {\n'; 225 | dot += ' style=filled;\n'; 226 | dot += ' color="' + bgcolor + '";\n'; 227 | dot += ' node [color="' + color + '"]\n'; 228 | dot += ' edge [color="' + color + '"]\n'; 229 | rev_list.commits.forEach(function (commit_info, index) { 230 | if (!commits_used.has(commit_info.commit)) { 231 | var extra = ''; 232 | if (commit_info.parents.length > 1) { 233 | extra += ' shape=octagon style="filled"'; 234 | } 235 | if (settings.fontcolor[rev_list.branch]) { 236 | extra += ' fontcolor="' + settings.fontcolor[rev_list.branch] + '"'; 237 | } 238 | dot += (' "' + 239 | commit_info.commit + '" [label=<' + 240 | commit_info.commit.substring(0,4) + '
' + 241 | commit_info.commit.substring(4,8) + '
> href="show/' + 242 | commit_info.commit + '" tooltip="' + 243 | commit_info.title + '"' + extra + ']\n'); 244 | commits_used.add(commit_info.commit); 245 | commit_info.parents.forEach(function (parent) { 246 | commits_unused.add(parent); 247 | dot_edges += (' "' + 248 | parent + '" -> "' + 249 | commit_info.commit + 250 | '" [color="' + color + '"]\n'); 251 | }); 252 | } 253 | }); 254 | //dot += dot_edges; 255 | dot += ' }\n\n'; 256 | //console.log(rev_list); 257 | }); 258 | (new Set([...commits_unused].filter(commit => !commits_used.has(commit)))).forEach(function (commit) { 259 | dot += ' "' + commit + '" [label=<' + commit.substring(0,4) + '
' + commit.substring(4,8) + '
> href="show/' + commit + '"]\n'; 260 | }); 261 | dot += '\n' + dot_edges + '\n\n'; 262 | var refs_used = new Set(); 263 | vars.refs.forEach(function (ref) { 264 | if (settings.branches.indexOf(ref.ref_short) >= 0) { 265 | //console.log(commit); 266 | var labels = []; 267 | vars.refs.forEach(function (ref2) { 268 | if (!refs_used.has(ref2.ref_name) && ref2.ref_commit === ref.ref_commit) { 269 | var color = '#808080'; 270 | if (ref2.ref_name.startsWith('refs/heads/')) color = '#60c060'; 271 | else if (ref2.ref_name.startsWith('refs/remotes/')) color = '#c06060'; 272 | else if (ref2.ref_name.startsWith('refs/tags/')) color = '#c0c060'; 273 | else if (ref2.ref_name === 'HEAD') color = '#60c0c0'; 274 | if (ref2.ref_name === vars.HEAD[0]) { 275 | labels.push(' HEAD -> ' + ref2.ref_short + '\n'); 276 | } else { 277 | labels.push(' ' + ref2.ref_short + '\n'); 278 | } 279 | refs_used.add(ref2.ref_name); 280 | } 281 | }); 282 | if (labels.length > 0) { 283 | dot += ' subgraph "' + ref.ref_short + '" {\n'; 284 | dot += ' color="#ffffff";\n'; 285 | dot += ' edge [color="#c0c0c0" arrowhead=none penwidth=2]\n\n'; 286 | dot += ' "' + ref.ref_short + '" [label=<\n'; 287 | dot += labels.join(''); 288 | dot += '
> shape=box fixedsize=false color="#ffffff" tooltip="' + ref.ref_short + '" fontname=Calibri fontsize=10]\n\n'; 289 | dot += ' "' + ref.ref_commit + '" -> "' + ref.ref_short + '"\n'; 290 | dot += ' }\n\n'; 291 | } 292 | } 293 | }) 294 | 295 | dot += '}\n'; 296 | return dot; 297 | }) 298 | .catch(function (err) { 299 | console.log('graph failed'); 300 | console.log(err); 301 | }); 302 | } 303 | 304 | module.exports = { 305 | graph: graph, 306 | queue_cmd: queue_cmd, 307 | invalidate_cmd_memo: invalidate_cmd_memo 308 | }; 309 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | //var child_process = require('child_process'); 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var EventEmitter = require('events'); 6 | var gitlost = require('./graph'); 7 | 8 | var webdir = path.join(__dirname, '../web'); 9 | var vizjs = path.join(__dirname, '../node_modules/viz.js/viz.js'); 10 | 11 | var mimetypes = { 12 | 'html': 'text/html', 13 | 'css': 'text/css', 14 | 'js': 'text/javascript' 15 | 16 | } 17 | 18 | var carot = '^'; 19 | if (process.platform === 'win32') { 20 | // stupid cmd.exe... 21 | carot = '^^'; 22 | } 23 | 24 | class GitEmitter extends EventEmitter {} 25 | 26 | var git_emitters = {}; 27 | var git_watchers = {}; 28 | function add_watcher(repo) { 29 | if (git_watchers[repo] === undefined) { 30 | var git_path = path.join(repo, '.git'); 31 | fs.readFile(git_path, {encoding: 'utf8'}, function (err, data) { 32 | if (!err) { 33 | git_path = path.join(repo, data.substring(8).trim()); 34 | } 35 | var gitEmitter = add_emitter(repo); 36 | var git_logs_watcher = fs.watch(path.join(git_path, 'logs'), {recursive: true}, function (eventType, filename) { 37 | if (filename) { 38 | console.log(eventType, filename); 39 | gitEmitter.emit('git', eventType, filename); 40 | } 41 | }) 42 | var git_refs_watcher = fs.watch(path.join(git_path, 'refs'), {recursive: true}, function (eventType, filename) { 43 | if (filename) { 44 | console.log(eventType, filename); 45 | gitEmitter.emit('git', eventType, filename); 46 | } 47 | }) 48 | git_watchers[repo] = [git_logs_watcher, git_refs_watcher]; 49 | console.log("watching: " + repo); 50 | }); 51 | } 52 | } 53 | function add_emitter(repo) { 54 | if (git_emitters[repo] === undefined) { 55 | var gitEmitter = new GitEmitter(); 56 | git_emitters[repo] = gitEmitter; 57 | gitEmitter.on('error', function (err) { 58 | console.log('gitEmitter: ' + err); 59 | }); 60 | setInterval(function () { 61 | gitEmitter.emit('git', 'heartbeat'); 62 | }, 55000); 63 | } 64 | return git_emitters[repo]; 65 | } 66 | 67 | var server = http.createServer(); 68 | 69 | var routes = [ 70 | { 71 | regex: /^\/$/, 72 | method: ['GET'], 73 | fn: function (request, response) { 74 | routes[1].fn(request, response, ['/graph.html', 'graph.html', 'html']); 75 | }, 76 | }, { 77 | regex: /^\/([^\/]+\.(html|css|js))$/, 78 | method: ['GET'], 79 | fn: function (request, response, parts) { 80 | response.statusCode = 200; 81 | response.setHeader('Content-Type', mimetypes[parts[2]]); 82 | var filepath = path.join(webdir, parts[1]); 83 | if (parts[1] === 'viz.js') { 84 | filepath = vizjs; 85 | } 86 | fs.readFile(filepath, 'utf8', function (err, data) { 87 | if (err) { 88 | console.log(err); 89 | response.statusCode = 404; 90 | response.end(); 91 | } 92 | else { 93 | response.write(data); 94 | response.end(); 95 | } 96 | }) 97 | } 98 | }, { 99 | regex: /^\/git\/status$/, 100 | method: ['GET'], 101 | fn: function (request, response, parts) { 102 | var repo = request.headers['gitlost-repo']; 103 | add_watcher(repo); 104 | gitlost.queue_cmd(repo, 'git status') 105 | .then(function (output) { 106 | response.statusCode = 200; 107 | response.setHeader('Content-Type', 'application/json'); 108 | response.write(output); 109 | response.end(); 110 | }) 111 | .catch(function (err) { 112 | console.log(err); 113 | response.statusCode = 500; 114 | response.end(); 115 | }) 116 | } 117 | }, { 118 | regex: /^\/dot$/, 119 | method: ['GET'], 120 | fn: function (request, response, parts) { 121 | response.setHeader('Content-Type', 'text/plain'); 122 | var settings = JSON.parse(request.headers['gitlost-settings'] || '{"rankdir":"LR"}'); 123 | var repo = request.headers['gitlost-repo']; 124 | console.log(settings); 125 | gitlost.graph(repo, settings) 126 | .then(function (dot) { 127 | response.statusCode = 200; 128 | response.setHeader('Content-Type', 'text/plain'); 129 | response.write(dot); 130 | response.end(); 131 | }) 132 | .catch(function (err) { 133 | console.log(err); 134 | response.statusCode = 500; 135 | response.end(); 136 | }); 137 | } 138 | }, { 139 | regex: /^\/git\/tags$/, 140 | method: ['GET'], 141 | fn: function (request, response, parts) { 142 | var repo = request.headers['gitlost-repo']; 143 | gitlost.queue_cmd(repo, 'git for-each-ref refs/tags/* --format="%(refname) %(*objectname)"') 144 | .then(function (output) { 145 | response.statusCode = 200; 146 | response.setHeader('Content-Type', 'application/json'); 147 | response.write(JSON.stringify(output.split(/\n/).map(function (tag) { 148 | var parts = tag.split(' '); 149 | return { 150 | refname: parts[0].substring(11), 151 | objectname: parts[1] 152 | } 153 | }))); 154 | response.end(); 155 | }) 156 | .catch(function (err) { 157 | console.log(err); 158 | response.statusCode = 500; 159 | response.end(); 160 | }) 161 | } 162 | }, { 163 | regex: /^\/git\/branches$/, 164 | method: ['GET'], 165 | fn: function (request, response, parts) { 166 | var repo = request.headers['gitlost-repo']; 167 | gitlost.queue_cmd(repo, 'git for-each-ref --format="%(refname) %(objectname)"') 168 | .then(function (output) { 169 | response.statusCode = 200; 170 | response.setHeader('Content-Type', 'application/json'); 171 | response.write(JSON.stringify(output.split(/\n/).map(function (tag) { 172 | var parts = tag.split(' '); 173 | return { 174 | refname: parts[0].substring(parts[0].indexOf('/', 6) + 1), 175 | objectname: parts[1] 176 | } 177 | }))); 178 | response.end(); 179 | }) 180 | .catch(function (err) { 181 | console.log(err); 182 | response.statusCode = 500; 183 | response.end(); 184 | }) 185 | } 186 | }, { 187 | regex: /^\/show\/([^;<>&|\\\*\[\?\s]+)$/, 188 | method: ['GET'], 189 | fn: function (request, response, parts) { 190 | var outputResponse = {'id': parts[1]}; 191 | var repo = request.headers['gitlost-repo']; 192 | gitlost.queue_cmd(repo, 'git show --stat=300 --format=fuller ' + parts[1]) 193 | .then(function (output) { 194 | outputResponse['text'] = output; 195 | response.statusCode = 200; 196 | response.setHeader('Content-Type', 'text/plain'); 197 | response.write(JSON.stringify(outputResponse)); 198 | response.end(); 199 | }) 200 | .catch(function (err) { 201 | console.log(err); 202 | response.statusCode = 500; 203 | response.end(); 204 | }) 205 | .catch(function (err) { 206 | console.log(err); 207 | response.statusCode = 500; 208 | response.end(); 209 | }) 210 | } 211 | }, { 212 | regex: /^\/refs$/, 213 | method: ['GET'], 214 | fn: function (request, response, parts) { 215 | var repo = request.headers['gitlost-repo']; 216 | gitlost.queue_cmd(repo, 'git for-each-ref --format="%(objectname) %(refname) %(refname:short)"') 217 | .then(function (refs) { 218 | response.statusCode = 200; 219 | response.setHeader('Content-Type', 'application/json'); 220 | var refs = refs.split('\n').map(function (ref) { 221 | var ref_info = ref.split(' '); 222 | return { 223 | commit: ref_info[0], 224 | ref_name: ref_info[1], 225 | ref_short: ref_info[2] 226 | } 227 | }); 228 | response.write(JSON.stringify({ 229 | repo_path: repo, 230 | refs: refs 231 | })); 232 | response.end(); 233 | }) 234 | .catch(function (err) { 235 | console.log(err); 236 | response.statusCode = 500; 237 | response.end(); 238 | }) 239 | } 240 | }, { 241 | regex: /^\/watch$/, 242 | method: ['GET'], 243 | fn: function (request, response, parts) { 244 | var repo = request.headers['gitlost-repo']; 245 | if (!git_emitters[repo]) { 246 | console.log("no emitter for " + repo); 247 | response.statusCode = 500; 248 | response.end(); 249 | } else { 250 | response.statusCode = 200; 251 | response.setHeader('Content-Type', 'application/json'); 252 | git_emitters[repo].once('git', function (eventType, filename) { 253 | if (eventType === 'close') { 254 | response.write('{"close": true}'); 255 | } else if (eventType === 'heartbeat') { 256 | response.write('{"heartbeat": true}'); 257 | } else { 258 | response.write(JSON.stringify({"filename": (filename || "")})); 259 | gitlost.invalidate_cmd_memo(repo); 260 | } 261 | response.end() 262 | }) 263 | } 264 | } 265 | }, { 266 | regex: /^\/close$/, 267 | method: ['PUT'], 268 | fn: function (request, response, parts) { 269 | gitEmitter.emit('git', 'close'); 270 | response.end(); 271 | server.close(); 272 | setTimeout(function () { 273 | process.exit(0); 274 | }, 500); 275 | } 276 | } 277 | ]; 278 | 279 | function get_route(request) { 280 | for (var i = 0; i < routes.length; i++) { 281 | var route = routes[i]; 282 | if (route.method && route.method.indexOf(request.method) !== -1) { 283 | var parts = route.regex.exec(request.url); 284 | if (parts) { 285 | return [parts, route]; 286 | } 287 | } 288 | } 289 | } 290 | 291 | if (!fs.existsSync(path.join(__dirname, '../log'))) { 292 | fs.mkdirSync(path.join(__dirname, '../log')); 293 | } 294 | server.on('request', function (request, response) { 295 | fs.appendFile(path.join(__dirname, '../log') + '/gitlost.log', request.url + ' ' + ( 296 | request.headers['x-forwarded-for'] || 297 | request.connection.remoteAddress || 298 | request.socket.remoteAddress || 299 | request.connection.socket.remoteAddress 300 | ) + ' ' + request.rawHeaders + '\n', function (err) { 301 | if (err) console.log(err); 302 | }); 303 | //console.log(request.url); 304 | //console.log(request.rawHeaders); 305 | //console.log(); 306 | var route_data = get_route(request); 307 | if (route_data) { 308 | response.on('error', function (err) { 309 | console.error(err.stack); 310 | }); 311 | var parts = route_data[0]; 312 | var route = route_data[1]; 313 | //console.log(parts); 314 | if (route.body) { 315 | var body = []; 316 | request.on('data', function (chunk) { 317 | body.push(chunk); 318 | }).on('end', function () { 319 | body = Buffer.concat(body).toString(); 320 | route.fn(request, response, parts, body); 321 | }).on('error', function (err) { 322 | console.error(err.stack); 323 | }); 324 | } else { 325 | route.fn(request, response, parts); 326 | } 327 | } else { 328 | response.statusCode = 404; 329 | response.end(); 330 | } 331 | }); 332 | 333 | module.exports = server; 334 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlost", 3 | "version": "1.1.5", 4 | "description": "Git repository graph visualizer and manipulator in the browser", 5 | "main": "./lib/graph.js", 6 | "bin": "./bin/gitlost.js", 7 | "dependencies": { 8 | "bootstrap-menu": "^1.0.14", 9 | "bootstrap3-dialog": "^1.35.3", 10 | "d3": "^4.13.0", 11 | "d3-graphviz": "^1.3.4", 12 | "md5": "^2.2.1", 13 | "opn": "^4.0.2", 14 | "sortablejs": "^1.5.1" 15 | }, 16 | "devDependencies": {}, 17 | "scripts": { 18 | "test": "echo \"Error: no test specified\" && exit 1" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/McLeopold/gitlost.git" 23 | }, 24 | "keywords": [ 25 | "git", 26 | "graphviz", 27 | "browser" 28 | ], 29 | "author": "Scott Hamilton", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/McLeopold/gitlost/issues" 33 | }, 34 | "homepage": "https://github.com/McLeopold/gitlost#readme" 35 | } 36 | -------------------------------------------------------------------------------- /web/client.js: -------------------------------------------------------------------------------- 1 | Vue.component('gitlost-graph', { 2 | components: { }, 3 | props: ['repo'], 4 | data() { 5 | return { 6 | // treeview 7 | items: [], 8 | search: null, 9 | // tab view 10 | commit_tab: null, 11 | commits: [], 12 | // ui elements 13 | graph: null, 14 | // ui 15 | top_items: ['HEAD', 'master', 'tags', ''], 16 | draw_types: ['dot', 'neato', 'twopi', 'circo', 'fdp', 'sfdp', 'patchwork', 'osage'], 17 | nav_expand: [0,1], 18 | loading: false, 19 | goto: null, 20 | commitTabSize: 0.1, 21 | sidebarSize: 20, 22 | sidebarLastSize: null, 23 | // polling 24 | graph_queued: false, 25 | graph_promise: null, 26 | polling: null, 27 | // 28 | settings: { 29 | branches: [], 30 | opened: [], 31 | rankdir: 'LR', 32 | include_forward: false, 33 | draw_type: 'dot', 34 | }, 35 | } 36 | }, 37 | methods: { 38 | toggleCommitTab: function () { 39 | if (this.commits.length > 0) { 40 | var sliderHeight = this.$el.querySelector('.commit_tab').querySelector('.v-tabs-bar').clientHeight; 41 | var tabHeight = this.$el.querySelector('.commit_tab').clientHeight; 42 | if (sliderHeight === tabHeight) { 43 | window.setTimeout(() => this.toggleCommitTab()); 44 | } else { 45 | var totalHeight = this.$el.querySelector('.graph_bar').clientHeight; 46 | this.commitTabSize = tabHeight/totalHeight*100; 47 | } 48 | } else { 49 | this.commitTabSize = 0.1; 50 | } 51 | }, 52 | toggleCommitResized: function (event) { 53 | this.commitTabSize = event[1].width; 54 | /* 55 | var sliderHeight = this.$el.querySelector('.commit_tab').querySelector('.v-tabs-bar').clientHeight; 56 | var tabHeight = this.$el.querySelector('.graph_bar').querySelector('#pane_1').clientHeight; 57 | var textHeight = tabHeight - sliderHeight; 58 | this.$el.querySelectorAll('.commit_text').forEach(text => { 59 | text.style.height = textHeight + 'px'; 60 | }); 61 | */ 62 | }, 63 | toggleSidebar: function() { 64 | if (this.sidebarLastSize === null) { 65 | this.sidebarLastSize = this.sidebarSize; 66 | this.sidebarSize = 0.1; 67 | } else { 68 | this.sidebarSize = this.sidebarLastSize; 69 | this.sidebarLastSize = null; 70 | } 71 | }, 72 | addCommitTab: function (commit) { 73 | this.commits.push(commit); 74 | window.setTimeout(() => { 75 | this.commit_tab = this.commits.length - 1; 76 | this.toggleCommitTab(); 77 | }); 78 | }, 79 | removeCommitTab: function (commit_id) { 80 | this.commits.splice(this.commits.findIndex(commit => commit.id === commit_id), 1); 81 | }, 82 | selectTree: function (stuff) { 83 | this.zoom_graph_on(stuff[0].ref_prefixes[0] + stuff[0].id); 84 | //console.log(stuff); 85 | }, 86 | remove_branch: function (id) { 87 | this.settings.branches.splice(this.settings.branches.findIndex(branch => branch.id === id), 1); 88 | }, 89 | updateGraph: function () { 90 | this.loading = true; 91 | //var branches = this.settings.branches.map(branch => branch.ref_prefixes.map(prefix => prefix + branch.id)).flat(); 92 | //console.log(branches); 93 | //settings.set('branches', branches); 94 | // update after select close 95 | setTimeout(() => { 96 | this.get_graph(this).then(() => this.loading = false); 97 | }, 1); 98 | }, 99 | updateRefs: function (refs) { 100 | var sortedRefs = refs 101 | .map(function (ref) { 102 | return ref.ref_name.replace('refs/', ''); 103 | }) 104 | .map(function (ref_name) { 105 | var parts = ref_name.match(/(tags\/|heads\/|remotes\/(\w+\/))(.+)/) 106 | return parts 107 | ? { prefix: parts[2] || '', parts: parts[3].split('/'), name: (parts[2] || '') + parts[3], tag: parts[1] === 'tags/' } 108 | : { prefix: '', parts: ref_name.split('/'), name: ref_name }; 109 | }) 110 | .sort(function (a, b) { 111 | if (a.parts[0] === 'master') return b.parts[0] !== 'master' ? -1 : a.parts[0] === '' ? -1 : 1; 112 | if (b.parts[0] === 'master') return 1; 113 | if (a.tag && !b.tag) return 1; 114 | if (b.tag && !a.tag) return -1; 115 | if (a.parts.length > 1 && b.parts.length === 1) return -1; 116 | if (a.parts.length === 1 && b.parts.length > 1) return 1; 117 | return (a.name > b.name) ? 1 : -1; 118 | }); 119 | //console.log(sortedRefs); 120 | var newTree = this.items; 121 | this.top_items.forEach(item => { 122 | if (!newTree.find(element => element.name === item)) newTree.push({ id: item, name: item }); 123 | }) 124 | sortedRefs.forEach(ref => { 125 | var treePath = ''; 126 | var treeLoc = newTree; 127 | if (ref.tag) treeLoc = treeLoc.find(element => element.name === 'tags'); 128 | else if (ref.parts.length === 1 && !this.top_items.includes(ref.parts[0])) treeLoc = newTree.find(element => element.name === ''); 129 | ref.parts.forEach(part => { 130 | treePath += (treePath !== '' ? '/' : '') + part; 131 | if (!Array.isArray(treeLoc)) { 132 | if (!treeLoc.children) treeLoc.children = []; 133 | treeLoc = treeLoc.children; 134 | } 135 | var newTreeLoc = treeLoc.find(element => element.name === part); 136 | if (!newTreeLoc) { 137 | newTreeLoc = { 138 | id: treePath, 139 | name: part, 140 | }; 141 | treeLoc.push(newTreeLoc); 142 | } 143 | treeLoc = newTreeLoc; 144 | }); 145 | if (!treeLoc.ref_prefixes) treeLoc.ref_prefixes = [ref.prefix]; 146 | else if (!treeLoc.ref_prefixes.includes(ref.prefix)) treeLoc.ref_prefixes.push(ref.prefix); 147 | }); 148 | //console.log(newTree); 149 | //this.settings.branches = this.settings.branches.map(branch => { return { id: branch }; }); 150 | }, 151 | zoom_graph_on: function (value) { 152 | // TODO: remove jquery, search item data for key 153 | this.zoom_graph_to($(this.graph).find("text:contains(" + value + ")")); 154 | }, 155 | zoom_graph_to: function (target) { 156 | var gv = d3.select(this.graph).graphviz(); 157 | var svg = $(this.graph).children('svg'); 158 | var viewbox = svg.attr('viewBox').split(' ').map(a => parseFloat(a)); 159 | var g = svg.children('.graph'); 160 | var scale = Math.max(Math.max(viewbox[2], viewbox[3])/1000, parseFloat(g.attr('transform').match(/scale\((.+)\)/)[1])); 161 | var x = parseFloat($(target).attr('x')); 162 | var y = parseFloat($(target).attr('y')); 163 | gv.zoomSelection().transition().duration(750).call(gv.zoomBehavior().transform, d3.zoomIdentity.scale(scale).translate(-x + viewbox[2] / 2 / scale, -y + viewbox[3] / 2 / scale)); 164 | }, 165 | update_graph: function (dot) { 166 | var t = d3.transition() 167 | .duration(750) 168 | .ease(d3.easeLinear); 169 | d3 170 | .select(this.graph) 171 | .graphviz({ 172 | zoomScaleExtent: [0.1, 100], 173 | }) 174 | .transition(t) 175 | .renderDot(dot, () => { 176 | var v = this; 177 | var $graph = $(this.graph); 178 | $graph 179 | .children('svg') 180 | .height('100%') 181 | .width('100%'); 182 | $graph 183 | .find('a') 184 | .each(function () { 185 | var that = $(this); 186 | that.data('href', that.attr('href')); 187 | that.removeAttr('href'); 188 | that.css('cursor', 'pointer'); 189 | }) 190 | .unbind('click') 191 | .click((event) => { 192 | event.preventDefault(); 193 | var commit_id = $(event.currentTarget).data('href').substring(5); 194 | axios.get('show/'+commit_id, { headers: { 'gitlost-repo': this.repo } }) 195 | .then(output => { 196 | this.addCommitTab(output.data); 197 | }) 198 | }); 199 | }); 200 | // add right click menus 201 | /* 202 | var menu = new BootstrapMenu( 203 | 'g.node', 204 | { 205 | actions: [ 206 | { 207 | name: 'Add Refs', 208 | onClick: function (objectname) { 209 | var link_refs = axios.get('/git/branches', { headers: { 'gitlost-repo': this.repo } }) 210 | var link_refs = $.ajax({ 211 | type: 'GET', 212 | url: '/git/branches', 213 | contentType: 'application/json' 214 | }) 215 | .then(function (all_branches) { 216 | all_branches = all_branches.data; 217 | var refs_select = $('select[name=refs]'); 218 | var new_branches = refs_select.val().concat( 219 | all_branches.filter(function (branch) { 220 | return branch.objectname === objectname; 221 | }).map(function (branch) { 222 | return branch.refname; 223 | }) 224 | ); 225 | refs_select.val(new_branches); 226 | refs_select.selectpicker('refresh'); 227 | settings.set('branches', new_branches); 228 | setTimeout(get_graph,1); 229 | }); 230 | } 231 | } 232 | ], 233 | fetchElementData: function ($el) { 234 | return $el.find('title').text(); 235 | } 236 | } 237 | ); 238 | */ 239 | }, 240 | get_graph: function () { 241 | if (this.polling !== null) { 242 | //polling.abort(); 243 | } 244 | if (this.graph_promise === null) { 245 | // Inital request 246 | this.graph_promise = axios.get('/refs', { headers: { 'gitlost-repo': this.repo } }) 247 | .then((repo) => { 248 | repo = repo.data; 249 | //settings = new Settings(repo.repo_path); 250 | /* 251 | if (settings.settings.include_forward) { 252 | $('button[name=include_forward]').addClass('active').attr('aria-pressed', 'true'); 253 | } 254 | settings.set('draw_type', $('select[name=graphTypes]').val()); 255 | */ 256 | this.updateRefs(repo.refs); 257 | var passed_settings = { 258 | branches: this.settings.branches.map(branch => 259 | branch.ref_prefixes.map(prefix => prefix + branch.id) 260 | ).flat(), 261 | rankdir: this.settings.rankdir, 262 | include_forward: this.settings.include_forward, 263 | draw_type: this.settings.draw_type, 264 | }; 265 | if (passed_settings.branches.length === 0) passed_settings.branches = ['master']; 266 | return axios.get('/dot', { headers: { 267 | 'gitlost-repo': this.repo, 268 | 'gitlost-settings': JSON.stringify(passed_settings) 269 | } }); 270 | }) 271 | .then((dot) => { 272 | dot = dot.data; 273 | this.update_graph(dot); 274 | if (this.graph_queued === false) { 275 | this.graph_promise = null; 276 | this.poll_git(); 277 | } 278 | }) 279 | .catch((err) => { 280 | this.graph_promise = null; 281 | console.log(err); 282 | }); 283 | return this.graph_promise; 284 | } else if (this.graph_queued === false) { 285 | // Queue one additional request 286 | this.graph_queued = true; 287 | this.graph_promise = this.graph_promise.then(() => { 288 | this.graph_queued = false; 289 | this.graph_promise = null; 290 | this.graph_promise = this.get_graph(); 291 | return this.graph_promise; 292 | }); 293 | } else { 294 | // Prevent multiple requests from queueing 295 | return this.graph_promise; 296 | } 297 | }, 298 | poll_git: function () { 299 | if (this.polling === null) { 300 | axios.get('/watch', { headers: { 'gitlost-repo': this.repo } }) 301 | /* 302 | polling = $.ajax({ 303 | type: "GET", 304 | url: "/watch" 305 | }) 306 | */ 307 | .then((result) => { 308 | result = result.data; 309 | this.polling = null; 310 | if (result.close) { 311 | } else if (result.heartbeat) { 312 | setTimeout(() => this.poll_git(), 1); 313 | } else { 314 | console.log(result); 315 | setTimeout(() => this.get_graph(), 1); 316 | } 317 | }) 318 | .catch((err) => { 319 | this.polling = null; 320 | console.log(err); 321 | }); 322 | } 323 | } 324 | }, 325 | mounted: function () { 326 | axios.get('/git/status', { headers: {'gitlost-repo': this.repo } }) 327 | .then(reponse => { 328 | 329 | }); 330 | axios.get('/refs', { headers: {'gitlost-repo': this.repo } }) 331 | .then(response => { 332 | this.updateRefs(response.data.refs); 333 | window.setTimeout(() => { 334 | var settings = JSON.parse(localStorage[this.repo] || '{}'); 335 | for (setting in settings) this.settings[setting] = settings[setting]; 336 | 337 | this.graph = this.$el.querySelector(".graph"); 338 | this.get_graph(this); 339 | }, 1); 340 | }); 341 | }, 342 | watch: { 343 | settings: { 344 | handler(new_settings) { 345 | localStorage[this.repo] = JSON.stringify(new_settings); 346 | }, 347 | deep: true 348 | } 349 | } 350 | }); 351 | 352 | Vue.component('gitlost-commit', { 353 | props: ['commit'], 354 | data() { 355 | return { 356 | 357 | } 358 | }, 359 | methods: { 360 | 361 | }, 362 | watch: { 363 | 364 | } 365 | }); -------------------------------------------------------------------------------- /web/graph.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Consolas, monospace; 3 | } 4 | 5 | svg { 6 | height: 100%; 7 | } 8 | 9 | .graph { 10 | background-color: #ffffff; 11 | } 12 | 13 | .v-toolbar__content .v-input__control > .v-input__slot { 14 | margin-bottom: 0px; 15 | } 16 | 17 | .commit-tab-split > #pane_1 { 18 | overflow: auto; 19 | } 20 | 21 | pre { 22 | font-size: 0.75em; 23 | overflow: scroll; 24 | height: 400px; 25 | } -------------------------------------------------------------------------------- /web/graph.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GitLost 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | {{repo}} 34 | 35 |
36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | {{ repo }} 44 |
45 | 46 | 47 | 48 | mdi-transfer-right 49 | mdi-transfer-left 50 | mdi-transfer-up 51 | mdi-transfer-down 52 | 53 |
54 | 55 | 56 | 57 | Refresh Graph 58 | 59 | 60 | 61 | Selected 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {{ prefix === '' ? 'mdi-arrow-down-thick' : 'mdi-arrow-top-right-thick'}} 73 | 74 | 75 | 76 | 77 | mdi-close-circle-outline 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | Branches 88 | 89 | 91 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | {{ commit.id.substring(0,8) }} 114 | mdi-close 115 | 116 | 117 | 118 | 119 |
{{ commit.text }}
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | 180 | 181 | 182 | --------------------------------------------------------------------------------