Make the state saving work when the commit message is selected.
[hgct.git] / citool.py
blobaad7f280467f48b5204980f457597a4ead1ef773
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
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 DEBUG = 1
36 def debug(str):
37 if DEBUG:
38 print str
40 class CommitError(Exception):
41 def __init__(self, operation, msg):
42 self.operation = operation
43 self.msg = msg
45 class FileState:
46 pass
48 class MyListItem(qt.QListViewItem):
49 def __init__(self, parent, file, inSync, commitMsg = False):
50 if inSync:
51 status = 'In sync '
52 else:
53 status = 'Working directory out of sync '
54 if commitMsg:
55 status = ''
56 qt.QListViewItem.__init__(self, parent, file.text, status)
57 self.inSync = inSync
58 self.file = file
59 self.commitMsg = commitMsg
61 def compare(self, item, col, asc):
62 if self.commitMsg:
63 if asc:
64 return -1
65 else:
66 return 1
67 elif item.commitMsg:
68 if asc:
69 return 1
70 else:
71 return -1
72 else:
73 return cmp(self.key(col, asc), item.key(col, asc))
75 def paintCell(self, p, cg, col, w, a):
76 if col == 1 and not self.inSync:
77 cg.setColor(qt.QColorGroup.Text, Qt.red)
78 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
79 cg.setColor(qt.QColorGroup.Text, Qt.black)
80 else:
81 qt.QListViewItem.paintCell(self, p, cg, col, w, a)
83 class MyListView(qt.QListView):
84 def __init__(self, parent=None, name=None):
85 qt.QListView.__init__(self, parent, name)
87 def __iter__(self):
88 return ListViewIterator(self)
90 class ListViewIterator:
91 def __init__(self, listview):
92 self.it = qt.QListViewItemIterator(listview)
94 def next(self):
95 cur = self.it.current()
96 if cur:
97 self.it += 1
98 if cur.commitMsg:
99 return self.next()
100 else:
101 return cur
102 else:
103 raise StopIteration()
105 def __iter__(self):
106 return self
108 class MainWidget(qt.QMainWindow):
109 def __init__(self, parent=None, name=None):
110 qt.QMainWindow.__init__(self, parent, name)
111 splitter = qt.QSplitter(Qt.Vertical, self)
113 fW = MyListView(splitter)
114 fW.setFocus()
115 fW.setSelectionMode(qt.QListView.Multi)
116 fW.addColumn('Description')
117 statusTitle = 'Cache Status'
118 fW.addColumn(statusTitle)
119 fW.setResizeMode(qt.QListView.AllColumns)
120 fW.header().setStretchEnabled(1, False)
121 fW.setColumnWidth(1, self.fontMetrics().width(statusTitle + 'xxx'))
123 text = qt.QTextEdit(splitter)
124 text.setTextFormat(qt.QTextEdit.PlainText)
126 self.setCentralWidget(splitter)
127 self.setCaption(applicationName)
129 self.newSelLambda = lambda i: self.newSelection(i)
130 qconnect(fW, qt.SIGNAL("currentChanged(QListViewItem*)"), self.newSelLambda)
132 ops = qt.QPopupMenu(self)
133 ops.insertItem("Commit Selected Files", self.commit, Qt.CTRL+Qt.Key_T)
134 ops.insertItem("Refresh", self.refreshFiles, Qt.CTRL+Qt.Key_R)
135 ops.insertItem("Update Cache for Selected Files", self.updateCache, Qt.CTRL+Qt.Key_U)
137 m = self.menuBar()
138 m.insertItem("&Operations", ops)
140 h = qt.QPopupMenu(self)
141 h.insertItem("&About", self.about)
142 m.insertItem("&Help", h)
144 self.diffNew = qt.QColor(0, 150, 0)
145 self.diffRemove = qt.QColor(200, 0, 0)
146 self.diffHead = qt.QColor(200, 0, 200)
147 self.diffStd = qt.QColor(0, 0, 0)
149 self.filesW = fW
150 self.splitter = splitter
151 self.text = text
153 def about(self, ignore):
154 qt.QMessageBox.about(self, "About " + applicationName,
155 "<qt><center><h1>" + applicationName + "</h1></center>\n" +
156 "<center>Copyright &copy; 2005 Fredrik Kuivinen &lt;freku045@student.liu.se&gt;</center>\n" +
157 "<p>This program is free software; you can redistribute it and/or modify " +
158 "it under the terms of the GNU General Public License version 2 as " +
159 "published by the Free Software Foundation.</p></qt>")
161 def newSelection(self, item):
162 if self.prevCur == self.cmitItem:
163 self.files[0].patch = self.text.text()
165 self.prevCur = item
167 self.text.setUpdatesEnabled(False)
168 if item == self.cmitItem:
169 self.text.setText(item.file.patch)
170 self.text.setReadOnly(False)
171 else:
172 self.setColorPatch(item.file.patch)
173 self.text.setReadOnly(True)
175 self.text.setContentsPos(0, 0)
176 self.text.setUpdatesEnabled(True)
177 self.text.update()
179 def setColorPatch(self, patch):
180 t = self.text
181 t.setText('')
182 for l in patch.split('\n'):
183 if len(l) > 0:
184 c = l[0]
185 else:
186 c = ''
188 if c == '+': t.setColor(self.diffNew)
189 elif c == '-': t.setColor(self.diffRemove)
190 elif c == '@': t.setColor(self.diffHead)
191 else: t.setColor(self.diffStd)
192 t.append(l + '\n')
194 def updateCache(self, id):
195 for it in self.selectedItems():
196 doUpdateCache(it.file.dstName)
197 self.refreshFiles()
199 def selectedItems(self):
200 ret = []
201 it = qt.QListViewItemIterator(self.filesW)
202 while it.current():
203 item = it.current()
204 if item.commitMsg:
205 it += 1
206 continue
208 if item.isSelected():
209 ret.append(item)
210 it += 1
211 return ret
213 def commit(self, id):
214 if self.prevCur == self.cmitItem:
215 self.files[0].patch = self.text.text()
217 selFileNames = []
218 keepFiles = []
220 it = qt.QListViewItemIterator(self.filesW)
221 while it.current():
222 item = it.current()
223 if item.commitMsg:
224 it += 1
225 continue
227 debug("file: " + item.file.text)
228 if item.isSelected():
229 selFileNames.append(item.file.text)
230 else:
231 keepFiles.append(item.file)
232 it += 1
234 commitMsg = str(self.files[0].patch)
236 if not selFileNames:
237 qt.QMessageBox.information(self, "Commit - " + applicationName,
238 "No files selected for commit.", "&Ok")
239 return
241 commitMsg = fixCommitMsgWhiteSpace(commitMsg)
242 if(qt.QMessageBox.question(self, "Commit - " + applicationName,
243 "Do you want to commit the following file(s):\n\n" +
244 indentMsg('\n'.join(selFileNames)) + '\n\n' +
245 'with the commit message:\n\n' +
246 indentMsg(commitMsg),
247 '&Yes', '&No')):
248 return
249 else:
250 try:
251 doCommit(keepFiles, commitMsg)
252 except CommitError, e:
253 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
254 "Commit failed during " + e.operation + ": " + e.msg,
255 '&Ok')
256 except OSError, e:
257 qt.QMessageBox.warning(self, "Commit Failed - " + applicationName,
258 "Commit failed: " + e.strerror,
259 '&Ok')
260 else:
261 self.refreshFiles()
263 def getFileState(self):
264 ret = FileState()
265 if self.filesW.currentItem():
266 ret.current = self.filesW.currentItem().file.srcName
267 else:
268 ret.current = None
269 ret.selected = {}
271 for x in self.filesW:
272 if x.isSelected():
273 ret.selected[x.file.srcName] = True
274 return ret
276 def restoreFileState(self, state):
277 for x in self.filesW:
278 if state.selected.has_key(x.file.srcName):
279 x.setSelected(True)
280 if x.file.srcName == state.current:
281 self.filesW.setCurrentItem(x)
283 def setFiles(self, files):
284 state = self.getFileState()
285 self.filesW.clear()
286 f = File()
287 f.text = "Commit message"
288 f.patch = ''
290 # Hack to make getFileState/restoreFileState work when the commit message is selected
291 f.srcName = f
293 self.cmitItem = MyListItem(self.filesW, f, False, True)
294 self.filesW.insertItem(self.cmitItem)
295 self.cmitItem.setSelectable(False)
297 self.files = [f]
298 for x in files:
299 self.files.append(x)
300 self.filesW.insertItem(MyListItem(self.filesW, x, x.updated))
303 self.text.setText('')
304 self.prevCur = self.cmitItem
305 self.filesW.setCurrentItem(self.cmitItem)
306 self.restoreFileState(state)
308 def refreshFiles(self, ignored=None):
309 updateCache()
310 files = getFiles()
311 self.setFiles(files)
313 def updateCache():
314 cacheHeadDiff = parseDiff('git-diff-cache -z --cached HEAD')
316 # The set of files that are different in the cache compared to HEAD
317 cacheHeadChange = {}
318 for f in cacheHeadDiff:
319 cacheHeadChange[f.srcName] = True
321 noncacheHeadDiff = parseDiff('git-diff-cache -z HEAD')
322 for f in noncacheHeadDiff:
323 if (f.srcSHA == '0'*40 or f.dstSHA == '0'*40) and not cacheHeadChange.has_key(f.srcName):
324 runProgram(['git-update-cache', '--remove', f.srcName])
326 def doUpdateCache(filename):
327 runProgram(['git-update-cache', '--remove', '--add', '--replace', filename])
329 def doCommit(filesToKeep, msg):
330 for file in filesToKeep:
331 # If we have a new file in the cache which we do not want to
332 # commit we have to remove it from the cache. We will add this
333 # cache entry back in to the cache at the end of this
334 # function.
335 if file.change == 'N':
336 runProgram(['git-update-cache', '--force-remove', file.srcName])
337 else:
338 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
339 file.srcMode, file.srcSHA, file.srcName])
341 tree = runProgram(['git-write-tree'])
342 tree = tree.rstrip()
343 commit = runProgram(['git-commit-tree', tree, '-p', 'HEAD'], msg)
344 commit = commit.rstrip()
346 try:
347 f = open(os.environ['GIT_DIR'] + '/HEAD', 'w+')
348 f.write(commit)
349 f.close()
350 except OSError, e:
351 raise CommitError('write to ' + os.environ['GIT_DIR'] + '/HEAD', e.strerror)
353 try:
354 os.unlink(os.environ['GIT_DIR'] + '/MERGE_HEAD')
355 except OSError:
356 pass
358 for file in filesToKeep:
359 # Don't add files that are going to be deleted back to the cache
360 if file.change != 'D':
361 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
362 file.dstMode, file.dstSHA, file.dstName])
365 commitMsgRE = re.compile('[ \t\r\f\v]*\n\\s*\n')
366 def fixCommitMsgWhiteSpace(msg):
367 msg = msg.lstrip()
368 msg = msg.rstrip()
369 msg = re.sub(commitMsgRE, '\n', msg)
370 msg += '\n'
371 return msg
373 def indentMsg(msg):
374 return ' ' + msg.replace('\n', ' \n')
376 class File:
377 pass
379 parseDiffRE = re.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
380 def parseDiff(prog):
381 inp = runProgram(prog)
382 ret = []
383 try:
384 recs = inp.split("\0")
385 recs.pop() # remove last entry (which is '')
386 it = recs.__iter__()
387 while True:
388 rec = it.next()
389 m = parseDiffRE.match(rec)
391 if not m:
392 print "Unknown output from " + str(prog) + "!: " + rec + "\n"
393 continue
395 f = File()
396 f.srcMode = m.group(1)
397 f.dstMode = m.group(2)
398 f.srcSHA = m.group(3)
399 f.dstSHA = m.group(4)
400 f.change = m.group(5)
401 f.score = m.group(6)
402 f.srcName = f.dstName = it.next()
404 if f.change == 'C' or f.change == 'R':
405 f.dstName = it.next()
406 f.patch = getPatch(f.srcName, f.dstName)
407 else:
408 f.patch = getPatch(f.srcName)
410 ret.append(f)
411 except StopIteration:
412 pass
413 return ret
416 # HEAD is src in the returned File objects. That is, srcName is the
417 # name in HEAD and dstName is the name in the cache.
418 def getFiles():
419 files = parseDiff('git-diff-cache -z -M --cached HEAD')
420 for f in files:
421 c = f.change
422 if c == 'C':
423 f.text = 'Copy from ' + f.srcName + ' to ' + f.dstName
424 elif c == 'R':
425 f.text = 'Rename from ' + f.srcName + ' to ' + f.dstName
426 elif c == 'N':
427 f.text = 'New file: ' + f.srcName
428 elif c == 'D':
429 f.text = 'Deleted file: ' + f.srcName
430 elif c == 'T':
431 f.text = 'Type change: ' + f.srcName
432 else:
433 f.text = f.srcName
435 if len(parseDiff(['git-diff-files', '-z', f.dstName])) > 0:
436 f.updated = False
437 else:
438 f.updated = True
440 return files
442 def getPatch(file, otherFile = None):
443 if otherFile:
444 f = [file, otherFile]
445 else:
446 f = [file]
447 # (ignored, fin) = os.popen2(['git-diff-cache', '-p', '-M', '--cached', 'HEAD'] + f, 'r')
448 (ignored, fin) = os.popen2(['git-diff-cache', '-p', '-M', 'HEAD'] + f, 'r')
449 ret = fin.read()
450 fin.close()
451 return ret
453 class ProgramError(Exception):
454 def __init__(self, program, err):
455 self.program = program
456 self.error = err
458 def runProgram(prog, input=None):
459 debug('runProgram prog: ' + str(prog) + " input: " + str(input))
460 if type(prog) is str:
461 progStr = prog
462 else:
463 progStr = ' '.join(prog)
465 try:
466 pop = subprocess.Popen(prog,
467 shell = type(prog) is str,
468 stderr=subprocess.STDOUT,
469 stdout=subprocess.PIPE,
470 stdin=subprocess.PIPE)
471 except OSError, e:
472 debug("strerror: " + e.strerror)
473 raise ProgramError(progStr, e.strerror)
475 if input != None:
476 pop.stdin.write(input)
477 pop.stdin.close()
479 code = pop.wait()
480 out = pop.stdout.read()
481 if code != 0:
482 debug("error output: " + out)
483 raise ProgramError(progStr, out)
484 debug("output: " + out.replace('\0', '\n'))
485 return out
487 app = qt.QApplication(sys.argv)
488 mw = MainWidget()
489 app.setMainWidget(mw)
491 mw.refreshFiles()
492 mw.setGeometry(100, 100, 500, 600)
493 mw.show()
495 if not os.environ.has_key('GIT_DIR'):
496 os.environ['GIT_DIR'] = '.git'
498 sys.exit(app.exec_loop())