71
|
71
|
|
"""
|
72
|
72
|
|
Scan GITOLITE_REPOSITORIES_ROOT for Git repositories, and return a list of them.
|
73
|
73
|
|
"""
|
74
|
|
- |
|
|
74
|
+ |
|
75
|
75
|
|
repositories = []
|
76
|
|
- |
|
|
76
|
+ |
|
77
|
77
|
|
# When topdown is True, the caller can modify the dirnames list in-place and
|
78
|
78
|
|
# walk() will only recurse into the subdirectories whose names remain in dirnames;
|
79
|
79
|
|
# this can be used to prune the search.
|
81
|
81
|
|
for path, dirs, files in os.walk(GITOLITE_REPOSITORIES_ROOT, topdown=True):
|
82
|
82
|
|
# Remove all files, we only want to recurse into directories
|
83
|
83
|
|
files.clear()
|
84
|
|
- |
|
|
84
|
+ |
|
85
|
85
|
|
# This path is a git repo. Remove all sub-dirs because we don't need to
|
86
|
86
|
|
# recurse any further
|
87
|
87
|
|
if path.endswith('.git'):
|
88
|
88
|
|
dirs.clear()
|
89
|
|
- |
|
|
89
|
+ |
|
90
|
90
|
|
repository = os.path.relpath(path, GITOLITE_REPOSITORIES_ROOT)
|
91
|
|
- |
|
|
91
|
+ |
|
92
|
92
|
|
# DO NOT LIST gitolite-admin repository!
|
93
|
93
|
|
# This is the administration repository of this instance!
|
94
|
94
|
|
if repository.lower() == 'gitolite-admin.git':
|
95
|
95
|
|
continue
|
96
|
|
- |
|
|
96
|
+ |
|
97
|
97
|
|
try:
|
98
|
98
|
|
with open(os.path.join(path, 'description')) as f:
|
99
|
99
|
|
description = f.read()
|
100
|
100
|
|
except:
|
101
|
101
|
|
description = ''
|
102
|
|
- |
|
|
102
|
+ |
|
103
|
103
|
|
repositories.append({
|
104
|
104
|
|
'path': repository,
|
105
|
105
|
|
'description': description
|
112
|
112
|
|
"""
|
113
|
113
|
|
Parse "tags" file of a mailing list thread.
|
114
|
114
|
|
"""
|
115
|
|
- |
|
|
115
|
+ |
|
116
|
116
|
|
tags = {}
|
117
|
|
- |
|
|
117
|
+ |
|
118
|
118
|
|
for line in data.splitlines():
|
119
|
119
|
|
k, v = line.split('=', 1)
|
120
|
120
|
|
k = k.strip()
|
121
|
121
|
|
v = v.strip()
|
122
|
122
|
|
tags[k] = tags.get(k, []) + [ v ]
|
123
|
|
- |
|
|
123
|
+ |
|
124
|
124
|
|
return tags
|
125
|
125
|
|
|
126
|
126
|
|
|
146
|
146
|
|
Convert a file size in bytes to a human friendly form.
|
147
|
147
|
|
This is only used in templates when showing file sizes.
|
148
|
148
|
|
"""
|
149
|
|
- |
|
|
149
|
+ |
|
150
|
150
|
|
for unit in [ 'B' if B else '', 'K', 'M', 'G', 'T', 'P' ]:
|
151
|
151
|
|
if bytes < 1024: break
|
152
|
152
|
|
bytes = bytes / 1024
|
153
|
|
- |
|
|
153
|
+ |
|
154
|
154
|
|
return '{}{}'.format(round(bytes), unit).rjust(5)
|
155
|
155
|
|
|
156
|
156
|
|
def humanct(commit_time, commit_time_offset = 0):
|
158
|
158
|
|
The following will add custom functions to the jinja2 template engine.
|
159
|
159
|
|
These will be available to use within templates.
|
160
|
160
|
|
"""
|
161
|
|
- |
|
|
161
|
+ |
|
162
|
162
|
|
delta = datetime.timedelta(minutes=commit_time_offset)
|
163
|
163
|
|
tz = datetime.timezone(delta)
|
164
|
164
|
|
|
165
|
165
|
|
dt = datetime.datetime.fromtimestamp(commit_time, tz)
|
166
|
|
- |
|
|
166
|
+ |
|
167
|
167
|
|
return dt.astimezone(pytz.utc).strftime('%Y-%m-%d %H:%M:%S')
|
168
|
168
|
|
|
169
|
169
|
|
template = functools.partial(template, template_settings = {
|
197
|
197
|
|
def error404(error):
|
198
|
198
|
|
"""
|
199
|
199
|
|
Custom 404 page.
|
200
|
|
- |
|
|
200
|
+ |
|
201
|
201
|
|
:param error: bottle.HTTPError given by Bottle when calling abort(404).
|
202
|
202
|
|
"""
|
203
|
|
- |
|
|
203
|
+ |
|
204
|
204
|
|
return '[404] {}'.format(error.body)
|
205
|
205
|
|
|
206
|
206
|
|
@bottle.get('/static/<filename:path>', name='static')
|
216
|
216
|
|
"""
|
217
|
217
|
|
The home page displayed at https://domain/
|
218
|
218
|
|
"""
|
219
|
|
- |
|
|
219
|
+ |
|
220
|
220
|
|
repositories = list_repositories()
|
221
|
221
|
|
return template('explore.html', repositories=repositories)
|
222
|
222
|
|
|
225
|
225
|
|
"""
|
226
|
226
|
|
The home page displayed at https://domain/
|
227
|
227
|
|
"""
|
228
|
|
- |
|
|
228
|
+ |
|
229
|
229
|
|
return template('about.html', domain=INSTANCE_DOMAIN)
|
230
|
230
|
|
|
231
|
231
|
|
@bottle.get('/<repository:path>.git', name='overview')
|
232
|
232
|
|
def overview(repository):
|
233
|
233
|
|
"""
|
234
|
234
|
|
Show README and other info about the repository.
|
235
|
|
- |
|
|
235
|
+ |
|
236
|
236
|
|
:param repository: Match repository name ending with ".git"
|
237
|
237
|
|
"""
|
238
|
|
- |
|
|
238
|
+ |
|
239
|
239
|
|
repository += '.git'
|
240
|
240
|
|
path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
241
|
241
|
|
|
242
|
242
|
|
if not os.path.isdir(path):
|
243
|
243
|
|
bottle.abort(404, 'No repository at this path.')
|
244
|
|
- |
|
|
244
|
+ |
|
245
|
245
|
|
repo = pygit2.Repository(path)
|
246
|
246
|
|
local_branches = list(repo.branches.local)
|
247
|
|
- |
|
|
247
|
+ |
|
248
|
248
|
|
HEAD = None
|
249
|
249
|
|
ref_name = None
|
250
|
|
- |
|
|
250
|
+ |
|
251
|
251
|
|
try:
|
252
|
252
|
|
HEAD = repo.head.name
|
253
|
253
|
|
ref_name = HEAD
|
256
|
256
|
|
if name_candidate in local_branches:
|
257
|
257
|
|
ref_name = name_candidate
|
258
|
258
|
|
break
|
259
|
|
- |
|
|
259
|
+ |
|
260
|
260
|
|
readme = ''
|
261
|
|
- |
|
|
261
|
+ |
|
262
|
262
|
|
if ref_name:
|
263
|
263
|
|
tree = repo.revparse_single(ref_name).tree
|
264
|
|
- |
|
|
264
|
+ |
|
265
|
265
|
|
for e in tree:
|
266
|
266
|
|
if e.name.lower() not in [ 'readme', 'readme.md', 'readme.rst' ]:
|
267
|
267
|
|
continue
|
268
|
|
- |
|
|
268
|
+ |
|
269
|
269
|
|
if e.is_binary:
|
270
|
270
|
|
continue
|
271
|
|
- |
|
|
271
|
+ |
|
272
|
272
|
|
# Read the README content, cut at 1MB
|
273
|
273
|
|
readme = tree[e.name].data[:1048576].decode('UTF-8')
|
274
|
274
|
|
break
|
275
|
|
- |
|
|
275
|
+ |
|
276
|
276
|
|
repo_size = sum(f.stat().st_size for f in pathlib.Path(path).glob("**/*"))
|
277
|
|
- |
|
|
277
|
+ |
|
278
|
278
|
|
try:
|
279
|
279
|
|
with open(os.path.join(path, 'description')) as f:
|
280
|
280
|
|
description = f.read()
|
281
|
281
|
|
except:
|
282
|
282
|
|
description = ''
|
283
|
|
- |
|
|
283
|
+ |
|
284
|
284
|
|
return template('repository/overview.html',
|
285
|
285
|
|
readme=readme,
|
286
|
286
|
|
repository=repository,
|
293
|
293
|
|
"""
|
294
|
294
|
|
List repository refs
|
295
|
295
|
|
"""
|
296
|
|
- |
|
|
296
|
+ |
|
297
|
297
|
|
repository += '.git'
|
298
|
298
|
|
path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
299
|
|
- |
|
|
299
|
+ |
|
300
|
300
|
|
if not os.path.isdir(path):
|
301
|
301
|
|
bottle.abort(404, 'No repository at this path.')
|
302
|
|
- |
|
|
302
|
+ |
|
303
|
303
|
|
repo = pygit2.Repository(path)
|
304
|
|
- |
|
|
304
|
+ |
|
305
|
305
|
|
if repo.is_empty:
|
306
|
306
|
|
return template('repository/refs.html',
|
307
|
307
|
|
repository=repository)
|
308
|
|
- |
|
|
308
|
+ |
|
309
|
309
|
|
try: HEAD = repo.head.name
|
310
|
310
|
|
except: HEAD = None
|
311
|
|
- |
|
|
311
|
+ |
|
312
|
312
|
|
heads = []
|
313
|
313
|
|
tags = []
|
314
|
|
- |
|
|
314
|
+ |
|
315
|
315
|
|
for ref in repo.references:
|
316
|
316
|
|
ref = repo.references.get(ref)
|
317
|
|
- |
|
|
317
|
+ |
|
318
|
318
|
|
if not ref:
|
319
|
319
|
|
continue
|
320
|
|
- |
|
|
320
|
+ |
|
321
|
321
|
|
if ref.name.startswith('refs/heads/'):
|
322
|
322
|
|
heads.append({
|
323
|
323
|
|
'ref': ref,
|
324
|
324
|
|
'commit': ref.peel(pygit2.GIT_OBJ_COMMIT)
|
325
|
325
|
|
})
|
326
|
|
- |
|
|
326
|
+ |
|
327
|
327
|
|
if ref.name.startswith('refs/tags/'):
|
328
|
328
|
|
target = repo.get(str(ref.target))
|
329
|
|
- |
|
|
329
|
+ |
|
330
|
330
|
|
tags.append({
|
331
|
331
|
|
'ref': ref,
|
332
|
332
|
|
'object': target,
|
333
|
333
|
|
'is_annotated': target.type == pygit2.GIT_OBJ_TAG
|
334
|
334
|
|
})
|
335
|
|
- |
|
|
335
|
+ |
|
336
|
336
|
|
heads.sort(key = lambda item: item['ref'].name)
|
337
|
|
- |
|
|
337
|
+ |
|
338
|
338
|
|
def tagsort(item):
|
339
|
339
|
|
try:
|
340
|
340
|
|
if item['object'].type == pygit2.GIT_OBJ_TAG:
|
341
|
341
|
|
return item['object'].tagger.time
|
342
|
|
- |
|
|
342
|
+ |
|
343
|
343
|
|
if item['object'].type == pygit2.GIT_OBJ_COMMIT:
|
344
|
344
|
|
return item['object'].commit_time
|
345
|
345
|
|
except:
|
346
|
346
|
|
return 0
|
347
|
|
- |
|
|
347
|
+ |
|
348
|
348
|
|
tags.sort(key = lambda item: tagsort(item), reverse=True)
|
349
|
|
- |
|
|
349
|
+ |
|
350
|
350
|
|
return template('repository/refs.html',
|
351
|
351
|
|
repository=repository,
|
352
|
352
|
|
heads=heads, tags=tags, HEAD=HEAD)
|
357
|
357
|
|
"""
|
358
|
358
|
|
Show commit tree.
|
359
|
359
|
|
"""
|
360
|
|
- |
|
|
360
|
+ |
|
361
|
361
|
|
repository += '.git'
|
362
|
362
|
|
repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
363
|
|
- |
|
|
363
|
+ |
|
364
|
364
|
|
if not os.path.isdir(repository_path):
|
365
|
365
|
|
bottle.abort(404, 'No repository at this path.')
|
366
|
|
- |
|
|
366
|
+ |
|
367
|
367
|
|
repo = pygit2.Repository(repository_path)
|
368
|
|
- |
|
|
368
|
+ |
|
369
|
369
|
|
if repo.is_empty:
|
370
|
370
|
|
return template('repository/tree.html',
|
371
|
371
|
|
repository=repository, revision=revision, offset=0)
|
372
|
|
- |
|
|
372
|
+ |
|
373
|
373
|
|
try:
|
374
|
374
|
|
git_object = repo.revparse_single(revision)
|
375
|
375
|
|
except:
|
376
|
376
|
|
bottle.abort(404)
|
377
|
|
- |
|
|
377
|
+ |
|
378
|
378
|
|
# List all the references.
|
379
|
379
|
|
# This is used for allowing the user to switch revision with a selector.
|
380
|
380
|
|
HEAD = None
|
385
|
385
|
|
if ref.startswith('refs/tags/'): tags.append(ref)
|
386
|
386
|
|
heads.sort()
|
387
|
387
|
|
tags.sort()
|
388
|
|
- |
|
|
388
|
+ |
|
389
|
389
|
|
try:
|
390
|
390
|
|
HEAD = repo.head.name
|
391
|
391
|
|
except:
|
392
|
392
|
|
pass
|
393
|
|
- |
|
|
393
|
+ |
|
394
|
394
|
|
if git_object.type == pygit2.GIT_OBJ_TAG:
|
395
|
395
|
|
git_object = git_object.peel(None)
|
396
|
|
- |
|
|
396
|
+ |
|
397
|
397
|
|
if git_object.type == pygit2.GIT_OBJ_COMMIT:
|
398
|
398
|
|
git_object = git_object.tree
|
399
|
|
- |
|
|
399
|
+ |
|
400
|
400
|
|
if git_object.type == pygit2.GIT_OBJ_TREE and tree_path:
|
401
|
401
|
|
git_object = git_object[tree_path]
|
402
|
|
- |
|
|
402
|
+ |
|
403
|
403
|
|
if git_object.type == pygit2.GIT_OBJ_TREE:
|
404
|
404
|
|
return template(
|
405
|
405
|
|
'repository/tree.html',
|
407
|
407
|
|
tree=git_object,
|
408
|
408
|
|
tree_path=tree_path,
|
409
|
409
|
|
repository=repository, revision=revision)
|
410
|
|
- |
|
|
410
|
+ |
|
411
|
411
|
|
if git_object.type == pygit2.GIT_OBJ_BLOB:
|
412
|
|
- |
|
|
412
|
+ |
|
413
|
413
|
|
# Highlight blob text
|
414
|
414
|
|
if git_object.is_binary:
|
415
|
415
|
|
blob_formatted = ''
|
416
|
416
|
|
else:
|
417
|
417
|
|
blob_data = git_object.data.decode('UTF-8')
|
418
|
|
- |
|
|
418
|
+ |
|
419
|
419
|
|
# Guess Pygments lexer by filename, or by content if we can't find one
|
420
|
420
|
|
try:
|
421
|
421
|
|
pygments_lexer = guess_lexer_for_filename(git_object.name, blob_data)
|
422
|
422
|
|
except:
|
423
|
423
|
|
pygments_lexer = guess_lexer(blob_data)
|
424
|
|
- |
|
|
424
|
+ |
|
425
|
425
|
|
pygments_formatter = HtmlFormatter(nobackground=True, linenos=True, anchorlinenos=True,
|
426
|
426
|
|
lineanchors='line')
|
427
|
|
- |
|
|
427
|
+ |
|
428
|
428
|
|
blob_formatted = highlight(blob_data, pygments_lexer, pygments_formatter)
|
429
|
|
- |
|
|
429
|
+ |
|
430
|
430
|
|
return template(
|
431
|
431
|
|
'repository/blob.html',
|
432
|
432
|
|
heads=heads, tags=tags,
|
434
|
434
|
|
blob_formatted=blob_formatted,
|
435
|
435
|
|
repository=repository, revision=revision,
|
436
|
436
|
|
tree_path=tree_path)
|
437
|
|
- |
|
|
437
|
+ |
|
438
|
438
|
|
bottle.abort(404)
|
439
|
439
|
|
|
440
|
440
|
|
@bottle.post('/<repository:path>.git/tree', name='tree_change')
|
444
|
444
|
|
This route is used by the <form> in the tree page when changing the revision
|
445
|
445
|
|
to be displayed.
|
446
|
446
|
|
"""
|
447
|
|
- |
|
|
447
|
+ |
|
448
|
448
|
|
revision = request.forms.get('revision')
|
449
|
|
- |
|
|
449
|
+ |
|
450
|
450
|
|
bottle.redirect(application.get_url('tree',
|
451
|
451
|
|
repository=repository,
|
452
|
452
|
|
revision=revision))
|
456
|
456
|
|
"""
|
457
|
457
|
|
Show commit log.
|
458
|
458
|
|
"""
|
459
|
|
- |
|
|
459
|
+ |
|
460
|
460
|
|
repository += '.git'
|
461
|
461
|
|
repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
462
|
|
- |
|
|
462
|
+ |
|
463
|
463
|
|
# Read commits
|
464
|
464
|
|
try: commits_offset = int(request.query.get('offset', 0))
|
465
|
465
|
|
except: commits_offset = 0
|
466
|
|
- |
|
|
466
|
+ |
|
467
|
467
|
|
if not os.path.isdir(repository_path):
|
468
|
468
|
|
bottle.abort(404, 'No repository at this path.')
|
469
|
|
- |
|
|
469
|
+ |
|
470
|
470
|
|
repo = pygit2.Repository(repository_path)
|
471
|
|
- |
|
|
471
|
+ |
|
472
|
472
|
|
if repo.is_empty:
|
473
|
473
|
|
return template('repository/log.html',
|
474
|
474
|
|
repository=repository, revision=revision, offset=commits_offset)
|
475
|
|
- |
|
|
475
|
+ |
|
476
|
476
|
|
try:
|
477
|
477
|
|
git_object = repo.revparse_single(revision)
|
478
|
478
|
|
except:
|
479
|
479
|
|
bottle.abort(404)
|
480
|
|
- |
|
|
480
|
+ |
|
481
|
481
|
|
# List all the references.
|
482
|
482
|
|
# This is used for allowing the user to switch revision with a selector.
|
483
|
483
|
|
HEAD = None
|
488
|
488
|
|
if ref.startswith('refs/tags/'): tags.append(ref)
|
489
|
489
|
|
heads.sort()
|
490
|
490
|
|
tags.sort()
|
491
|
|
- |
|
|
491
|
+ |
|
492
|
492
|
|
try:
|
493
|
493
|
|
HEAD = repo.head.name
|
494
|
494
|
|
except:
|
495
|
495
|
|
pass
|
496
|
|
- |
|
|
496
|
+ |
|
497
|
497
|
|
if git_object.type in [ pygit2.GIT_OBJ_TREE, pygit2.GIT_OBJ_BLOB ]:
|
498
|
498
|
|
return 'Not a valid ref'
|
499
|
|
- |
|
|
499
|
+ |
|
500
|
500
|
|
if git_object.type == pygit2.GIT_OBJ_TAG:
|
501
|
501
|
|
git_object = git_object.peel(None)
|
502
|
|
- |
|
|
502
|
+ |
|
503
|
503
|
|
# At this point git_object should be a valid pygit2.GIT_OBJ_COMMIT
|
504
|
|
- |
|
|
504
|
+ |
|
505
|
505
|
|
commits = []
|
506
|
506
|
|
diff = {}
|
507
|
507
|
|
commit_ith = 0
|
510
|
510
|
|
if commit_ith < commits_offset:
|
511
|
511
|
|
commit_ith += 1
|
512
|
512
|
|
continue
|
513
|
|
- |
|
|
513
|
+ |
|
514
|
514
|
|
# Stop if we have reached pagination size
|
515
|
515
|
|
if len(commits) >= LOG_PAGINATION:
|
516
|
516
|
|
break
|
517
|
|
- |
|
|
517
|
+ |
|
518
|
518
|
|
commits.append(commit)
|
519
|
|
- |
|
|
519
|
+ |
|
520
|
520
|
|
# Diff with parent tree, or empty tree if there's no parent
|
521
|
521
|
|
if LOG_STATS:
|
522
|
522
|
|
diff[commit.short_id] = \
|
523
|
523
|
|
commit.parents[0].tree.diff_to_tree(commit.tree) \
|
524
|
524
|
|
if len(commit.parents) > 0 \
|
525
|
525
|
|
else commit.tree.diff_to_tree(swap=True)
|
526
|
|
- |
|
|
526
|
+ |
|
527
|
527
|
|
return template(
|
528
|
528
|
|
'repository/log.html',
|
529
|
529
|
|
heads=heads, head_ref=HEAD, tags=tags,
|
538
|
538
|
|
This route is used by the <form> in the log page when changing the revision
|
539
|
539
|
|
to be displayed.
|
540
|
540
|
|
"""
|
541
|
|
- |
|
|
541
|
+ |
|
542
|
542
|
|
revision = request.forms.get('revision')
|
543
|
|
- |
|
|
543
|
+ |
|
544
|
544
|
|
bottle.redirect(application.get_url('log',
|
545
|
545
|
|
repository=repository,
|
546
|
546
|
|
revision=revision))
|
554
|
554
|
|
"""
|
555
|
555
|
|
Show a commit.
|
556
|
556
|
|
"""
|
557
|
|
- |
|
|
557
|
+ |
|
558
|
558
|
|
repository += '.git'
|
559
|
559
|
|
repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
560
|
|
- |
|
|
560
|
+ |
|
561
|
561
|
|
if not os.path.isdir(repository_path):
|
562
|
562
|
|
bottle.abort(404, 'No repository at this path.')
|
563
|
|
- |
|
|
563
|
+ |
|
564
|
564
|
|
repo = pygit2.Repository(repository_path)
|
565
|
|
- |
|
|
565
|
+ |
|
566
|
566
|
|
try:
|
567
|
567
|
|
commit = repo.get(commit_id)
|
568
|
568
|
|
assert commit.type == pygit2.GIT_OBJ_COMMIT
|
569
|
|
- |
|
|
569
|
+ |
|
570
|
570
|
|
if commit_id2:
|
571
|
571
|
|
commit2 = repo.get(commit_id2)
|
572
|
572
|
|
assert commit2.type == pygit2.GIT_OBJ_COMMIT
|
577
|
577
|
|
commit2 = None
|
578
|
578
|
|
except:
|
579
|
579
|
|
bottle.abort(404, 'Not a valid commit.')
|
580
|
|
- |
|
|
580
|
+ |
|
581
|
581
|
|
# Diff options
|
582
|
|
- |
|
|
582
|
+ |
|
583
|
583
|
|
diff_mode = DIFF_VIEW
|
584
|
584
|
|
if 'mode' in request.query:
|
585
|
585
|
|
if request.query.get('mode') in [ 'udiff', 'udiff_raw', 'ssdiff' ]:
|
586
|
586
|
|
diff_mode = request.query.get('mode')
|
587
|
587
|
|
else:
|
588
|
588
|
|
bottle.abort(400, 'Bad request: mode')
|
589
|
|
- |
|
|
589
|
+ |
|
590
|
590
|
|
try: diff_context_lines = int(request.query.get('context_lines', DIFF_CONTEXT_LINES))
|
591
|
591
|
|
except: bottle.abort(400, 'Bad request: context_lines')
|
592
|
|
- |
|
|
592
|
+ |
|
593
|
593
|
|
try: diff_inter_hunk_lines = int(request.query.get('inter_hunk_lines', DIFF_INTERHUNK_LINES))
|
594
|
594
|
|
except: bottle.abort(400, 'Bad request: inter_hunk_lines')
|
595
|
|
- |
|
|
595
|
+ |
|
596
|
596
|
|
diff_flags = pygit2.GIT_DIFF_NORMAL
|
597
|
597
|
|
diff_side = DIFF_SIDE
|
598
|
598
|
|
if 'side' in request.query:
|
603
|
603
|
|
diff_side = 'reverse'
|
604
|
604
|
|
else:
|
605
|
605
|
|
bottle.abort(400, 'Bad request: side')
|
606
|
|
- |
|
|
606
|
+ |
|
607
|
607
|
|
diff_whitespace = DIFF_WHITESPACE
|
608
|
608
|
|
if 'whitespace' in request.query:
|
609
|
609
|
|
if request.query.get('whitespace') == 'include':
|
619
|
619
|
|
diff_whitespace = 'ignore_eol'
|
620
|
620
|
|
else:
|
621
|
621
|
|
bottle.abort(400, 'Bad request: whitespace')
|
622
|
|
- |
|
|
622
|
+ |
|
623
|
623
|
|
# Compute diff with parent
|
624
|
|
- |
|
|
624
|
+ |
|
625
|
625
|
|
if commit2:
|
626
|
626
|
|
diff = repo.diff(
|
627
|
627
|
|
a=commit2, b=commit, flags=diff_flags,
|
633
|
633
|
|
interhunk_lines = diff_inter_hunk_lines,
|
634
|
634
|
|
flags = diff_flags,
|
635
|
635
|
|
swap = True)
|
636
|
|
- |
|
|
636
|
+ |
|
637
|
637
|
|
# Compute the similarity index. This is used to decide which files are "renamed".
|
638
|
638
|
|
diff.find_similar()
|
639
|
|
- |
|
|
639
|
+ |
|
640
|
640
|
|
if request.route.name in [ 'commit_diff', 'commit_diff2' ]:
|
641
|
641
|
|
response.content_type = 'text/plain'
|
642
|
642
|
|
return diff.patch
|
643
|
|
- |
|
|
643
|
+ |
|
644
|
644
|
|
if request.route.name == 'commit_patch':
|
645
|
645
|
|
"""
|
646
|
646
|
|
Looks like pygit2 doesn't have a way for creating a patch file from a commit.
|
647
|
647
|
|
So, exclusively for this task, we fork a new subprocess and read the output.
|
648
|
648
|
|
TODO see if this subprocess call can be replaced with pygit2.
|
649
|
649
|
|
"""
|
650
|
|
- |
|
|
650
|
+ |
|
651
|
651
|
|
try:
|
652
|
652
|
|
output = subprocess.check_output(
|
653
|
653
|
|
args=[ 'git', 'format-patch',
|
657
|
657
|
|
'--inter-hunk-context={}'.format(diff_inter_hunk_lines),
|
658
|
658
|
|
'-1', str(commit.id) ],
|
659
|
659
|
|
cwd=repository_path)
|
660
|
|
- |
|
|
660
|
+ |
|
661
|
661
|
|
response.content_type = 'text/plain'
|
662
|
662
|
|
return output
|
663
|
663
|
|
except Exception as e:
|
664
|
664
|
|
print(e)
|
665
|
665
|
|
bottle.abort(500, 'Cannot create patch.')
|
666
|
|
- |
|
|
666
|
+ |
|
667
|
667
|
|
return template(
|
668
|
668
|
|
'repository/commit.html',
|
669
|
669
|
|
repository=repository, commit=commit, commit2=commit2, diff=diff,
|
675
|
675
|
|
"""
|
676
|
676
|
|
Return a raw blow object.
|
677
|
677
|
|
"""
|
678
|
|
- |
|
|
678
|
+ |
|
679
|
679
|
|
repository += '.git'
|
680
|
680
|
|
repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
681
|
|
- |
|
|
681
|
+ |
|
682
|
682
|
|
if not os.path.isdir(repository_path):
|
683
|
683
|
|
bottle.abort(404, 'No repository at this path.')
|
684
|
|
- |
|
|
684
|
+ |
|
685
|
685
|
|
repo = pygit2.Repository(repository_path)
|
686
|
|
- |
|
|
686
|
+ |
|
687
|
687
|
|
if repo.is_empty:
|
688
|
688
|
|
return ""
|
689
|
|
- |
|
|
689
|
+ |
|
690
|
690
|
|
git_tree = None
|
691
|
|
- |
|
|
691
|
+ |
|
692
|
692
|
|
try:
|
693
|
693
|
|
git_object = repo.revparse_single(revision)
|
694
|
694
|
|
except:
|
695
|
695
|
|
pass
|
696
|
|
- |
|
|
696
|
+ |
|
697
|
697
|
|
if not git_object or git_object.type != pygit2.GIT_OBJ_COMMIT:
|
698
|
698
|
|
bottle.abort(404, 'Not a valid revision.')
|
699
|
|
- |
|
|
699
|
+ |
|
700
|
700
|
|
blob = None
|
701
|
|
- |
|
|
701
|
+ |
|
702
|
702
|
|
try:
|
703
|
703
|
|
blob = git_object.tree[tree_path]
|
704
|
704
|
|
except:
|
705
|
705
|
|
bottle.abort(404, 'Object does not exist.')
|
706
|
|
- |
|
|
706
|
+ |
|
707
|
707
|
|
if blob.type != pygit2.GIT_OBJ_BLOB:
|
708
|
708
|
|
bottle.abort(404, 'Object is not a blob.')
|
709
|
|
- |
|
|
709
|
+ |
|
710
|
710
|
|
mime = magic.from_buffer(blob.data[:1048576], mime=True)
|
711
|
711
|
|
response.content_type = mime
|
712
|
712
|
|
return blob.data
|
721
|
721
|
|
Note that this controller only matches "git-upload-pack" (used for fetching)
|
722
|
722
|
|
but does not match "git-receive-pack" (used for pushing). Pushing should only
|
723
|
723
|
|
happen via SSH.
|
724
|
|
- |
|
|
724
|
+ |
|
725
|
725
|
|
Note: If CLIF is running behind a web server such as httpd or lighttpd, the
|
726
|
726
|
|
same behavior of this controller can be achieved much more simply by configuring
|
727
|
727
|
|
the server with CGI and an alias that redirects the URLs above to the gitolite-shell
|
728
|
728
|
|
script. However, this controller exists so that anonymous HTTP clones can work
|
729
|
729
|
|
"out of the box" without any manual configuration of the server.
|
730
|
|
- |
|
|
730
|
+ |
|
731
|
731
|
|
Documentation useful for understanding how this works:
|
732
|
732
|
|
https://git-scm.com/docs/http-protocol
|
733
|
733
|
|
https://bottlepy.org/docs/dev/async.html
|
734
|
734
|
|
https://gitolite.com/gitolite/http.html#allowing-unauthenticated-access
|
735
|
735
|
|
"""
|
736
|
|
- |
|
|
736
|
+ |
|
737
|
737
|
|
# Environment variables for the Gitolite shell
|
738
|
738
|
|
# TODO Gitolite gives a warning: "WARNING: Use of uninitialized value in concatenation (.) or string at /home/git/bin/gitolite-shell line 239"
|
739
|
739
|
|
# Looks like some non-critical env vars are missing here: REMOTE_PORT SERVER_ADDR SERVER_PORT
|
740
|
740
|
|
gitenv = {
|
741
|
741
|
|
**os.environ,
|
742
|
|
- |
|
|
742
|
+ |
|
743
|
743
|
|
# https://git-scm.com/docs/git-http-backend#_environment
|
744
|
744
|
|
'PATH_INFO': request.path,
|
745
|
745
|
|
'REMOTE_USER': 'anonymous', # This user must be set in ~/.gitolite.rc like this:
|
750
|
750
|
|
'REQUEST_METHOD': request.method,
|
751
|
751
|
|
'GIT_PROJECT_ROOT': GITOLITE_REPOSITORIES_ROOT,
|
752
|
752
|
|
'GIT_HTTP_EXPORT_ALL': 'true',
|
753
|
|
- |
|
|
753
|
+ |
|
754
|
754
|
|
# Additional variables required by Gitolite
|
755
|
755
|
|
'REQUEST_URI': request.fullpath,
|
756
|
756
|
|
'GITOLITE_HTTP_HOME': GITOLITE_HTTP_HOME,
|
757
|
757
|
|
'HOME': GITOLITE_HTTP_HOME,
|
758
|
758
|
|
}
|
759
|
|
- |
|
|
759
|
+ |
|
760
|
760
|
|
# Start a Gitolite shell.
|
761
|
761
|
|
# Do not replace .Popen() with .run() because it waits for child process to finish before returning.
|
762
|
762
|
|
proc = subprocess.Popen(
|
765
|
765
|
|
stdin = subprocess.PIPE,
|
766
|
766
|
|
stdout = subprocess.PIPE)
|
767
|
767
|
|
# stderr = )
|
768
|
|
- |
|
|
768
|
+ |
|
769
|
769
|
|
# Write the whole request body to Gitolite stdin.
|
770
|
770
|
|
# Don't forget to close the pipe or it will hang!
|
771
|
771
|
|
proc.stdin.write(request.body.read())
|
772
|
772
|
|
proc.stdin.close()
|
773
|
|
- |
|
|
773
|
+ |
|
774
|
774
|
|
# Now we process the Gitolite response and return it to the client.
|
775
|
|
- |
|
|
775
|
+ |
|
776
|
776
|
|
# First we need to scan all the HTTP headers in the response so that we can
|
777
|
777
|
|
# add them to the bottle response...
|
778
|
778
|
|
for line in proc.stdout:
|
793
|
793
|
|
def threads(repository):
|
794
|
794
|
|
"""
|
795
|
795
|
|
List email threads.
|
796
|
|
- |
|
|
796
|
+ |
|
797
|
797
|
|
:param repository: Match repository name NOT ending with ".git"
|
798
|
798
|
|
"""
|
799
|
|
- |
|
|
799
|
+ |
|
800
|
800
|
|
# List of seletected tags, retrieved from the query string
|
801
|
801
|
|
query_tags = { k: request.query.getall(k) for k in request.query.keys() }
|
802
|
|
- |
|
|
802
|
+ |
|
803
|
803
|
|
repository += '.mlist.git'
|
804
|
804
|
|
path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
805
|
805
|
|
list_address = '{}@{}'.format(repository[:-10], INSTANCE_DOMAIN)
|
806
|
|
- |
|
|
806
|
+ |
|
807
|
807
|
|
if not os.path.isdir(path):
|
808
|
808
|
|
bottle.abort(404, 'No repository at this path.')
|
809
|
|
- |
|
|
809
|
+ |
|
810
|
810
|
|
try:
|
811
|
811
|
|
repo = pygit2.Repository(path)
|
812
|
812
|
|
tree = repo.revparse_single('HEAD').tree
|
813
|
813
|
|
except:
|
814
|
814
|
|
return template('mailing_list/emails.html', list_address=list_address, repository=repository)
|
815
|
|
- |
|
|
815
|
+ |
|
816
|
816
|
|
threads_list = []
|
817
|
817
|
|
tags = {}
|
818
|
|
- |
|
|
818
|
+ |
|
819
|
819
|
|
for obj in tree:
|
820
|
820
|
|
if obj.type != pygit2.GIT_OBJ_TREE:
|
821
|
821
|
|
continue
|
822
|
|
- |
|
|
822
|
+ |
|
823
|
823
|
|
thread_date, thread_time, thread_id, thread_title = obj.name.split(' ', 3)
|
824
|
|
- |
|
|
824
|
+ |
|
825
|
825
|
|
try:
|
826
|
826
|
|
thread_tags = parse_thread_tags(obj['tags'].data.decode('UTF-8'))
|
827
|
|
- |
|
|
827
|
+ |
|
828
|
828
|
|
# Collect tags for filters
|
829
|
829
|
|
for k, v in thread_tags.items():
|
830
|
830
|
|
tags[k] = tags.get(k, set()).union(v)
|
831
|
831
|
|
except:
|
832
|
832
|
|
thread_tags = {}
|
833
|
|
- |
|
|
833
|
+ |
|
834
|
834
|
|
# Check if we should filter out this thread from the list
|
835
|
835
|
|
keep = True
|
836
|
836
|
|
for key in query_tags.keys():
|
837
|
837
|
|
for value in query_tags[key]:
|
838
|
838
|
|
action, value = value[0], value[1:]
|
839
|
|
- |
|
|
839
|
+ |
|
840
|
840
|
|
if action not in [ '+', '-' ]:
|
841
|
841
|
|
bottle.abort(400, 'Bad request: {}'.format(key))
|
842
|
|
- |
|
|
842
|
+ |
|
843
|
843
|
|
if action == '+' and value not in thread_tags.get(key, []):
|
844
|
844
|
|
keep = False
|
845
|
845
|
|
break
|
846
|
|
- |
|
|
846
|
+ |
|
847
|
847
|
|
if action == '-' and value in thread_tags.get(key, []):
|
848
|
848
|
|
keep = False
|
849
|
849
|
|
break
|
850
|
|
- |
|
|
850
|
+ |
|
851
|
851
|
|
if not keep: break
|
852
|
|
- |
|
|
852
|
+ |
|
853
|
853
|
|
if keep:
|
854
|
854
|
|
threads_list.append({
|
855
|
855
|
|
'datetime': thread_date + ' ' + thread_time,
|
857
|
857
|
|
'title': thread_title,
|
858
|
858
|
|
'tags': thread_tags
|
859
|
859
|
|
})
|
860
|
|
- |
|
|
860
|
+ |
|
861
|
861
|
|
threads_list.reverse()
|
862
|
|
- |
|
|
862
|
+ |
|
863
|
863
|
|
return template('mailing_list/emails.html', threads=threads_list,
|
864
|
864
|
|
list_address=list_address,
|
865
|
865
|
|
repository=repository,
|
870
|
870
|
|
"""
|
871
|
871
|
|
Show a single email thread.
|
872
|
872
|
|
"""
|
873
|
|
- |
|
|
873
|
+ |
|
874
|
874
|
|
repository += '.mlist.git'
|
875
|
875
|
|
path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
|
876
|
876
|
|
list_address = '{}@{}'.format(repository[:-10], INSTANCE_DOMAIN)
|
877
|
|
- |
|
|
877
|
+ |
|
878
|
878
|
|
if not os.path.isdir(path):
|
879
|
879
|
|
bottle.abort(404, 'No repository at this path.')
|
880
|
|
- |
|
|
880
|
+ |
|
881
|
881
|
|
repo = pygit2.Repository(path)
|
882
|
882
|
|
head_tree = repo.revparse_single('HEAD').tree
|
883
|
883
|
|
thread_tree = None
|
884
|
|
- |
|
|
884
|
+ |
|
885
|
885
|
|
for obj in head_tree:
|
886
|
886
|
|
if obj.type != pygit2.GIT_OBJ_TREE:
|
887
|
887
|
|
continue
|
888
|
|
- |
|
|
888
|
+ |
|
889
|
889
|
|
if thread_id in obj.name:
|
890
|
890
|
|
thread_tree = obj
|
891
|
891
|
|
break
|
892
|
|
- |
|
|
892
|
+ |
|
893
|
893
|
|
if not thread_tree:
|
894
|
894
|
|
bottle.abort(404, 'Not a valid thread')
|
895
|
|
- |
|
|
895
|
+ |
|
896
|
896
|
|
thread_date, thread_time, thread_id, thread_title = thread_tree.name.split(' ', 3)
|
897
|
897
|
|
thread_data = {
|
898
|
898
|
|
'datetime': thread_date + ' ' + thread_time,
|
899
|
899
|
|
'id': thread_id,
|
900
|
900
|
|
'title': thread_title
|
901
|
901
|
|
}
|
902
|
|
- |
|
|
902
|
+ |
|
903
|
903
|
|
# Read all the emails in this thread and collect some statistics on the way (for
|
904
|
904
|
|
# displaying purposes only)
|
905
|
905
|
|
emails = []
|
906
|
906
|
|
participants = []
|
907
|
907
|
|
tags = {}
|
908
|
|
- |
|
|
908
|
+ |
|
909
|
909
|
|
for obj in thread_tree:
|
910
|
910
|
|
if obj.type != pygit2.GIT_OBJ_BLOB:
|
911
|
911
|
|
continue
|
912
|
|
- |
|
|
912
|
+ |
|
913
|
913
|
|
if obj.name == 'tags':
|
914
|
914
|
|
tags = parse_thread_tags(obj.data.decode('UTF-8'))
|
915
|
915
|
|
continue
|
916
|
|
- |
|
|
916
|
+ |
|
917
|
917
|
|
if not obj.name.endswith('.email'):
|
918
|
918
|
|
continue
|
919
|
|
- |
|
|
919
|
+ |
|
920
|
920
|
|
message = email.message_from_string(obj.data.decode('UTF-8'), policy=email.policy.default)
|
921
|
|
- |
|
|
921
|
+ |
|
922
|
922
|
|
email_data = {
|
923
|
923
|
|
'id': message.get('message-id'),
|
924
|
924
|
|
'id_hash': hashlib.sha256(message.get('message-id').encode('utf-8')).hexdigest()[:8],
|
930
|
930
|
|
'subject': message.get('subject'),
|
931
|
931
|
|
'body': message.get_body(('plain',)).get_content()
|
932
|
932
|
|
}
|
933
|
|
- |
|
|
933
|
+ |
|
934
|
934
|
|
emails.append(email_data)
|
935
|
|
- |
|
|
935
|
+ |
|
936
|
936
|
|
if email_data['from'] not in participants:
|
937
|
937
|
|
participants.append(email_data['from'])
|
938
|
|
- |
|
|
938
|
+ |
|
939
|
939
|
|
emails.sort(key = lambda email: email['received_at'])
|
940
|
|
- |
|
|
940
|
+ |
|
941
|
941
|
|
return template('mailing_list/emails_thread.html', thread=thread_data, emails=emails,
|
942
|
942
|
|
participants=participants, list_address=list_address, tags=tags,
|
943
|
943
|
|
repository=repository)
|