(the "prequel" is here.)
Last time, I wrote a post about using Bash commands to open a GitHub repo's page on GitHub from the terminal. It's an approach I used to develop my own helper tool I call fur
, which I use to quickly perform some Git-related actions including opening Git repo pages in the browser. It works pretty well, when it works. It actually very quickly dawned on me that the current approach in making the program is not very optimal - it depends on a little way too many things to even be functional. Now, watch me complain on my own approach before we go into the main topic in the title of this post :)
One of the first thing that initially bothered me on my approach was that, since it is a pile of shell scripts, it naturally only works on POSIX-compatible shells. Bash, Dash, Zsh, Ksh, and probably others, but Elvish and Fish are definitely out of the question. Too bad, I mainly use Elvish as my main shell at work. How about I wrap my commands in an Elvish function to let Elvish start a new shell to run them in? It'll probably work, but after a few tries it very quickly showed that I have another problem. Turns out, not all shells perform the same!
Most of the development of the program are tested with Git Bash. It is a somewhat reasonable choice for me, as it is the shell that comes with Git installations on Windows. However, Git Bash has some wack behaviours. For example, start
is handled as a Windows command and the shell will try to run the Windows command if you use it. But, find
, which is also a Windows command, will not be handled in the same way. Git Bash will try to execute the find
binary that does the exact same thing as the find
we know on Linux. This is not the case for the MSYS2 Bash shell - it tries to run find
as a Windows command as well. This breaks the help
function of my program, since it relies on the find
command as we know on Linux! Besides that, the program currently relies on some command line tools that are only available on systems that uses GNU coreutils. Although almost all mainstream Linux distros use it, I still don't like the idea of relying on it, since it isn't available by default on some very popular systems like macOS.
"Shell scripts are the most 'it works on my machine' thing out there isn't it," well, you're right, internet stranger. To be frank, I don't exactly need the program to be compatible everywhere, since to my knowledge, I'm the only person on planet Earth that uses it. However, since I decided to make it public, might as well try to make it less painful to use.
Since most of the problems I have here are caused by either me relying on external dependencies that differ from system to system or depending on how a shell behaves, let's just solve that by dropping the dependency on both!
Before starting, I thought a lot about a question: Should I write this in C or C++ for speed or should I use something else?
I'm all for good performance, but then, the compatibility concern strikes me again. As far as we know, the logic relies on spawning git
processes, and it isn't as trivial as I hoped that it will be. Every operating system family has a different method for that. For Windows, you have CreateProcess
. On Linux and other POSIX/POSIX-ish systems, you have fork
. I could save myself some trouble by using an external dependency to handle it for me, but sometimes tells me that it might not be worth the effort - fur
is not even something that can be considered as performance critical!
So, I'll just keep it simple to myself. Python. And I don't care about compatibility with old versions. Time to write something!
The most important part of this whole operation of opening GitHub repo pages from the terminal is to get the remote origin URL from the local repo's config, which we previously used this command to get:
git config --get remote.origin.url
Since we are doing this in Python, we might be able to directly and easily read the config file in the .git
directory to take the value ourselves. However, a reasonable way to find the .git
directory in the current repo folder is, well, by running another git
command: git rev-parse --git-dir
. We aren't saving a lot by reading the file ourselves if we still need to run an external git
command at the end of the day... so let's just do it the easy way!
To run an external command and get its output in Python, we can use the subprocess
module and the run
method in it. It even comes with the arguments to automatically capture the command's output and the converting them to text instead of returning a bunch of bytes to you. Very neat.
import subprocess
result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
capture_output=True,
text=True)
print(result.stdout)
Right, we got our remote's origin URL. But, apparently I cloned this repo to my local directory through SSH... previously, a bunch of sed
commands were used to convert it to a GitHub page link. We can still do that here if we're stubborn, but since we are using Python, we can directly do some string manipulation with some regexes and get what we want.
import subprocess
import re
result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
capture_output=True,
text=True)
origin = result.stdout
origin = re.sub(
'^[email protected]:',
'https://github.com/',
origin)
origin = re.sub('.git$', '', origin)
print(origin)
Probably I could use simpler string substitutions instead of regexes to make it go faster, but I wanted to make sure that I am only replacing the start and the end of the string and leave the rest of it alone. Regexes is what I thought of using for now, but if you have a better idea, let me know!
Then, the only other thing we have to do is to start the browser, am I right? Can we use the same trick from the previous post again? Of course... we are just going to call xdg-open
with subprocess.run
.
import subprocess
import re
result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
capture_output=True,
text=True)
origin = result.stdout
origin = re.sub(
'^[email protected]:',
'https://github.com/',
origin)
origin = re.sub('.git$', '', origin)
subprocess.run(['xdg-open', origin])
Oops, seems like we didn't remove the trailing newline character at the end of our origin URL string. Either way, Firefox didn't complain and opened the page properly. I would expect that most browsers will not complain about a trailing newline character as well, but if you really need to remove it (for OCD or if your browser is really peculiar), we can do a strip()
on the origin URL string before passing it into the second subprocess.run
.
On Windows, the situation gets a little bit more interesting. Because the command we are relying on to start the web browser (start
) is a Windows command instead of an executable, we will need to use the shell=True
argument in subprocess.run
for it to be called successfully. When this argument is passed, Python will execute the command in cmd.exe
, which is the Command Prompt on Windows. That's how it'll work, since start
is a totally valid and recognized command in the Command Prompt world!
import subprocess
import re
result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
capture_output=True,
text=True)
origin = result.stdout
origin = re.sub(
'^[email protected]:',
'https://github.com/',
origin)
origin = re.sub('.git$', '', origin)
subprocess.run(['start', origin], shell=True)
Mission accomplished, I guess? Since we're already on Python, any other tasks that need us to append new things to the URL is going to be easy and trivial. It'll be easy to expand from here!
At some point I'll have to rewrite fur
in Python for this (and add a few launchers to run the program directly from the command line, Windows will never recognize Python scripts as executables on its own...), but before that, I somehow wonder... what if we just turn this into a Bash alias?
You can let Python execute any Python code directly by passing them as an argument to the Python executable under the -c
parameter. For example, you could print Hello World by doing so:
python3 -c "print('hello world')"
That isn't very interesting, but, you can pass multi-line arguments through your shell...
python3 -c "import subprocess
import re
result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
capture_output=True,
text=True)
origin = result.stdout
origin = re.sub(
'^[email protected]:',
'https://github.com/',
origin)
origin = re.sub('.git$', '', origin)
subprocess.run(['xdg-open', origin])"
...and if your shell cooperates, Python would happily run the whole thing. This means that, we can still add this whole chunk of code as a Bash alias! I'm not very sure would anyone wants to do that, but if you like having something like this as a Bash alias, you can.
I think that's all for now! Thanks for reading. See you next time :)