Build a git monitor
John Schwartzman discusses a Pyqt5 program that will find all of your git repositories and display their status.
John Schwartzman discusses a Pyqt5 program that will find all of your git repositories and display their status in a shiny GUI of your creation.
Do you need a hand keeping all your git repositories up to date? Do you want a visual reminder of the modified, added, renamed, untracked and deleted files in your git working directories? Build a Qt 5 Python 3 GUI application that finds all of your git repositories and displays their status through colours.
The left panel of Figure 1 (see right) shows the locations of all of the git working directories sorted alphabetically. We have selected the git working directory home/js/development/historydialog. Notice that its colour is red, which indicates the presence of modified files in the working directory. There are also deleted and untracked files, but the overall status is determined by the presence of modified files. The right panel shows the issues that git reports about individual files in the git working directory. They are colour-coded by importance, where red signifies an important issue, orange signifies a less-important issue and green signifies that git reported no issues.
Our historydialog directory shown in Figure 1 is not currently used. It could be deleted, but we can also tell
gitstatus.py to ignore this repository by adding it to the
gitstatus.ini file in the same directory.
You’ll build the dialog-based application visually using Qt 5 Designer and then save it as
Gitstatusdialog.ui. This is an XML file that contains information about all of the widgets in the application and their properties and configurations. You’re going to use a tool to convert it into a Python script.
Execute Qt 5 Designer from the system menu or from the command line. In order to run it from the command line type: designer-qt5 .
You should see the File > New Dialog as shown in Figure 2 (see opposite). You need to add the methods shown in the boxout (see opposite). Build the dialog from scratch by clicking on Dialog Without Buttons under Templates/forms. You can also, of course, start with your copy of Gitstatusdialog.ui.
If you’re building it from scratch, you will want to add widgets from the palette found on the left; add three Qlabels, two Qtextedits, two Qlistwidgets and two Qbuttons and position them as shown in Figure 3 (see
page 90). The upper-right corner of the screen in Figure 3 shows the added widgets and their assigned names.
The Qlabels and the Qtextedit widgets should have a focuspolicy of Nofocus selected. Set the properties of these in the property editor, seen on the right-hand side of Figure 3. All other widgets should have their focuspolicy set to Strongfocus. All widgets should have their enabled check box checked and should also have their tooltip and whatsthis text properties set to something descriptive.
We now want to set the size policy of all of the widgets so that the dialog will resize properly. The four widgets on the upper left of the dialog and the two Qbuttons have their horizontal and vertical size policies set to Fixed. They won’t be resized. We want all the other widgets to stretch when we resize the dialog. The Status label has its horizontal and vertical size policies set to Preferred. The two list widgets, listwidgetrepo and listwidgetstatus, have their horizontal and vertical size policies set to Minimum Expanding.
Select Form > Preview from the menu and make sure that the dialog resizes properly when you stretch it by pulling on the bottom right-hand corner of the dialog.
There are just a couple more steps. Select Edit > Edit Buddies from the menu and make the Git Repositories label a buddy of listwidgetrepo. Then make the Status label a buddy of listwidgetstatus.
Now, select Edit > Edit Tab Order from the menu and set the tab order to 1. listwidgetrepo,
2. listwidgetstatus, 3. pushbuttonrefresh and
4. pushbuttonclose.
Finally, select Edit > Edit Signals/slots and drag a line between pushbuttonclose and the Close Button on the top right-hand corner of the gitstatus Dialog. Use the Configure Connection dialog to make the SIGNAL clicked(bool) connect to the SLOT accept(). This is
shown in Figure 4.
Gitstatusdialog.ui is an XML file. We’re going to use the pyuic5 tool to convert it to a Python file. At the command line type: pyuic5 -x -o gitstatus.py Gitstatusdialog.ui.
We’re now going to edit gitstatus.py, so be careful not to run the previous command again or you’ll overwrite your Python file.
Before we edit gitstatus.py, let’s see what pyuic5 gave us. At the command line type: python3 gitstatus.py
You should see the empty dialog. Clicking pushbuttonclose should close the application, since we’ve already hooked up the signal and slot for that button. pyuic5 created a working dialog that doesn’t really do anything.
Gitstatus.py should now contain the class Ui_gitstatus. The class should have two methods: setupui(self, gitstatus) and retranslateui(self, gitstatus). The -x option in pyuic5 also created a main section for our project.
Now, you’ll add some functionality to gitstatus.py. Open up gitstaus.py in your text editor. Add #!/usr/bin/ env python3 as the first line in the program. This tells Bash that if we ask it to execute gitstatus.py, it will load Python 3 to do it. In order for that to happen, we need to set the flag that tells Bash that gitstatus.py is an executable program. At the command line, type: chmod +x gitstatus.py
Go back to your editor and add the signals and slots for pushbuttonrefresh, listwidgetrepo and listwidgetstatus. At the end of the method retranslateui(self, Gitstatus) add the following lines:
# hook up signals (widget outputs) and slots (class member methods) self.listwidgetrepo.itemselectionchanged. connect(self.reposelectionchanged) self.listwidgetstatus.itemselectionchanged. connect(self.statusselectionchanged) self.pushbuttonrefresh.clicked.connect(self.refresh).
Now add the methods that we want to call when pushbuttonrefresh is clicked or the selection is changed in listwidgetrepo or listwidgetstatus: def refresh(self): # pushbuttonrefresh has been clicked def reposelectionchanged(self): # listwidgetrepo selection changed def statusselectionchanged(self): # listwidgetstatus selection changed
Populate these functions by copying the code from
gitstatus.py.original. Continue adding methods to
gitstatus.py. There is a lot of code to copy. If you’re building gitstatus.py from scratch, open gitstatus. py.original in your code editor and copy the code from there. You need to add the methods shown in the boxout (see right).
The program works by initially searching for git repositories, determining their default colour (red, orange or green) by invoking the git status --porcelain command, populating and then alphabetising listwidgetrepo. This happens in the method populatedialog(). We call populatedialog() once in
main(), before the dialog is visible. We also call
populatedialog() when pushbuttonrefresh is clicked.
Every time listwidgetrepo’s selected working directory is changed, and it is changed when we first populate listwidgetrepo, the selectionchanged()
method is called. That’s where we call git and get all of the modified, deleted, added, removed or untracked files in the selected working directory. We determine the colour for each issue that git status reports, and then we populate listwidgetstatus with that information.
The first thing we do is to construct a Qapplication object with any options passed to main() on the command line. This occurs in line 466 of gitstatus.py:
app = Qapplication(sys.argv)
We then set the application’s name and version for the Qcommandlineparser class in lines 467 and 468:
clp = Qcommandlineparser()
This handles the --help, --version, and the --verbose
command line options. This is all taken care of for us by the clp.process(sys.argv) statement in line 476.
We then construct our dialog in line 478:
Gitstatus = Qtwidgets.qdialog() # construct Qdialog ui = Ui_gitstatus()# instantiate ui ui.setupui(gitstatus) # configure all widgets ui.isverbose = clp.isset(verboseoption)
We now call the parseconfigfile() function to read
gitstatus.ini, which is located in the same directory as gitstatus.py. If gitstatus.ini is not found, parseconfigfile() will create gitstatus.ini and make
reasonable assumptions about your environment. The purpose of parseconfigfile() is to construct a list of start directories where you want to search for git repositories. If you haven’t already created gitstatus.ini, the list of start directories (startdirs) is populated with a single value: your home directory. The method parseconfigfile() is also responsible for creating a list of git repositories we want to ignore (exceptdirs).
All of our development takes place in /home/js/ Development, so our gitstatus.ini looks like this:
[startdirs] /home/js/development
[exceptdirs] github.com golang.org
When you invoke gitstatus.py with the --verbose option, you will see the output of parseconfigfile() on the console. The remainder of main looks like this:
if not ui.parseconfigfile(): # read the .ini file sys.exit(1) ui.populatedialog() # populate the dialog’s widgets Gitstatus.show() # make the dialog visible sys.exit(app.exec_()) # handle all messages until exit
Before populatedialog() is called from main,
parseconfigfile() has been executed. This populates the Qstringlists startdirs and exceptdirs from gitstatus.ini in the program’s directory.
First, populatedialog() gets the date and time and populates the Datetimeedit Qtextedit control. Next, starting at startdirs[0] and continuing with any other startdirs, it walks the directory tree and searches for directories that contain the hidden directory .git.
Inside the directory walk, populatedialog() does a list comprehension, which is a way of pruning a list in place.
dirs[:] = [d for d in dirs if d not in self.exceptdirs].
This removes any directories from dirs[] that were in exceptdirs. For each path that’s left in dirs[] we check if it contains a hidden directory .git.
To display a git repository’s working directory,
populatedialog() creates a Qlistwidgetitem object and populates it with the working directory’s path. It then calls quickcheckpriority(dir) to determine the colour with which to paint the Qlistwidgetitem. It sets the data property of the Qlistwidgetitem to the text of the colour that we want to use the paint the Qlistwidgetitem. It then adds the item to listwidgetrepo, the list of working directories.
When all the qualified git repository directories have been added to listwidgetrepo, populatedialog()
then alphabetises listwidgetrepo.
self.listwidgetrepo.setsortingenabled(true) self.listwidgetrepo.sortitems()
It then repaints the foreground colour of all of the items in listwidgetrepo.
for i in range(nrepocount): item = self.listwidgetrepo.item(i) item.setforeground(qcolor(item.data(qt.userrole))) Finally, populatedialog() sets the Qtextedit widget Reponumfound to the number of git repositories found.
Currently every time an item in listwidgetrepo is selected, it paints the text in black. We need to fix that, by setting a style sheet for listwidgetrepo’s selected items at the beginning of reposelectionchanged().
Every time an item is selected, listwidgetrepo emits a signal to tell any interested parties that the selection has changed. We set up a slot to capture that signal, so that every time listwidgetrepo’s selection is changed, the class function reposelectionchanged() is called.
The first thing we do in reposelectionchanged() is to populate listwidgetrepo’s selected item style sheet:
self.listwidgetrepo.setstylesheet(“”” Qlistwidget::item:selected { color: ““” + self.listwidgetrepo.currentitem().data(qt.userrole) +
““”; } Qlistwidget::item:selected { background-color: white; } Qlistwidget::item:selected { border: 2px solid red; } ““”)
Listwidgetrepo was created in single selection mode, so you can’t select more than one item at a time. The style sheet tells listwidgetrepo that when a new selection is made the item’s background colour is white, a red border is painted around the selected item and the item’s text is painted in the colour saved in the data field of the current item.
We also need to clear and populate listwidgetstatus. We must do this every time listwidgetrepo’s selection is changed, so we do it after setting listwidgetrepo’s selected style sheet in reposelectionchanged().
# use git status --porcelain to interrogate selected repository p = Popen([“git”,“status”,“--porcelain”], cwd = self.listwidgetrepo.currentitem().text(), stdout =
PIPE) p.wait() out = p.communicate()[0] strarray = out.splitlines() self.listwidgetstatus.clear()
This invokes the command git status --porcelain in the repository’s working directory. The command git status produces verbose output, which is good for humans; gitstatus --porcelain is more machine friendly. It produces an \n terminated line of output for each issue found in the git repository. All the lines are read into the string out , this is then split into the string array, strarray. Each element of strarray contains an issue found in the repository. If strarray is empty then we write the single entry ‘working directory clean’ and paint it green.
The first two bytes of strarray[i] are the characters X and Y. They are followed by a space and the name of the file relative to the repository’s working directory. X indicates changes ready for commit, and Y indicates
changes that are not yet staged for commit: if sizeoflist == 0: # no problems to report item = Qlistwidgetitem(‘no modified, deleted or
untracked files’)problems tem.setdata(qt.userrole, COLOR_GREEN) item.setforeground(qcolor(color_green)) self.listwidgetstatus.additem(item) else: while i < sizeoflist: line = strarray[i].decode().strip() i += 1 x = line[0]; y = line[1]; if x == ‘M’ or y == ‘M’: # modified s = “modified: “color = COLOR_RED elif x == ‘D’ or y == ‘D’: # deleted s = “deleted: “color = COLOR_ORANGE elif x == ‘A’ or y == ‘A’: # added s = “added: “color = COLOR_ORANGE elif x == ‘R’ or y == ‘R’: # renamed s = “renamed: “color = COLOR_ORANGE elif x == ‘C’ or y == ‘C’: # copied s = “copied: “color = COLOR_ORANGE elif x == ‘?’ or y == ‘?’: # untracked s = “untracked: “color = COLOR_ORANGE
item = Qlistwidgetitem(s + line[2:]) item.setdata(qt.userrole, color) item.setforeground(qcolor(color)) self.listwidgetstatus.additem(item)
We have somewhat arbitrarily decided that a modified file is a serious problem and everything else is a minor problem. If you have a different idea about what constitutes a serious problem, this function and
quickcheckpriority() are where you should go to make your changes.
That ends the code for reposelectionchanged. We also capture the signal emitted by listwidgetstatus when its selection is changed by the keyboard or the mouse, but we have little to do in response to that signal. The only thing we need to place in
statusselectionchanged() is the listwidgetstatus selected style sheet, so that we’ll preserve the text colour when the listwidgetstatus selection is changed:
def statusselectionchanged(self):
““” listwidgetstatus selection has changed
- modify the listwidgetstatus stylesheet
““” self.listwidgetstatus.setstylesheet(“”” Qlistwidget::item:selected { color: ““” + self.listwidgetstatus.currentitem().data(qt.userrole) + ““”; } Qlistwidget::item:selected { background-color: white; }
Qlistwidget::item:selected { border: 2px solid red; }
““”)
When pushbuttonrefresh is clicked we capture the signal it emits and then can invoke the refresh()
member function.
As you can see we clear everything, so before clicking pushbuttonrefresh you can modify gitstatus.ini and refresh() will detect the changes. You can also open a console and use git commit to commit files before clicking refresh().
The refresh() method will get the text of the current selection, and then after deleting and restoring the contents of listwidgetrepo it will try to find that text. If it finds it, it will select the same item that was selected before refresh() was called.
And that brings us to the conclusion of our description of gitstatus.py. We hope you find it to be a useful tool. We also hope that it encourages you to build other tools. Have fun!