├── .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 |
--------------------------------------------------------------------------------