Use checkboxes to select files.
[hgct.git] / main.py
blob326f770eca4514f8d4651b52d5ceb43e9e0d12c8
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)
62 self.inSync = inSync
63 self.file = file
64 self.commitMsg = commitMsg
66 def compare(self, item, col, asc):
67 if self.commitMsg:
68 if asc:
69 return -1
70 else:
71 return 1
72 elif item.commitMsg:
73 if asc:
74 return 1
75 else:
76 return -1
77 else:
78 return cmp(self.key(col, asc), item.key(col, asc))
80 def paintCell(self, p, cg, col, w, a):
81 if col == 1 and not self.inSync:
82 cg.setColor(qt.QColorGroup.Text, Qt.red)
83 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
84 cg.setColor(qt.QColorGroup.Text, Qt.black)
85 else:
86 if self.commitMsg:
87 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
88 else:
89 qt.QCheckListItem.paintCell(self, p, cg, col, w, a)
91 def isSelected(self):
92 return self.state() == qt.QCheckListItem.On
94 def setSelected(self, s):
95 if s:
96 self.setState(qt.QCheckListItem.On)
97 else:
98 self.setState(qt.QCheckListItem.Off)
100 class MyListView(qt.QListView):
101 def __init__(self, parent=None, name=None):
102 qt.QListView.__init__(self, parent, name)
104 def __iter__(self):
105 return ListViewIterator(self)
107 class ListViewIterator:
108 def __init__(self, listview):
109 self.it = qt.QListViewItemIterator(listview)
111 def next(self):
112 cur = self.it.current()
113 if cur:
114 self.it += 1
115 if cur.commitMsg:
116 return self.next()
117 else:
118 return cur
119 else:
120 raise StopIteration()
122 def __iter__(self):
123 return self
125 class MainWidget(qt.QMainWindow):
126 def __init__(self, parent=None, name=None):
127 qt.QMainWindow.__init__(self, parent, name)
128 splitter = qt.QSplitter(Qt.Vertical, self)
130 fW = MyListView(splitter)
131 fW.setFocus()
132 fW.setSelectionMode(qt.QListView.NoSelection)
133 fW.addColumn('Description')
134 statusTitle = 'Cache Status'
135 fW.addColumn(statusTitle)
136 fW.setResizeMode(qt.QListView.AllColumns)
137 fW.header().setStretchEnabled(1, False)
138 fW.setColumnWidth(1, self.fontMetrics().width(statusTitle + 'xxx'))
140 text = qt.QWidgetStack(splitter)
142 self.setCentralWidget(splitter)
143 self.setCaption(applicationName)
145 self.newCurLambda = lambda i: self.currentChange(i)
146 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newCurLambda)
148 ops = qt.QPopupMenu(self)
149 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
150 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
151 ops.insertItem("Update Cache for Selected Files", self.updateCache, Qt.CTRL+Qt.Key_U)
153 m = self.menuBar()
154 m.insertItem("&Operations", ops)
156 h = qt.QPopupMenu(self)
157 h.insertItem("&About", self.about)
158 m.insertItem("&Help", h)
160 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
162 self.filesW = fW
163 self.files = []
164 self.splitter = splitter
165 self.text = text
167 f = File()
168 f.text = "Commit message"
169 f.textW = self.newTextEdit()
170 f.textW.setTextFormat(Qt.PlainText)
171 f.textW.setReadOnly(False)
172 self.cmitFile = f
173 self.createCmitItem()
174 self.splitter.setSizes(eval(str(settings.readEntry('splitter', '[400, 200]')[0])))
176 def closeEvent(self, e):
177 p = self.pos()
178 settings.writeEntry('x', p.x()),
179 settings.writeEntry('y', p.y()),
181 s = self.size()
182 settings.writeEntry('width', s.width()),
183 settings.writeEntry('height', s.height())
185 settings.writeEntry('splitter', str(self.splitter.sizes()))
186 e.accept()
188 def createCmitItem(self):
189 self.cmitItem = MyListItem(self.filesW, self.cmitFile, False, True)
190 self.cmitItem.setSelectable(False)
191 self.filesW.insertItem(self.cmitItem)
193 def about(self, ignore):
194 qt.QMessageBox.about(self, "About " + applicationName,
195 "<qt><center><h1>" + applicationName + " " + version + "</h1></center>\n" +
196 "<center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;</center>\n" +
197 "<p>This program is free software; you can redistribute it and/or modify " +
198 "it under the terms of the GNU General Public License version 2 as " +
199 "published by the Free Software Foundation.</p></qt>")
201 def currentChange(self, item):
202 self.text.raiseWidget(item.file.textW)
203 self.text.update()
205 def updateCache(self, id):
206 for it in self.selectedItems():
207 doUpdateCache(it.file.dstName)
208 self.refreshFiles()
210 def selectedItems(self):
211 ret = []
212 for item in self.filesW:
213 if item.isSelected():
214 ret.append(item)
215 return ret
217 def commit(self, id):
218 selFileNames = []
219 keepFiles = []
221 for item in self.filesW:
222 debug("file: " + item.file.text)
223 if item.isSelected():
224 selFileNames.append(item.file.text)
225 else:
226 keepFiles.append(item.file)
228 commitMsg = str(self.cmitItem.file.textW.text())
230 if not selFileNames:
231 qt.QMessageBox.information(self, "Commit - " + applicationName,
232 "No files selected for commit.", "&Ok")
233 return
235 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
236 if(qt.QMessageBox.question(self, "Confirm Commit - " + applicationName,
237 '<qt><p>Do you want to commit the following file(s):</p>' +
238 '<blockquote>' + '<br>'.join(selFileNames) + '</blockquote>' +
239 '<p>with the commit message:</p>' +
240 '<blockquote><pre>' + commitMsg + '</pre></blockquote></qt>',
241 '&Yes', '&No')):
242 return
243 else:
244 try:
245 doCommit(keepFiles, commitMsg)
246 except CommitError, e:
247 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
248 "Commit failed during " + e.operation + ": " + e.msg,
249 '&Ok')
250 except OSError, e:
251 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
252 "Commit failed: " + e.strerror,
253 '&Ok')
254 else:
255 self.cmitItem.file.textW.setText('')
256 self.refreshFiles()
258 def getFileState(self):
259 ret = FileState()
260 cur = self.filesW.currentItem()
261 if cur and cur != self.cmitItem:
262 ret.current = self.filesW.currentItem().file.srcName
263 else:
264 ret.current = None
265 ret.selected = {}
267 for x in self.filesW:
268 if x.isSelected():
269 ret.selected[x.file.srcName] = True
270 return ret
272 def restoreFileState(self, state):
273 for x in self.filesW:
274 if state.selected.has_key(x.file.srcName):
275 x.setSelected(True)
276 if x.file.srcName == state.current:
277 self.filesW.setCurrentItem(x)
279 def newTextEdit(self):
280 ret = qt.QTextEdit()
281 self.text.addWidget(ret)
282 return ret
284 def setFiles(self, files):
285 state = self.getFileState()
286 self.filesW.clear()
287 self.createCmitItem()
288 for f in self.files:
289 self.text.removeWidget(f.textW)
291 self.files = []
292 for f in files:
293 f.textW = self.newTextEdit()
294 f.textW.setReadOnly(False)
295 f.textW.setTextFormat(Qt.RichText)
296 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
297 self.files.append(f)
298 self.filesW.insertItem(MyListItem(self.filesW, f, f.updated))
300 self.filesW.setCurrentItem(self.cmitItem)
302 # For some reason the currentChanged signal isn't emitted
303 # here. We call currentChange ourselves instead.
304 self.currentChange(self.cmitItem)
306 self.restoreFileState(state)
308 def refreshFiles(self, ignored=None):
309 updateCache()
310 self.setFiles(getFiles())
312 def updateCache():
313 cacheHeadDiff = parseDiff('git-diff-cache -z --cached HEAD')
315 # The set of files that are different in the cache compared to HEAD
316 cacheHeadChange = {}
317 for f in cacheHeadDiff:
318 cacheHeadChange[f.srcName] = True
320 noncacheHeadDiff = parseDiff('git-diff-cache -z HEAD')
321 for f in noncacheHeadDiff:
322 if (f.srcSHA == '0'*40 or f.dstSHA == '0'*40) and not cacheHeadChange.has_key(f.srcName):
323 runProgram(['git-update-cache', '--remove', f.srcName])
325 def doUpdateCache(filename):
326 runProgram(['git-update-cache', '--remove', '--add', '--replace', filename])
328 def doCommit(filesToKeep, msg):
329 for file in filesToKeep:
330 # If we have a new file in the cache which we do not want to
331 # commit we have to remove it from the cache. We will add this
332 # cache entry back in to the cache at the end of this
333 # function.
334 if file.change == 'N':
335 runProgram(['git-update-cache', '--force-remove', file.srcName])
336 else:
337 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
338 file.srcMode, file.srcSHA, file.srcName])
340 tree = runProgram(['git-write-tree'])
341 tree = tree.rstrip()
342 commit = runProgram(['git-commit-tree', tree, '-p', 'HEAD'], msg)
343 commit = commit.rstrip()
345 try:
346 f = open(os.environ['GIT_DIR'] + '/HEAD', 'w+')
347 f.write(commit)
348 f.close()
349 except OSError, e:
350 raise CommitError('write to ' + os.environ['GIT_DIR'] + '/HEAD', e.strerror)
352 try:
353 os.unlink(os.environ['GIT_DIR'] + '/MERGE_HEAD')
354 except OSError:
355 pass
357 for file in filesToKeep:
358 # Don't add files that are going to be deleted back to the cache
359 if file.change != 'D':
360 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
361 file.dstMode, file.dstSHA, file.dstName])
364 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
365 def fixCommitMsgWhiteSpace(msg):
366 msg = msg.lstrip()
367 msg = msg.rstrip()
368 msg = re.sub(commitMsgRE, '\n', msg)
369 msg += '\n'
370 return msg
372 class File:
373 pass
375 parseDiffRE = re.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
376 def parseDiff(prog):
377 inp = runProgram(prog)
378 ret = []
379 try:
380 recs = inp.split("\0")
381 recs.pop() # remove last entry (which is '')
382 it = recs.__iter__()
383 while True:
384 rec = it.next()
385 m = parseDiffRE.match(rec)
387 if not m:
388 print "Unknown output from " + str(prog) + "!: " + rec + "\n"
389 continue
391 f = File()
392 f.srcMode = m.group(1)
393 f.dstMode = m.group(2)
394 f.srcSHA = m.group(3)
395 f.dstSHA = m.group(4)
396 f.change = m.group(5)
397 f.score = m.group(6)
398 f.srcName = f.dstName = it.next()
400 if f.change == 'C' or f.change == 'R':
401 f.dstName = it.next()
402 f.patch = getPatch(f.srcName, f.dstName)
403 else:
404 f.patch = getPatch(f.srcName)
406 ret.append(f)
407 except StopIteration:
408 pass
409 return ret
412 # HEAD is src in the returned File objects. That is, srcName is the
413 # name in HEAD and dstName is the name in the cache.
414 def getFiles():
415 files = parseDiff('git-diff-cache -z -M --cached HEAD')
416 for f in files:
417 c = f.change
418 if c == 'C':
419 f.text = 'Copy from ' + f.srcName + ' to ' + f.dstName
420 elif c == 'R':
421 f.text = 'Rename from ' + f.srcName + ' to ' + f.dstName
422 elif c == 'N':
423 f.text = 'New file: ' + f.srcName
424 elif c == 'D':
425 f.text = 'Deleted file: ' + f.srcName
426 elif c == 'T':
427 f.text = 'Type change: ' + f.srcName
428 else:
429 f.text = f.srcName
431 if len(parseDiff(['git-diff-files', '-z', f.dstName])) > 0:
432 f.updated = False
433 else:
434 f.updated = True
435 return files
437 def getPatch(file, otherFile = None):
438 if otherFile:
439 f = [file, otherFile]
440 else:
441 f = [file]
442 return runProgram(['git-diff-cache', '-p', '-M', '--cached', 'HEAD'] + f)
444 def formatPatchRichText(patch, colors):
445 ret = '<font color="' + colors['std'] + '">'
446 prev = ' '
447 for l in patch.split('\n'):
448 if len(l) > 0:
449 c = l[0]
450 else:
451 c = ' '
453 if c != prev:
454 if c == '+': style = 'new'
455 elif c == '-': style = 'remove'
456 elif c == '@': style = 'head'
457 else: style = 'std'
458 ret += '</font><font color="' + colors[style] + '">'
459 prev = c
460 ret += str(qt.QStyleSheet.escape(l)) + '<br>\n'
461 return ret
463 class ProgramError(Exception):
464 def __init__(self, program, err):
465 self.program = program
466 self.error = err
468 def runProgram(prog, input=None):
469 debug('runProgram prog: ' + str(prog) + " input: " + str(input))
470 if type(prog) is str:
471 progStr = prog
472 else:
473 progStr = ' '.join(prog)
475 try:
476 pop = subprocess.Popen(prog,
477 shell = type(prog) is str,
478 stderr=subprocess.STDOUT,
479 stdout=subprocess.PIPE,
480 stdin=subprocess.PIPE)
481 except OSError, e:
482 debug("strerror: " + e.strerror)
483 raise ProgramError(progStr, e.strerror)
485 if input != None:
486 pop.stdin.write(input)
487 pop.stdin.close()
489 out = pop.stdout.read()
490 code = pop.wait()
491 if code != 0:
492 debug("error output: " + out)
493 raise ProgramError(progStr, out)
494 debug("output: " + out.replace('\0', '\n'))
495 return out
497 if not os.environ.has_key('GIT_DIR'):
498 os.environ['GIT_DIR'] = '.git'
500 def basicsFailed(msg):
501 print "'git-cat-file -t HEAD' failed: " + msg
502 print "Make sure that the current working directory contains a '.git' directory, or\nthat GIT_DIR is set appropriately."
503 sys.exit(1)
505 try:
506 runProgram('git-cat-file -t HEAD')
507 except OSError, e:
508 basicsFailed(e.strerror)
509 except ProgramError, e:
510 basicsFailed(e.error)
512 app = qt.QApplication(sys.argv)
513 settings = qt.QSettings()
514 settings.beginGroup('/' + shortName)
515 settings.beginGroup('/geometry/')
517 mw = MainWidget()
518 mw.refreshFiles()
520 mw.resize(settings.readNumEntry('width', 500)[0],
521 settings.readNumEntry('height', 600)[0])
524 # The following code doesn't work correctly in some (at least
525 # Metacity) window
526 # managers. http://doc.trolltech.com/3.3/geometry.html contains some
527 # information about this issue.
528 # mw.move(settings.readNumEntry('x', 100)[0],
529 # settings.readNumEntry('y', 100)[0])
531 mw.show()
532 app.setMainWidget(mw)
535 # Handle CTRL-C appropriately
536 signal.signal(signal.SIGINT, lambda s, f: app.quit())
538 ret = app.exec_loop()
539 del settings
540 sys.exit(ret)