Cosmetics in README
[hgct.git] / main.py
blob7c35b6a08ecdfe6bb5c8117c9295efddd67e3737
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 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-pre'
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.QListViewItem):
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 = ''
58 qt.QListViewItem.__init__(self, parent, file.text, status)
59 self.inSync = inSync
60 self.file = file
61 self.commitMsg = commitMsg
63 def compare(self, item, col, asc):
64 if self.commitMsg:
65 if asc:
66 return -1
67 else:
68 return 1
69 elif item.commitMsg:
70 if asc:
71 return 1
72 else:
73 return -1
74 else:
75 return cmp(self.key(col, asc), item.key(col, asc))
77 def paintCell(self, p, cg, col, w, a):
78 if col == 1 and not self.inSync:
79 cg.setColor(qt.QColorGroup.Text, Qt.red)
80 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
81 cg.setColor(qt.QColorGroup.Text, Qt.black)
82 else:
83 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
85 class MyListView(qt.QListView):
86 def __init__(self, parent=None, name=None):
87 qt.QListView.__init__(self, parent, name)
89 def __iter__(self):
90 return ListViewIterator(self)
92 class ListViewIterator:
93 def __init__(self, listview):
94 self.it = qt.QListViewItemIterator(listview)
96 def next(self):
97 cur = self.it.current()
98 if cur:
99 self.it += 1
100 if cur.commitMsg:
101 return self.next()
102 else:
103 return cur
104 else:
105 raise StopIteration()
107 def __iter__(self):
108 return self
110 class MainWidget(qt.QMainWindow):
111 def __init__(self, parent=None, name=None):
112 qt.QMainWindow.__init__(self, parent, name)
113 splitter = qt.QSplitter(Qt.Vertical, self)
115 fW = MyListView(splitter)
116 fW.setFocus()
117 fW.setSelectionMode(qt.QListView.Multi)
118 fW.addColumn('Description')
119 statusTitle = 'Cache Status'
120 fW.addColumn(statusTitle)
121 fW.setResizeMode(qt.QListView.AllColumns)
122 fW.header().setStretchEnabled(1, False)
123 fW.setColumnWidth(1, self.fontMetrics().width(statusTitle + 'xxx'))
125 text = qt.QWidgetStack(splitter)
127 self.setCentralWidget(splitter)
128 self.setCaption(applicationName)
130 self.newSelLambda = lambda i: self.newSelection(i)
131 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newSelLambda)
133 ops = qt.QPopupMenu(self)
134 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
135 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
136 ops.insertItem("Update Cache for Selected Files", self.updateCache, Qt.CTRL+Qt.Key_U)
138 m = self.menuBar()
139 m.insertItem("&Operations", ops)
141 h = qt.QPopupMenu(self)
142 h.insertItem("&About", self.about)
143 m.insertItem("&Help", h)
145 self.patchColors = {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
147 self.filesW = fW
148 self.files = []
149 self.splitter = splitter
150 self.text = text
152 f = File()
153 f.text = "Commit message"
154 f.textW = self.newTextEdit()
155 f.textW.setTextFormat(Qt.PlainText)
156 f.textW.setReadOnly(False)
157 self.cmitFile = f
158 self.createCmitItem()
159 self.splitter.setSizes(eval(str(settings.readEntry('splitter', '[400, 200]')[0])))
161 def closeEvent(self, e):
162 settings.writeEntry('x', self.x()),
163 settings.writeEntry('y', self.y()),
164 settings.writeEntry('width', self.width()),
165 settings.writeEntry('height', self.height())
166 settings.writeEntry('splitter', str(self.splitter.sizes()))
167 e.accept()
169 def createCmitItem(self):
170 self.cmitItem = MyListItem(self.filesW, self.cmitFile, False, True)
171 self.cmitItem.setSelectable(False)
172 self.filesW.insertItem(self.cmitItem)
174 def about(self, ignore):
175 qt.QMessageBox.about(self, "About " + applicationName,
176 "<qt><center><h1>" + applicationName + " " + version + "</h1></center>\n" +
177 "<center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;</center>\n" +
178 "<p>This program is free software; you can redistribute it and/or modify " +
179 "it under the terms of the GNU General Public License version 2 as " +
180 "published by the Free Software Foundation.</p></qt>")
182 def newSelection(self, item):
183 self.text.raiseWidget(item.file.textW)
184 self.text.update()
186 def updateCache(self, id):
187 for it in self.selectedItems():
188 doUpdateCache(it.file.dstName)
189 self.refreshFiles()
191 def selectedItems(self):
192 ret = []
193 for item in self.filesW:
194 if item.isSelected():
195 ret.append(item)
196 return ret
198 def commit(self, id):
199 selFileNames = []
200 keepFiles = []
202 for item in self.filesW:
203 debug("file: " + item.file.text)
204 if item.isSelected():
205 selFileNames.append(item.file.text)
206 else:
207 keepFiles.append(item.file)
209 commitMsg = str(self.cmitItem.file.textW.text())
211 if not selFileNames:
212 qt.QMessageBox.information(self, "Commit - " + applicationName,
213 "No files selected for commit.", "&Ok")
214 return
216 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
217 if(qt.QMessageBox.question(self, "Commit - " + applicationName,
218 "Do you want to commit the following file(s):\n\n" +
219 indentMsg('\n'.join(selFileNames)) + '\n\n' +
220 'with the commit message:\n\n' +
221 indentMsg(commitMsg),
222 '&Yes', '&No')):
223 return
224 else:
225 try:
226 doCommit(keepFiles, commitMsg)
227 except CommitError, e:
228 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
229 "Commit failed during " + e.operation + ": " + e.msg,
230 '&Ok')
231 except OSError, e:
232 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
233 "Commit failed: " + e.strerror,
234 '&Ok')
235 else:
236 self.cmitItem.file.textW.setText('')
237 self.refreshFiles()
239 def getFileState(self):
240 ret = FileState()
241 cur = self.filesW.currentItem()
242 if cur and cur != self.cmitItem:
243 ret.current = self.filesW.currentItem().file.srcName
244 else:
245 ret.current = None
246 ret.selected = {}
248 for x in self.filesW:
249 if x.isSelected():
250 ret.selected[x.file.srcName] = True
251 return ret
253 def restoreFileState(self, state):
254 for x in self.filesW:
255 if state.selected.has_key(x.file.srcName):
256 x.setSelected(True)
257 if x.file.srcName == state.current:
258 self.filesW.setCurrentItem(x)
260 def newTextEdit(self):
261 ret = qt.QTextEdit()
262 self.text.addWidget(ret)
263 return ret
265 def setFiles(self, files):
266 state = self.getFileState()
267 self.filesW.clear()
268 self.createCmitItem()
269 for f in self.files:
270 self.text.removeWidget(f.textW)
272 self.files = []
273 for f in files:
274 f.textW = self.newTextEdit()
275 f.textW.setReadOnly(False)
276 f.textW.setTextFormat(Qt.RichText)
277 f.textW.setText(formatPatchRichText(f.patch, self.patchColors))
278 self.files.append(f)
279 self.filesW.insertItem(MyListItem(self.filesW, f, f.updated))
281 self.filesW.setCurrentItem(self.cmitItem)
282 self.restoreFileState(state)
284 def refreshFiles(self, ignored=None):
285 updateCache()
286 self.setFiles(getFiles())
288 def updateCache():
289 cacheHeadDiff = parseDiff('git-diff-cache -z --cached HEAD')
291 # The set of files that are different in the cache compared to HEAD
292 cacheHeadChange = {}
293 for f in cacheHeadDiff:
294 cacheHeadChange[f.srcName] = True
296 noncacheHeadDiff = parseDiff('git-diff-cache -z HEAD')
297 for f in noncacheHeadDiff:
298 if (f.srcSHA == '0'*40 or f.dstSHA == '0'*40) and not cacheHeadChange.has_key(f.srcName):
299 runProgram(['git-update-cache', '--remove', f.srcName])
301 def doUpdateCache(filename):
302 runProgram(['git-update-cache', '--remove', '--add', '--replace', filename])
304 def doCommit(filesToKeep, msg):
305 for file in filesToKeep:
306 # If we have a new file in the cache which we do not want to
307 # commit we have to remove it from the cache. We will add this
308 # cache entry back in to the cache at the end of this
309 # function.
310 if file.change == 'N':
311 runProgram(['git-update-cache', '--force-remove', file.srcName])
312 else:
313 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
314 file.srcMode, file.srcSHA, file.srcName])
316 tree = runProgram(['git-write-tree'])
317 tree = tree.rstrip()
318 commit = runProgram(['git-commit-tree', tree, '-p', 'HEAD'], msg)
319 commit = commit.rstrip()
321 try:
322 f = open(os.environ['GIT_DIR'] + '/HEAD', 'w+')
323 f.write(commit)
324 f.close()
325 except OSError, e:
326 raise CommitError('write to ' + os.environ['GIT_DIR'] + '/HEAD', e.strerror)
328 try:
329 os.unlink(os.environ['GIT_DIR'] + '/MERGE_HEAD')
330 except OSError:
331 pass
333 for file in filesToKeep:
334 # Don't add files that are going to be deleted back to the cache
335 if file.change != 'D':
336 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
337 file.dstMode, file.dstSHA, file.dstName])
340 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
341 def fixCommitMsgWhiteSpace(msg):
342 msg = msg.lstrip()
343 msg = msg.rstrip()
344 msg = re.sub(commitMsgRE, '\n', msg)
345 msg += '\n'
346 return msg
348 def indentMsg(msg):
349 return ' ' + msg.replace('\n', ' \n')
351 class File:
352 pass
354 parseDiffRE = re.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
355 def parseDiff(prog):
356 inp = runProgram(prog)
357 ret = []
358 try:
359 recs = inp.split("\0")
360 recs.pop() # remove last entry (which is '')
361 it = recs.__iter__()
362 while True:
363 rec = it.next()
364 m = parseDiffRE.match(rec)
366 if not m:
367 print "Unknown output from " + str(prog) + "!: " + rec + "\n"
368 continue
370 f = File()
371 f.srcMode = m.group(1)
372 f.dstMode = m.group(2)
373 f.srcSHA = m.group(3)
374 f.dstSHA = m.group(4)
375 f.change = m.group(5)
376 f.score = m.group(6)
377 f.srcName = f.dstName = it.next()
379 if f.change == 'C' or f.change == 'R':
380 f.dstName = it.next()
381 f.patch = getPatch(f.srcName, f.dstName)
382 else:
383 f.patch = getPatch(f.srcName)
385 ret.append(f)
386 except StopIteration:
387 pass
388 return ret
391 # HEAD is src in the returned File objects. That is, srcName is the
392 # name in HEAD and dstName is the name in the cache.
393 def getFiles():
394 files = parseDiff('git-diff-cache -z -M --cached HEAD')
395 for f in files:
396 c = f.change
397 if c == 'C':
398 f.text = 'Copy from ' + f.srcName + ' to ' + f.dstName
399 elif c == 'R':
400 f.text = 'Rename from ' + f.srcName + ' to ' + f.dstName
401 elif c == 'N':
402 f.text = 'New file: ' + f.srcName
403 elif c == 'D':
404 f.text = 'Deleted file: ' + f.srcName
405 elif c == 'T':
406 f.text = 'Type change: ' + f.srcName
407 else:
408 f.text = f.srcName
410 if len(parseDiff(['git-diff-files', '-z', f.dstName])) > 0:
411 f.updated = False
412 else:
413 f.updated = True
414 return files
416 def getPatch(file, otherFile = None):
417 if otherFile:
418 f = [file, otherFile]
419 else:
420 f = [file]
421 return runProgram(['git-diff-cache', '-p', '-M', '--cached', 'HEAD'] + f)
423 def formatPatchRichText(patch, colors):
424 ret = '<font color="' + colors['std'] + '">'
425 prev = ' '
426 for l in patch.split('\n'):
427 if len(l) > 0:
428 c = l[0]
429 else:
430 c = ' '
432 if c != prev:
433 if c == '+': style = 'new'
434 elif c == '-': style = 'remove'
435 elif c == '@': style = 'head'
436 else: style = 'std'
437 ret += '</font><font color="' + colors[style] + '">'
438 prev = c
439 ret += str(qt.QStyleSheet.escape(l)) + '<br>\n'
440 return ret
442 class ProgramError(Exception):
443 def __init__(self, program, err):
444 self.program = program
445 self.error = err
447 def runProgram(prog, input=None):
448 debug('runProgram prog: ' + str(prog) + " input: " + str(input))
449 if type(prog) is str:
450 progStr = prog
451 else:
452 progStr = ' '.join(prog)
454 try:
455 pop = subprocess.Popen(prog,
456 shell = type(prog) is str,
457 stderr=subprocess.STDOUT,
458 stdout=subprocess.PIPE,
459 stdin=subprocess.PIPE)
460 except OSError, e:
461 debug("strerror: " + e.strerror)
462 raise ProgramError(progStr, e.strerror)
464 if input != None:
465 pop.stdin.write(input)
466 pop.stdin.close()
468 out = pop.stdout.read()
469 code = pop.wait()
470 if code != 0:
471 debug("error output: " + out)
472 raise ProgramError(progStr, out)
473 debug("output: " + out.replace('\0', '\n'))
474 return out
476 if not os.environ.has_key('GIT_DIR'):
477 os.environ['GIT_DIR'] = '.git'
479 def basicsFailed(msg):
480 print "'git-cat-file -t HEAD' failed: " + msg
481 print "Make sure that the current working directory contains a '.git' directory, or\nthat GIT_DIR is set appropriately."
482 sys.exit(1)
484 try:
485 runProgram('git-cat-file -t HEAD')
486 except OSError, e:
487 basicsFailed(e.strerror)
488 except ProgramError, e:
489 basicsFailed(e.error)
491 app = qt.QApplication(sys.argv)
492 settings = qt.QSettings()
493 settings.beginGroup('/' + shortName)
494 settings.beginGroup('/geometry/')
496 mw = MainWidget()
498 mw.setGeometry(settings.readNumEntry('x', 100)[0],
499 settings.readNumEntry('y', 100)[0],
500 settings.readNumEntry('width', 500)[0],
501 settings.readNumEntry('height', 600)[0])
503 app.setMainWidget(mw)
505 mw.refreshFiles()
506 mw.show()
508 # Handle CTRL-C appropriately
509 signal.signal(signal.SIGINT, lambda s, f: app.quit())
511 sys.exit(app.exec_loop())