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.
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):
27 subprocess
= mysubprocess
31 qconnect
= qt
.QObject
.connect
33 applicationName
= 'Git Commit Tool'
42 class CommitError(Exception):
43 def __init__(self
, operation
, msg
):
44 self
.operation
= operation
50 class MyListItem(qt
.QCheckListItem
):
51 def __init__(self
, parent
, file, inSync
, commitMsg
= False):
55 status
= 'Working directory out of sync '
59 qt
.QListViewItem
.__init
__(self
, parent
, file.text
, qt
.QCheckListItem
.CheckBox
)
60 self
.setText(1, status
)
63 self
.commitMsg
= commitMsg
65 def compare(self
, item
, col
, asc
):
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
)
86 qt
.QListViewItem
.paintCell(self
, p
, cg
, col
, w
, a
)
88 qt
.QCheckListItem
.paintCell(self
, p
, cg
, col
, w
, a
)
91 return self
.state() == qt
.QCheckListItem
.On
93 def setSelected(self
, s
):
95 self
.setState(qt
.QCheckListItem
.On
)
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
)
104 return ListViewIterator(self
)
106 class ListViewIterator
:
107 def __init__(self
, listview
):
108 self
.it
= qt
.QListViewItemIterator(listview
)
111 cur
= self
.it
.current()
119 raise StopIteration()
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
)
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
)
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
167 self
.currentContextItem
= None
169 self
.patchColors
= {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
173 self
.splitter
= splitter
177 f
.text
= "Commit message"
178 f
.textW
= self
.newTextEdit()
179 f
.textW
.setTextFormat(Qt
.PlainText
)
180 f
.textW
.setReadOnly(False)
182 self
.createCmitItem()
183 self
.splitter
.setSizes(eval(str(settings
.readEntry('splitter', '[400, 200]')[0])))
185 def closeEvent(self
, e
):
187 settings
.writeEntry('x', p
.x()),
188 settings
.writeEntry('y', p
.y()),
191 settings
.writeEntry('width', s
.width()),
192 settings
.writeEntry('height', s
.height())
194 settings
.writeEntry('splitter', str(self
.splitter
.sizes()))
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 © 2005 Fredrik Kuivinen <freku045@student.liu.se>
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())
215 self
.currentContextItem
= None
217 def toggleFile(self
, ignored
):
218 it
= self
.currentContextItem
223 it
.setSelected(False)
227 def editFile(self
, ignored
):
228 it
= self
.currentContextItem
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
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.
243 runProgram(ed
+ ' ' + shellQuote(it
.file.dstName
))
244 doUpdateCache(it
.file.dstName
)
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
)
254 def updateCacheSelected(self
, id):
255 for it
in self
.selectedItems():
256 doUpdateCache(it
.file.dstName
)
259 def selectedItems(self
):
261 for item
in self
.filesW
:
262 if item
.isSelected():
266 def commit(self
, id):
270 for item
in self
.filesW
:
271 debug("file: " + item
.file.text
)
272 if item
.isSelected():
273 selFileNames
.append(item
.file.text
)
275 keepFiles
.append(item
.file)
277 commitMsg
= str(self
.cmitItem
.file.textW
.text())
280 qt
.QMessageBox
.information(self
, "Commit - " + applicationName
,
281 "No files selected for commit.", "&Ok")
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>',
294 doCommit(keepFiles
, commitMsg
)
295 except CommitError
, e
:
296 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
297 "Commit failed during " + e
.operation
+ ": " + e
.msg
,
300 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
301 "Commit failed: " + e
.strerror
,
304 self
.cmitItem
.file.textW
.setText('')
307 def getFileState(self
):
309 cur
= self
.filesW
.currentItem()
310 if cur
and cur
!= self
.cmitItem
:
311 ret
.current
= self
.filesW
.currentItem().file.srcName
316 for x
in self
.filesW
:
318 ret
.selected
[x
.file.srcName
] = True
321 def restoreFileState(self
, state
):
322 for x
in self
.filesW
:
323 if state
.selected
.has_key(x
.file.srcName
):
325 if x
.file.srcName
== state
.current
:
326 self
.filesW
.setCurrentItem(x
)
328 def newTextEdit(self
):
330 self
.text
.addWidget(ret
)
333 def setFiles(self
, files
):
334 state
= self
.getFileState()
336 self
.createCmitItem()
338 self
.text
.removeWidget(f
.textW
)
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
))
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):
359 self
.setFiles(getFiles())
362 cacheHeadDiff
= parseDiff('git-diff-cache -z --cached HEAD')
364 # The set of files that are different in the cache compared to HEAD
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
383 if file.change
== 'N':
384 runProgram(['git-update-cache', '--force-remove', file.srcName
])
386 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
387 file.srcMode
, file.srcSHA
, file.srcName
])
389 tree
= runProgram(['git-write-tree'])
391 commit
= runProgram(['git-commit-tree', tree
, '-p', 'HEAD'], msg
)
392 commit
= commit
.rstrip()
395 f
= open(os
.environ
['GIT_DIR'] + '/HEAD', 'w+')
399 raise CommitError('write to ' + os
.environ
['GIT_DIR'] + '/HEAD', e
.strerror
)
402 os
.unlink(os
.environ
['GIT_DIR'] + '/MERGE_HEAD')
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
):
417 msg
= re
.sub(commitMsgRE
, '\n', msg
)
424 parseDiffRE
= re
.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
426 inp
= runProgram(prog
)
429 recs
= inp
.split("\0")
430 recs
.pop() # remove last entry (which is '')
434 m
= parseDiffRE
.match(rec
)
437 print "Unknown output from " + str(prog
) + "!: " + rec
+ "\n"
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)
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
)
453 f
.patch
= getPatch(f
.srcName
)
456 except StopIteration:
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.
464 files
= parseDiff('git-diff-cache -z -M --cached HEAD')
468 f
.text
= 'Copy from ' + f
.srcName
+ ' to ' + f
.dstName
470 f
.text
= 'Rename from ' + f
.srcName
+ ' to ' + f
.dstName
471 elif c
== 'N' or c
== 'A':
472 f
.text
= 'New file: ' + f
.srcName
474 f
.text
= 'Deleted file: ' + f
.srcName
476 f
.text
= 'Type change: ' + f
.srcName
480 if len(parseDiff(['git-diff-files', '-z', f
.dstName
])) > 0:
486 def getPatch(file, otherFile
= None):
488 f
= [file, otherFile
]
491 return runProgram(['git-diff-cache', '-p', '-M', '--cached', 'HEAD'] + f
)
493 def formatPatchRichText(patch
, colors
):
494 ret
= ['<qt><pre><font color="', colors
['std'], '">']
496 for l
in patch
.split('\n'):
503 if c
== '+': style
= 'new'
504 elif c
== '-': style
= 'remove'
505 elif c
== '@': style
= 'head'
507 ret
.extend(['</font><font color="', colors
[style
], '">'])
509 ret
.extend([str(qt
.QStyleSheet
.escape(l
)), '\n'])
510 ret
.append('</pre></qt>')
513 class ProgramError(Exception):
514 def __init__(self
, program
, err
):
515 self
.program
= program
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']
535 return "'" + res
+ "'"
537 def runProgram(prog
, input=None):
538 debug('runProgram prog: ' + str(prog
) + " input: " + str(input))
539 if type(prog
) is str:
542 progStr
= ' '.join(prog
)
545 pop
= subprocess
.Popen(prog
,
546 shell
= type(prog
) is str,
547 stderr
=subprocess
.STDOUT
,
548 stdout
=subprocess
.PIPE
,
549 stdin
=subprocess
.PIPE
)
551 debug("strerror: " + e
.strerror
)
552 raise ProgramError(progStr
, e
.strerror
)
555 pop
.stdin
.write(input)
558 out
= pop
.stdout
.read()
561 debug("error output: " + out
)
562 raise ProgramError(progStr
, out
)
563 debug("output: " + out
.replace('\0', '\n'))
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."
575 runProgram('git-cat-file -t HEAD')
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/')
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
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])
601 app
.setMainWidget(mw
)
604 # Handle CTRL-C appropriately
605 signal
.signal(signal
.SIGINT
, lambda s
, f
: app
.quit())
607 ret
= app
.exec_loop()