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'
40 class CommitError(Exception):
41 def __init__(self
, operation
, msg
):
42 self
.operation
= operation
48 class MyListItem(qt
.QListViewItem
):
49 def __init__(self
, parent
, file, inSync
, commitMsg
= False):
53 status
= 'Working directory out of sync '
56 qt
.QListViewItem
.__init
__(self
, parent
, file.text
, status
)
59 self
.commitMsg
= commitMsg
61 def compare(self
, item
, col
, asc
):
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
)
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
)
88 return ListViewIterator(self
)
90 class ListViewIterator
:
91 def __init__(self
, listview
):
92 self
.it
= qt
.QListViewItemIterator(listview
)
95 cur
= self
.it
.current()
103 raise StopIteration()
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
)
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
)
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)
150 self
.splitter
= splitter
153 def about(self
, ignore
):
154 qt
.QMessageBox
.about(self
, "About " + applicationName
,
155 "<qt><center><h1>" + applicationName
+ "</h1></center>\n" +
156 "<center>Copyright © 2005 Fredrik Kuivinen <freku045@student.liu.se></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()
167 self
.text
.setUpdatesEnabled(False)
168 if item
== self
.cmitItem
:
169 self
.text
.setText(item
.file.patch
)
170 self
.text
.setReadOnly(False)
172 self
.setColorPatch(item
.file.patch
)
173 self
.text
.setReadOnly(True)
175 self
.text
.setContentsPos(0, 0)
176 self
.text
.setUpdatesEnabled(True)
179 def setColorPatch(self
, patch
):
182 for l
in patch
.split('\n'):
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
)
194 def updateCache(self
, id):
195 for it
in self
.selectedItems():
196 doUpdateCache(it
.file.dstName
)
199 def selectedItems(self
):
201 it
= qt
.QListViewItemIterator(self
.filesW
)
208 if item
.isSelected():
213 def commit(self
, id):
214 if self
.prevCur
== self
.cmitItem
:
215 self
.files
[0].patch
= self
.text
.text()
220 it
= qt
.QListViewItemIterator(self
.filesW
)
227 debug("file: " + item
.file.text
)
228 if item
.isSelected():
229 selFileNames
.append(item
.file.text
)
231 keepFiles
.append(item
.file)
234 commitMsg
= str(self
.files
[0].patch
)
237 qt
.QMessageBox
.information(self
, "Commit - " + applicationName
,
238 "No files selected for commit.", "&Ok")
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
),
251 doCommit(keepFiles
, commitMsg
)
252 except CommitError
, e
:
253 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
254 "Commit failed during " + e
.operation
+ ": " + e
.msg
,
257 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
258 "Commit failed: " + e
.strerror
,
263 def getFileState(self
):
265 if self
.filesW
.currentItem():
266 ret
.current
= self
.filesW
.currentItem().file.srcName
271 for x
in self
.filesW
:
273 ret
.selected
[x
.file.srcName
] = True
276 def restoreFileState(self
, state
):
277 for x
in self
.filesW
:
278 if state
.selected
.has_key(x
.file.srcName
):
280 if x
.file.srcName
== state
.current
:
281 self
.filesW
.setCurrentItem(x
)
283 def setFiles(self
, files
):
284 state
= self
.getFileState()
287 f
.text
= "Commit message"
290 # Hack to make getFileState/restoreFileState work when the commit message is selected
293 self
.cmitItem
= MyListItem(self
.filesW
, f
, False, True)
294 self
.filesW
.insertItem(self
.cmitItem
)
295 self
.cmitItem
.setSelectable(False)
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):
314 cacheHeadDiff
= parseDiff('git-diff-cache -z --cached HEAD')
316 # The set of files that are different in the cache compared to HEAD
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
335 if file.change
== 'N':
336 runProgram(['git-update-cache', '--force-remove', file.srcName
])
338 runProgram(['git-update-cache', '--add', '--replace', '--cacheinfo',
339 file.srcMode
, file.srcSHA
, file.srcName
])
341 tree
= runProgram(['git-write-tree'])
343 commit
= runProgram(['git-commit-tree', tree
, '-p', 'HEAD'], msg
)
344 commit
= commit
.rstrip()
347 f
= open(os
.environ
['GIT_DIR'] + '/HEAD', 'w+')
351 raise CommitError('write to ' + os
.environ
['GIT_DIR'] + '/HEAD', e
.strerror
)
354 os
.unlink(os
.environ
['GIT_DIR'] + '/MERGE_HEAD')
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
):
369 msg
= re
.sub(commitMsgRE
, '\n', msg
)
374 return ' ' + msg
.replace('\n', ' \n')
379 parseDiffRE
= re
.compile(':([0-9]+) ([0-9]+) ([0-9a-f]{40}) ([0-9a-f]{40}) ([MCRNDUT])([0-9]*)')
381 inp
= runProgram(prog
)
384 recs
= inp
.split("\0")
385 recs
.pop() # remove last entry (which is '')
389 m
= parseDiffRE
.match(rec
)
392 print "Unknown output from " + str(prog
) + "!: " + rec
+ "\n"
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)
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
)
408 f
.patch
= getPatch(f
.srcName
)
411 except StopIteration:
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.
419 files
= parseDiff('git-diff-cache -z -M --cached HEAD')
423 f
.text
= 'Copy from ' + f
.srcName
+ ' to ' + f
.dstName
425 f
.text
= 'Rename from ' + f
.srcName
+ ' to ' + f
.dstName
427 f
.text
= 'New file: ' + f
.srcName
429 f
.text
= 'Deleted file: ' + f
.srcName
431 f
.text
= 'Type change: ' + f
.srcName
435 if len(parseDiff(['git-diff-files', '-z', f
.dstName
])) > 0:
442 def getPatch(file, otherFile
= None):
444 f
= [file, otherFile
]
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')
453 class ProgramError(Exception):
454 def __init__(self
, program
, err
):
455 self
.program
= program
458 def runProgram(prog
, input=None):
459 debug('runProgram prog: ' + str(prog
) + " input: " + str(input))
460 if type(prog
) is str:
463 progStr
= ' '.join(prog
)
466 pop
= subprocess
.Popen(prog
,
467 shell
= type(prog
) is str,
468 stderr
=subprocess
.STDOUT
,
469 stdout
=subprocess
.PIPE
,
470 stdin
=subprocess
.PIPE
)
472 debug("strerror: " + e
.strerror
)
473 raise ProgramError(progStr
, e
.strerror
)
476 pop
.stdin
.write(input)
480 out
= pop
.stdout
.read()
482 debug("error output: " + out
)
483 raise ProgramError(progStr
, out
)
484 debug("output: " + out
.replace('\0', '\n'))
487 app
= qt
.QApplication(sys
.argv
)
489 app
.setMainWidget(mw
)
492 mw
.setGeometry(100, 100, 500, 600)
495 if not os
.environ
.has_key('GIT_DIR'):
496 os
.environ
['GIT_DIR'] = '.git'
498 sys
.exit(app
.exec_loop())