Teleporting to GitHub from the Terminal, but it's Python

in Programming & Dev2 years ago

(the "prequel" is here.)

Thumbnail image


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 :)

Just a shell. Photo by Javardh on Unsplash.

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.

I'd like to not care about compatibility by simply shipping machines instead of software, but this isn't a use case of Docker... Photo by Borderpolar Photographer on Unsplash.

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)

image.png

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)

image.png

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])

image.png

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)

image.png

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')"

image.png

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])"

image.png

...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 :)