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
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:
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
.QListViewItem
):
51 def __init__(self
, parent
, file, inSync
, commitMsg
= False):
55 status
= 'Working directory out of sync '
58 qt
.QListViewItem
.__init
__(self
, parent
, file.text
, status
)
61 self
.commitMsg
= commitMsg
63 def compare(self
, item
, col
, asc
):
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
)
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
)
90 return ListViewIterator(self
)
92 class ListViewIterator
:
93 def __init__(self
, listview
):
94 self
.it
= qt
.QListViewItemIterator(listview
)
97 cur
= self
.it
.current()
105 raise StopIteration()
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
)
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
)
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'}
149 self
.splitter
= splitter
153 f
.text
= "Commit message"
154 f
.textW
= self
.newTextEdit()
155 f
.textW
.setTextFormat(Qt
.PlainText
)
156 f
.textW
.setReadOnly(False)
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()))
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 © 2005 Fredrik Kuivinen <freku045@student.liu.se></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
)
186 def updateCache(self
, id):
187 for it
in self
.selectedItems():
188 doUpdateCache(it
.file.dstName
)
191 def selectedItems(self
):
193 for item
in self
.filesW
:
194 if item
.isSelected():
198 def commit(self
, id):
202 for item
in self
.filesW
:
203 debug("file: " + item
.file.text
)
204 if item
.isSelected():
205 selFileNames
.append(item
.file.text
)
207 keepFiles
.append(item
.file)
209 commitMsg
= str(self
.cmitItem
.file.textW
.text())
212 qt
.QMessageBox
.information(self
, "Commit - " + applicationName
,
213 "No files selected for commit.", "&Ok")
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
),
226 doCommit(keepFiles
, commitMsg
)
227 except CommitError
, e
:
228 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
229 "Commit failed during " + e
.operation
+ ": " + e
.msg
,
232 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
233 "Commit failed: " + e
.strerror
,
236 self
.cmitItem
.file.textW
.setText('')
239 def getFileState(self
):
241 cur
= self
.filesW
.currentItem()
242 if cur
and cur
!= self
.cmitItem
:
243 ret
.current
= self
.filesW
.currentItem().file.srcName
248 for x
in self
.filesW
:
250 ret
.selected
[x
.file.srcName
] = True
253 def restoreFileState(self
, state
):
254 for x
in self
.filesW
:
255 if state
.selected
.has_key(x
.file.srcName
):
257 if x
.file.srcName
== state
.current
:
258 self
.filesW
.setCurrentItem(x
)
260 def newTextEdit(self
):
262 self
.text
.addWidget(ret
)
265 def setFiles(self
, files
):
266 state
= self
.getFileState()
268 self
.createCmitItem()
270 self
.text
.removeWidget(f
.textW
)
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
))
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):
286 self
.setFiles(getFiles())
289 cacheHeadDiff
= parseDiff('git-diff-cache -z --cached HEAD')
291 # The set of files that are different in the cache compared to HEAD
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
310 if file.change
== 'N':
311 runProgram(['git-update-cache', '--force-remove', file.srcName
])
313 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
314 file.srcMode
, file.srcSHA
, file.srcName
])
316 tree
= runProgram(['git-write-tree'])
318 commit
= runProgram(['git-commit-tree', tree
, '-p', 'HEAD'], msg
)
319 commit
= commit
.rstrip()
322 f
= open(os
.environ
['GIT_DIR'] + '/HEAD', 'w+')
326 raise CommitError('write to ' + os
.environ
['GIT_DIR'] + '/HEAD', e
.strerror
)
329 os
.unlink(os
.environ
['GIT_DIR'] + '/MERGE_HEAD')
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
):
344 msg
= re
.sub(commitMsgRE
, '\n', msg
)
349 return ' ' + msg
.replace('\n', ' \n')
354 parseDiffRE
= re
.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
356 inp
= runProgram(prog
)
359 recs
= inp
.split("\0")
360 recs
.pop() # remove last entry (which is '')
364 m
= parseDiffRE
.match(rec
)
367 print "Unknown output from " + str(prog
) + "!: " + rec
+ "\n"
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)
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
)
383 f
.patch
= getPatch(f
.srcName
)
386 except StopIteration:
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.
394 files
= parseDiff('git-diff-cache -z -M --cached HEAD')
398 f
.text
= 'Copy from ' + f
.srcName
+ ' to ' + f
.dstName
400 f
.text
= 'Rename from ' + f
.srcName
+ ' to ' + f
.dstName
402 f
.text
= 'New file: ' + f
.srcName
404 f
.text
= 'Deleted file: ' + f
.srcName
406 f
.text
= 'Type change: ' + f
.srcName
410 if len(parseDiff(['git-diff-files', '-z', f
.dstName
])) > 0:
416 def getPatch(file, otherFile
= None):
418 f
= [file, otherFile
]
421 return runProgram(['git-diff-cache', '-p', '-M', '--cached', 'HEAD'] + f
)
423 def formatPatchRichText(patch
, colors
):
424 ret
= '<font color="' + colors
['std'] + '">'
426 for l
in patch
.split('\n'):
433 if c
== '+': style
= 'new'
434 elif c
== '-': style
= 'remove'
435 elif c
== '@': style
= 'head'
437 ret
+= '</font><font color="' + colors
[style
] + '">'
439 ret
+= str(qt
.QStyleSheet
.escape(l
)) + '<br>\n'
442 class ProgramError(Exception):
443 def __init__(self
, program
, err
):
444 self
.program
= program
447 def runProgram(prog
, input=None):
448 debug('runProgram prog: ' + str(prog
) + " input: " + str(input))
449 if type(prog
) is str:
452 progStr
= ' '.join(prog
)
455 pop
= subprocess
.Popen(prog
,
456 shell
= type(prog
) is str,
457 stderr
=subprocess
.STDOUT
,
458 stdout
=subprocess
.PIPE
,
459 stdin
=subprocess
.PIPE
)
461 debug("strerror: " + e
.strerror
)
462 raise ProgramError(progStr
, e
.strerror
)
465 pop
.stdin
.write(input)
468 out
= pop
.stdout
.read()
471 debug("error output: " + out
)
472 raise ProgramError(progStr
, out
)
473 debug("output: " + out
.replace('\0', '\n'))
476 app
= qt
.QApplication(sys
.argv
)
477 settings
= qt
.QSettings()
478 settings
.beginGroup('/' + shortName
)
479 settings
.beginGroup('/geometry/')
483 mw
.setGeometry(settings
.readNumEntry('x', 100)[0],
484 settings
.readNumEntry('y', 100)[0],
485 settings
.readNumEntry('width', 500)[0],
486 settings
.readNumEntry('height', 600)[0])
488 app
.setMainWidget(mw
)
493 if not os
.environ
.has_key('GIT_DIR'):
494 os
.environ
['GIT_DIR'] = '.git'
496 sys
.exit(app
.exec_loop())