Escape rich text tags when we display the commit message.
[hgct.git] / main.py
blobf262f15e2045d4f974a8a732c6d8bacba21fa6dc
1 #!/usr/bin/env python
3 # Copyright (c) 2005 Fredrik Kuivinen <freku045@student.liu.se>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License version 2 as
7 # published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18 import sys, math, random, qt, os, re, signal
20 # PyQt3 and python 2.4 isn't currently available together on
21 # Debian/testing we do therefore use the following quite ugly work
22 # around. The module 'mysubprocess' is just a copy of the 'subprocess'
23 # module from the Python 2.4 distribution.
24 ver = sys.version_info
25 if ver[0] < 2 or (ver[0] == 2 and ver[1] <= 3):
26 import mysubprocess
27 subprocess = mysubprocess
28 else:
29 import subprocess
31 qconnect = qt.QObject.connect
32 Qt = qt.Qt
33 applicationName = 'Git Commit Tool'
34 shortName = 'gct'
35 version = 'v0.1'
36 DEBUG = 0
38 def debug(str):
39 if DEBUG:
40 print str
42 class CommitError(Exception):
43 def __init__(self, operation, msg):
44 self.operation = operation
45 self.msg = msg
47 class FileState:
48 pass
50 class MyListItem(qt.QCheckListItem):
51 def __init__(self, parent, file, inSync, commitMsg = False):
52 if inSync:
53 status = 'In sync '
54 else:
55 status = 'Working directory out of sync '
56 if commitMsg:
57 status = ''
59 qt.QListViewItem.__init__(self, parent, file.text, qt.QCheckListItem.CheckBox)
60 self.setText(1, status)
61 self.inSync = inSync
62 self.file = file
63 self.commitMsg = commitMsg
65 def compare(self, item, col, asc):
66 if self.commitMsg:
67 if asc:
68 return -1
69 else:
70 return 1
71 elif item.commitMsg:
72 if asc:
73 return 1
74 else:
75 return -1
76 else:
77 return cmp(self.key(col, asc), item.key(col, asc))
79 def paintCell(self, p, cg, col, w, a):
80 if col == 1 and not self.inSync:
81 cg.setColor(qt.QColorGroup.Text, Qt.red)
82 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
83 cg.setColor(qt.QColorGroup.Text, Qt.black)
84 else:
85 if self.commitMsg:
86 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
87 else:
88 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
90 def isSelected(self):
91 return self.state() == qt.QCheckListItem.On
93 def setSelected(self, s):
94 if s:
95 self.setState(qt.QCheckListItem.On)
96 else:
97 self.setState(qt.QCheckListItem.Off)
99 class MyListView(qt.QListView):
100 def __init__(self, parent=None, name=None):
101 qt.QListView.__init__(self, parent, name)
103 def __iter__(self):
104 return ListViewIterator(self)
106 class ListViewIterator:
107 def __init__(self, listview):
108 self.it = qt.QListViewItemIterator(listview)
110 def next(self):
111 cur = self.it.current()
112 if cur:
113 self.it += 1
114 if cur.commitMsg:
115 return self.next()
116 else:
117 return cur
118 else:
119 raise StopIteration()
121 def __iter__(self):
122 return self
124 class MainWidget(qt.QMainWindow):
125 def __init__(self, parent=None, name=None):
126 qt.QMainWindow.__init__(self, parent, name)
127 splitter = qt.QSplitter(Qt.Vertical, self)
129 fW = MyListView(splitter)
130 fW.setFocus()
131 fW.setSelectionMode(qt.QListView.NoSelection)
132 fW.addColumn('Description')
133 statusTitle = 'Cache Status'
134 fW.addColumn(statusTitle)
135 fW.setResizeMode(qt.QListView.AllColumns)
136 fW.header().setStretchEnabled(1, False)
137 fW.setColumnWidth(1, self.fontMetrics().width(statusTitle + 'xxx'))
139 text = qt.QWidgetStack(splitter)
141 self.setCentralWidget(splitter)
142 self.setCaption(applicationName)
144 self.newCurLambda = lambda i: self.currentChange(i)
145 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
147 ops = qt.QPopupMenu(self)
148 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
149 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
150 ops.insertItem("Update Cache for Selected Files", self.updateCacheSelected, Qt.CTRL+Qt.Key_U)
152 m = self.menuBar()
153 m.insertItem("&Operations", ops)
155 h = qt.QPopupMenu(self)
156 h.insertItem("&About", self.about)
157 m.insertItem("&Help", h)
159 qconnect(fW, qt.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
160 self.contextMenuRequestedSlot)
161 self.fileOps = qt.QPopupMenu(self)
162 self.fileOps.insertItem("(Un)select", self.toggleFile)
163 self.fileOps.insertItem("Edit", self.editFile, Qt.CTRL+Qt.Key_E)
165 # The following attribute is set by contextMenuRequestedSlot and used
166 # by the fileOps
167 self.currentContextItem = None
169 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
171 self.filesW = fW
172 self.files = []
173 self.splitter = splitter
174 self.text = text
176 f = File()
177 f.text = "Commit message"
178 f.textW = self.newTextEdit()
179 f.textW.setTextFormat(Qt.PlainText)
180 f.textW.setReadOnly(False)
181 self.cmitFile = f
182 self.createCmitItem()
183 self.splitter.setSizes(eval(str(settings.readEntry('splitter', '[400, 200]')[0])))
185 def closeEvent(self, e):
186 p = self.pos()
187 settings.writeEntry('x', p.x()),
188 settings.writeEntry('y', p.y()),
190 s = self.size()
191 settings.writeEntry('width', s.width()),
192 settings.writeEntry('height', s.height())
194 settings.writeEntry('splitter', str(self.splitter.sizes()))
195 e.accept()
197 def createCmitItem(self):
198 self.cmitItem = MyListItem(self.filesW, self.cmitFile, False, True)
199 self.cmitItem.setSelectable(False)
200 self.filesW.insertItem(self.cmitItem)
202 def about(self, ignore):
203 qt.QMessageBox.about(self, "About " + applicationName,
204 "<qt><center><h1>" + applicationName + " " + version + """</h1></center>\n
205 <center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;
206 </center>\n<p>This program is free software; you can redistribute it and/or
207 modify it under the terms of the GNU General Public License version 2 as
208 published by the Free Software Foundation.</p></qt>""")
210 def contextMenuRequestedSlot(self, item, pos, col):
211 if item and not item.commitMsg:
212 self.currentContextItem = item
213 self.fileOps.exec_loop(qt.QCursor.pos())
214 else:
215 self.currentContextItem = None
217 def toggleFile(self, ignored):
218 it = self.currentContextItem
219 if not it:
220 return
222 if it.isSelected():
223 it.setSelected(False)
224 else:
225 it.setSelected(True)
227 def editFile(self, ignored):
228 it = self.currentContextItem
229 if not it:
230 return
232 ed = getEditor()
233 if not ed:
234 qt.QMessageBox.warning(self, 'No editor found',
235 '''No editor found. Gct looks for an editor to execute in the environment
236 variable GCT_EDITOR, if that variable is not set it will use the variable
237 EDITOR.''')
238 return
239 # We can't use runProgram([ed, it.file.dstName]) here because
240 # ed might be something like 'xemacs -nw' which has to be
241 # interpreted by the shell.
242 try:
243 runProgram(ed + ' ' + shellQuote(it.file.dstName))
244 doUpdateCache(it.file.dstName)
245 self.refreshFiles()
246 except ProgramError, e:
247 qt.QMessageBox.warning(self, 'Failed to launch editor',
248 '''Gct failed to launch the editor. The command used was: ''' + e.program)
250 def currentChange(self, item):
251 self.text.raiseWidget(item.file.textW)
252 self.text.update()
254 def updateCacheSelected(self, id):
255 for it in self.selectedItems():
256 doUpdateCache(it.file.dstName)
257 self.refreshFiles()
259 def selectedItems(self):
260 ret = []
261 for item in self.filesW:
262 if item.isSelected():
263 ret.append(item)
264 return ret
266 def commit(self, id):
267 selFileNames = []
268 keepFiles = []
270 for item in self.filesW:
271 debug("file: " + item.file.text)
272 if item.isSelected():
273 selFileNames.append(item.file.text)
274 else:
275 keepFiles.append(item.file)
277 commitMsg = str(self.cmitItem.file.textW.text())
279 if not selFileNames:
280 qt.QMessageBox.information(self, "Commit - " + applicationName,
281 "No files selected for commit.", "&Ok")
282 return
284 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
285 if(qt.QMessageBox.question(self, "Confirm Commit - " + applicationName,
286 '''<qt><p>Do you want to commit the following file(s):</p><blockquote>''' +
287 '<br>'.join(selFileNames) +
288 '''</blockquote><p>with the commit message:</p><blockquote><pre>''' +
289 str(qt.QStyleSheet.escape(commitMsg)) + '</pre></blockquote></qt>',
290 '&Yes', '&No')):
291 return
292 else:
293 try:
294 doCommit(keepFiles, commitMsg)
295 except CommitError, e:
296 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
297 "Commit failed during " + e.operation + ": " + e.msg,
298 '&Ok')
299 except OSError, e:
300 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
301 "Commit failed: " + e.strerror,
302 '&Ok')
303 else:
304 self.cmitItem.file.textW.setText('')
305 self.refreshFiles()
307 def getFileState(self):
308 ret = FileState()
309 cur = self.filesW.currentItem()
310 if cur and cur != self.cmitItem:
311 ret.current = self.filesW.currentItem().file.srcName
312 else:
313 ret.current = None
314 ret.selected = {}
316 for x in self.filesW:
317 if x.isSelected():
318 ret.selected[x.file.srcName] = True
319 return ret
321 def restoreFileState(self, state):
322 for x in self.filesW:
323 if state.selected.has_key(x.file.srcName):
324 x.setSelected(True)
325 if x.file.srcName == state.current:
326 self.filesW.setCurrentItem(x)
328 def newTextEdit(self):
329 ret = qt.QTextEdit()
330 self.text.addWidget(ret)
331 return ret
333 def setFiles(self, files):
334 state = self.getFileState()
335 self.filesW.clear()
336 self.createCmitItem()
337 for f in self.files:
338 self.text.removeWidget(f.textW)
340 self.files = []
341 for f in files:
342 f.textW = self.newTextEdit()
343 f.textW.setReadOnly(False)
344 f.textW.setTextFormat(Qt.RichText)
345 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
346 self.files.append(f)
347 self.filesW.insertItem(MyListItem(self.filesW, f, f.updated))
349 self.filesW.setCurrentItem(self.cmitItem)
351 # For some reason the currentChanged signal isn't emitted
352 # here. We call currentChange ourselves instead.
353 self.currentChange(self.cmitItem)
355 self.restoreFileState(state)
357 def refreshFiles(self, ignored=None):
358 updateCache()
359 self.setFiles(getFiles())
361 def updateCache():
362 cacheHeadDiff = parseDiff('git-diff-cache -z --cached HEAD')
364 # The set of files that are different in the cache compared to HEAD
365 cacheHeadChange = {}
366 for f in cacheHeadDiff:
367 cacheHeadChange[f.srcName] = True
369 noncacheHeadDiff = parseDiff('git-diff-cache -z HEAD')
370 for f in noncacheHeadDiff:
371 if (f.srcSHA == '0'*40 or f.dstSHA == '0'*40) and not cacheHeadChange.has_key(f.srcName):
372 runProgram(['git-update-cache', '--remove', f.srcName])
374 def doUpdateCache(filename):
375 runProgram(['git-update-cache', '--remove', '--add', '--replace', filename])
377 def doCommit(filesToKeep, msg):
378 for file in filesToKeep:
379 # If we have a new file in the cache which we do not want to
380 # commit we have to remove it from the cache. We will add this
381 # cache entry back in to the cache at the end of this
382 # function.
383 if file.change == 'N':
384 runProgram(['git-update-cache', '--force-remove', file.srcName])
385 else:
386 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
387 file.srcMode, file.srcSHA, file.srcName])
389 tree = runProgram(['git-write-tree'])
390 tree = tree.rstrip()
391 commit = runProgram(['git-commit-tree', tree, '-p', 'HEAD'], msg)
392 commit = commit.rstrip()
394 try:
395 f = open(os.environ['GIT_DIR'] + '/HEAD', 'w+')
396 f.write(commit)
397 f.close()
398 except OSError, e:
399 raise CommitError('write to ' + os.environ['GIT_DIR'] + '/HEAD', e.strerror)
401 try:
402 os.unlink(os.environ['GIT_DIR'] + '/MERGE_HEAD')
403 except OSError:
404 pass
406 for file in filesToKeep:
407 # Don't add files that are going to be deleted back to the cache
408 if file.change != 'D':
409 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
410 file.dstMode, file.dstSHA, file.dstName])
413 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
414 def fixCommitMsgWhiteSpace(msg):
415 msg = msg.lstrip()
416 msg = msg.rstrip()
417 msg = re.sub(commitMsgRE, '\n', msg)
418 msg += '\n'
419 return msg
421 class File:
422 pass
424 parseDiffRE = re.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
425 def parseDiff(prog):
426 inp = runProgram(prog)
427 ret = []
428 try:
429 recs = inp.split("\0")
430 recs.pop() # remove last entry (which is '')
431 it = recs.__iter__()
432 while True:
433 rec = it.next()
434 m = parseDiffRE.match(rec)
436 if not m:
437 print "Unknown output from " + str(prog) + "!: " + rec + "\n"
438 continue
440 f = File()
441 f.srcMode = m.group(1)
442 f.dstMode = m.group(2)
443 f.srcSHA = m.group(3)
444 f.dstSHA = m.group(4)
445 f.change = m.group(5)
446 f.score = m.group(6)
447 f.srcName = f.dstName = it.next()
449 if f.change == 'C' or f.change == 'R':
450 f.dstName = it.next()
451 f.patch = getPatch(f.srcName, f.dstName)
452 else:
453 f.patch = getPatch(f.srcName)
455 ret.append(f)
456 except StopIteration:
457 pass
458 return ret
461 # HEAD is src in the returned File objects. That is, srcName is the
462 # name in HEAD and dstName is the name in the cache.
463 def getFiles():
464 files = parseDiff('git-diff-cache -z -M --cached HEAD')
465 for f in files:
466 c = f.change
467 if c == 'C':
468 f.text = 'Copy from ' + f.srcName + ' to ' + f.dstName
469 elif c == 'R':
470 f.text = 'Rename from ' + f.srcName + ' to ' + f.dstName
471 elif c == 'N' or c == 'A':
472 f.text = 'New file: ' + f.srcName
473 elif c == 'D':
474 f.text = 'Deleted file: ' + f.srcName
475 elif c == 'T':
476 f.text = 'Type change: ' + f.srcName
477 else:
478 f.text = f.srcName
480 if len(parseDiff(['git-diff-files', '-z', f.dstName])) > 0:
481 f.updated = False
482 else:
483 f.updated = True
484 return files
486 def getPatch(file, otherFile = None):
487 if otherFile:
488 f = [file, otherFile]
489 else:
490 f = [file]
491 return runProgram(['git-diff-cache', '-p', '-M', '--cached', 'HEAD'] + f)
493 def formatPatchRichText(patch, colors):
494 ret = ['<qt><pre><font color="', colors['std'], '">']
495 prev = ' '
496 for l in patch.split('\n'):
497 if len(l) > 0:
498 c = l[0]
499 else:
500 c = ' '
502 if c != prev:
503 if c == '+': style = 'new'
504 elif c == '-': style = 'remove'
505 elif c == '@': style = 'head'
506 else: style = 'std'
507 ret.extend(['</font><font color="', colors[style], '">'])
508 prev = c
509 ret.extend([str(qt.QStyleSheet.escape(l)), '\n'])
510 ret.append('</pre></qt>')
511 return ''.join(ret)
513 class ProgramError(Exception):
514 def __init__(self, program, err):
515 self.program = program
516 self.error = err
518 def getEditor():
519 if os.environ.has_key('GCT_EDITOR'):
520 return os.environ['GCT_EDITOR']
521 elif os.environ.has_key('EDITOR'):
522 return os.environ['EDITOR']
523 else:
524 return None
526 def shellQuote(str):
527 res = ''
528 for c in str:
529 if c == '\\':
530 res += '\\'
531 elif c == "'":
532 res += "'\\''"
533 else:
534 res += c
535 return "'" + res + "'"
537 def runProgram(prog, input=None):
538 debug('runProgram prog: ' + str(prog) + " input: " + str(input))
539 if type(prog) is str:
540 progStr = prog
541 else:
542 progStr = ' '.join(prog)
544 try:
545 pop = subprocess.Popen(prog,
546 shell = type(prog) is str,
547 stderr=subprocess.STDOUT,
548 stdout=subprocess.PIPE,
549 stdin=subprocess.PIPE)
550 except OSError, e:
551 debug("strerror: " + e.strerror)
552 raise ProgramError(progStr, e.strerror)
554 if input != None:
555 pop.stdin.write(input)
556 pop.stdin.close()
558 out = pop.stdout.read()
559 code = pop.wait()
560 if code != 0:
561 debug("error output: " + out)
562 raise ProgramError(progStr, out)
563 debug("output: " + out.replace('\0', '\n'))
564 return out
566 if not os.environ.has_key('GIT_DIR'):
567 os.environ['GIT_DIR'] = '.git'
569 def basicsFailed(msg):
570 print "'git-cat-file -t HEAD' failed: " + msg
571 print "Make sure that the current working directory contains a '.git' directory, or\nthat GIT_DIR is set appropriately."
572 sys.exit(1)
574 try:
575 runProgram('git-cat-file -t HEAD')
576 except OSError, e:
577 basicsFailed(e.strerror)
578 except ProgramError, e:
579 basicsFailed(e.error)
581 app = qt.QApplication(sys.argv)
582 settings = qt.QSettings()
583 settings.beginGroup('/' + shortName)
584 settings.beginGroup('/geometry/')
586 mw = MainWidget()
587 mw.refreshFiles()
589 mw.resize(settings.readNumEntry('width', 500)[0],
590 settings.readNumEntry('height', 600)[0])
593 # The following code doesn't work correctly in some (at least
594 # Metacity) window
595 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
596 # information about this issue.
597 # mw.move(settings.readNumEntry('x', 100)[0],
598 # settings.readNumEntry('y', 100)[0])
600 mw.show()
601 app.setMainWidget(mw)
604 # Handle CTRL-C appropriately
605 signal.signal(signal.SIGINT, lambda s, f: app.quit())
607 ret = app.exec_loop()
608 del settings
609 sys.exit(ret)