[personal profile] tara_hanoi
Sorry for the delay in posting, there's been a post I've been trying to articulate for months, and every time I feel ready to finish it, another related event crops up, and changes (but reinforces) the point I want to make. So, I've stashed it for now to share something shiny I made.

You see, I've recently switched jobs, and I'm now working as a developer. I have to say, the benefits that switching jobs has had on my general well-being cannot be understated (sadly, I will be understating them in this post as that's possibly for another post).

However, professionally speaking my new role meant switching from my familiar use of mercurial to git. I was a bit wary at first, mostly because most blogs about git usage have a strange obsession about exposing the plumbing of the system, giving the impression that you need to understand the internals in order to simply use it, but I have to say I'm liking it now that I'm using it. That said, I've always been dabbling with git over a long period, but because I had done paid, productive work with mercurial, that got priority over my own git-dabbling. Now, part of my job is to learn how to use git productively, so I've taken some of my start-up time to go on a deep-dive into the system, and its supporting ecosystem, in order to customize my setup to a point beyond all recognition my needs.

If you google around about git and bash, you'll inevitably stumble on sites with completion recipes, prompt hacks, and other fun aliases to give you more information and insight into what you're doing and where. I have to admit, I felt a bit left out. I had to blog about something to do with git and bash.

Now, here's the thing. In my new workplace, we've got about 20 different git repos. There's apparently A Very Good Reason(tm) for this, but what it means is that when I'm flitting through the sources, I'm dealing with a forest of separate source trees, each with a roughly identical directory structure, and I'm prone to wanting to 'cd' up to the root, then switch to the next one. Now, it's quite possible that my Google-foo failed me, but I couldn't easily find something allowed me to realias 'cd' so that it that got me to the top-level directory of the repository when I just typed "cd" on its own; I'd just end up back in my home directory. So, after having a peek at the bash auto-completion code that's shipped with most git packages, I decided to make my own.

I shouldn't be as proud of this moment as I actually am. Seriously, I should not. It took some googling, and trawling StackOverflow for some of the interesting tricks I used, but overall, I had this done in about an hour or so.

After mentioning it on facebook, and how disgustingly proud I was of making this monstrosity, I was encouraged to join the trendy github hoardes and share it. So I did. I know, I'm now one of those trendy hipster GitHub folk, and I'm still disgustingly proud of this. It's like a kid running up to their parents holding a potty with a perfectly formed poo inside saying, "Look mommy, I didn't miss this time!".

With that adorable image aside, it was mentioned that if I'm blogging about this I might as well explain some of the tricks in there, so that's going under a cut. But first, a few notes:

  • You'll want to source this file after you source your bash-completion script for git, otherwise this won't work (this is kinda by design, more on that in the fine details).

  • While I've done my best to test this, there may be corner cases.

  • The idea is to get to the top-level directory of your git repo with a blank "cd" call. If you type it again, "cd" should behave normally.

Now for the finer details... While I'm pulling apart the code, I will be looking at this revision.

One of the first things I was conscious of was that I was over-riding "cd" with an alias. This is probably dangerous for a number of reasons that I won't go into. So I thought it'd be an excellent idea to only override "cd" if we had the function that I was going to rely on. That's what the declare statement (line 10) is for. For the uninitiated in shell scripting, every command run in the shell returns a return code, and the shell picks up that value and puts it into a magic variable called '$?'. Have you ever written a simple C program and wondered why main returns an "int" and why you have to put "return 0;" at the end of main? Have you wondered where it goes? Well, in the Bourne family of UNIX shells (i.e. /bin/sh, bash and ksh), that return code goes into '$?'. Don't believe me? Try it. It's fun.

Basically, $? is what you check to see if stuff worked. If it worked, you should get a value of 0. If it something went wrong, it should not return 0. So whenever you see an if statement and see $? in there, it's checking to see how the statement right before it behaved. So I'm checking out how "declare -f" fared.

So what does the "declare -f" do? At a high level, I'm checking to see if a function called "__gitdir" exists. Why? Because __gitdir is what's going to do most of the magic for me, and if it doesn't exist, there isn't even a point in overriding cd (well, technically there is, because it might be declared later, but if it doesn't exist when you call cd, things will get bad). So what's happening behind the scenes, and why do I specify "-f"?

Now, here I need to go on a bit of a history lesson (warning, I'm not claiming to be accurate, I'm more describing my more-than-likely flawed understanding of the stuff I've had to work with). Have you ever used /bin/sh? I mean, have you ever really used it? Have you ever had to write a script specifically for the bourne shell? If you haven't, count your lucky stars, and enjoy the abundant fruits of bash and ksh as they scrabble to reclaim some ground lost to terrible design decisions that have become "legacy behaviour" over the decades. As far as pure, unadulterated Bourne shell (aka "/bin/sh") goes, namespaces aren't a thing. In fact, I'm pretty sure that shell was around before namespaces got popular in the programming community (except maybe for the fancy academic languages like pascal or smalltalk). Basically, you had one namespace (well, technically two - but you get the contents of both when you type "set"), that was basically a big ol' key-value table. You put a new entry in the table, and everyone could see and modify that entry. Local variables? We don't need no stinking local variables. Arrays? Nah, you can have a single variable with spaces in the characters, that's good enough! Programmability in a shell was more for convenience than for anything serious. Variables go on one table, and executables go on another, and if you can't find the executable in the executables table, then look it up in the directories $PATH.

For the purposes of what we're talking about, we can count an alias as a lobotomized function. I think there are not-so-subtle distinctions that shell programmers would shout at me for ignoring, but think of "alias" as being a boilerplate for declaring a really simple (one-line) functions. Now, remember that if a function already exists, and you declare it again, you overwrite that existing function1 - Unlike PHP2, once you declare a function, you can overwrite that sucker. As you might guess, blindly overwriting a function is not a great idea. However, because that was how /bin/sh did it, its descendents kept on the tradition for fear of breaking legacy scripts (remember, bash and ksh were meant to be drop-in replacements for /bin/sh, and trust me when I say there are systems that still use scripts written for /bin/sh). However, if you were using the new shiny Bourne Again Shell (bash) or the Korn Shell (ksh), you might want to know ahead of time if you were going to poop all over a pre-existing function, so we have some commands like "type" and "typeset" (also known as "declare", in the bash manual they appear to be the same thing). Typeset is used for declaring a variable (this is especially useful when it comes to declaring arrays and read-only variables), while type tells you what executable is actually called. For instance, if you run "type umask" it will tell you that you're running a shell builtin3.

A shell builtin is a special case where the shell programmers said, "Why rely on a command in the filesystem, when it's more sensible for the shell to implement it?". There are some commands where it's really, really important for it to be a shell builtin. This is because, in UNIX, a child process inherits a copy of its parent's exported environment variables, but can't propagate any changes back up to the parent. So anything that changes the state of the currently running shell process needs to be a builtin, so that'd be things like your default file permission mask (umask), or your current working directory (cd). Of course, the next generation of shells took that further.

You see, forking processes isn't that light-weight on a system (it has to tell the kernel to grab space on a process table, allocate a new heap and stack for the process, copy the environment variables on, and all that jazz), so it's easier to build common functionality into the shell, especially for short-lived stuff that you use often. This means that the shell does all that for you without having to do any context switching. Things like "test" will be a shell builtin in bash. Sure, there's /usr/bin/test, but the shell can do it for you, and it's a really simple spec to implement, so the shell does it.

Anyway, "type" will tell you if something is a shell builtin. It will also tell you if something is an alias or a function. The only problem is, its output is not that easily parseable. Well, it is, you just chuck the output through grep looking for "is a function", while chucking stderr to /dev/null (in case what you're looking for doesn't exist) but that pipe means a fork/exec, making it a bit slower. But it turns out that typeset/declare was written more for scriptability than for interactivity (like "type" was). You tell typeset that you think something's a function, and it will put 0 in $? if you were right, or 1 if the function doesn't exist. It'll also write its declaration to stdout, but you can chuck that output to /dev/null, like I did. The return code is all we care about right now4. Once we know __gitdir() exists, we can override "cd" knowing we won't end up with a broken function5.

Now the fun begins. Functions and aliases take precedence over builtins, which take precedence over executables in $PATH6 but, in all of the Bourne shells, recursion is entirely possible7, although how advisable it is to go down that dark, dark path depends on what flavour you're using. Anyway, because recursion is possible, and our function has the same name as the builtin we're trying to override we need a way to tell bash that we're not actually looking to crash our system when we try to change directory, but we want to use its builtin. It turns out that it's as simple as putting the keyword, "builtin" before we call "cd" (lines 15, 26 and 28). That's the bulk of our magic done.

By the way, if you're not familiar with functions in shell scripting, $# tells you how many arguments you have, while $@ gives you all your arguments at once, and you can access your first 98 arguments through $1, $2 and so on. After that you need to start using the "shift" builtin, or you use $@. Anyway, for the most part we're only really interested in if we even have arguments to our function (line 14). If we have arguments, we pass them onto cd, and be on our way (line 15). If we don't have any arguments, that's when we can start doing the things I want to do.

Now I get to tell you what the __gitdir function really does. If you're not in a git repository it returns nothing. But if you are inside a repository, it doesn't tell you where the top-level directory is, it tells you where git's all-important meta-data lives; which just so happens to be right below the parent directory. So if the meta-data lives in "/home/aoife/my_repo/.git", and I want to go to "/home/aoife/my_repo", all I have to do is strip off the last entry. It turns out we have a really simple way to do that, it's called "dirname"... but that's getting a tiny bit ahead of ourselves because line 17 has an awful lot going on.

Firstly, I don't want to call __gitdir more than once. I don't know how expensive it is to call, so I'd rather put it into a variable. This is where we get to deal with yet another legacy decision from bourne shell. Have you ever wanted to put the current date into a filename or into a file? How did you do it? If your answer involves backticks, you'd be technically correct, but morally wrong. I don't know why, but most introductory material to shell scripting teaches people to use fucking backticks. You might wonder why backticks are a problem. If you have to wonder, you've obviously never tried to nest the fuckers. If you try to go more than 2 levels down with backticks, your scripts suddenly take on an air of modern art, as if M.C. Escher did an explorative work on the visual depiction of pain through the medium of backslashes; it's recursive, it's trippy, and if you stare at it too hard, your brain melts out through your eyesockets. Unfortunately, such acts are a lot like throwing your hand into a running car engine to remove an obstruction... it's something you don't want to do, but it's necessary.

One of the lesser-known features of bash and ksh is the lovely $() operator. For the sake of one extra character you can get all of the power of backticks, but with proper fucking nesting... use it. Even for the most trivial of things, use the $() notation. I cannot think of a single reason, bar /bin/sh compatibilty, that you should ever use backticks instead of this notation.

By the by, one of the reasons you might want to nest backticks is when you actually want to use the output from a function in a shell script. For the most part, you can treat all of the Bourne shells as a string-only language, like TCL. /bin/sh doesn't do numbers - that's what /usr/bin/expr is for - but it can do strings9. There is, however, the exception that proves10 your sanity. Functions only return numbers. Integers, in fact. These are then picked up in $?, like everything else. So you can't return a string. All you can do is print strings to stdout... so how do you pick them up? Well, for /bin/sh, you capture the output just like you capture the output of any other executable... with backticks. And if you want to pass today's date as an argument to the function, you nest the backticks. So, instead we use $(), just like anyone else who values their time, and cognitive faculties.

So, line 17 just has me calling __gitdir, and putting it into a variable called _gitroot. Notice the "local" keyword? That's me availing of something that bash/ksh gives me, which is local variables. That variable should never hit my global namespace, but I've named it as if it would anyway, just in case (no reason, just I only discovered that "local" was a thing while I was debugging my function).

Lines 18 through to 23 are a note to myself, and whoever else that hacks on it, that dirname can't be trusted to behave the same on all platforms. I mean, it will probably be fine, but sometimes other platforms do fun things.

Line 24 does 2 checks, and the order is slightly important here. The first check ("-z $_gitroot") checks that the $_gitroot variable is empty. If that's not true, it'll run onto the second condition, where I strip off the ".git" at the end, and check that I'm not already in that directory11. If I'm already in that directory, or I'm not in a git repo, I call cd as normal (line 26). If I'm in a repository, but not at the top level, I strip off the ".git" at the end, and change to that directory (line 28). And then I'm done.

However, you might wonder why I'm calling dirname twice to strip off the ".git" in each case? Why not bung it into a variable or even into $_gitroot... well, it turns out that dirname doesn't like to be called without arguments, and if I don't check the output of __gitdir to see if it's empty first, it will keep whinging when I type "cd" without any arguments outside of a git repository. I could direct from dirname stderr into /dev/null, but that feels a bit dirty to me (I may change my mind on that). So, for the moment, I just call dirname twice, once for the conditional, and once to actually move to the directory.

And that's my tour of the code12 done. I hope someone finds it (and the actual function) vaguely useful, and I really hope that the blog was at least fun to read, if not that informative. I welcome any comments, questions or issues on either the blog or on github (although if you mention an issue, please put the name of the function's filename in the title).

1 As a fun aside, gentoo uses this fact to implement a sort of Object-oriented-like inheritance in its ebuild scripts by the way, it's kinda funky, and helped me wrap my head around inheritance in a very twisted way.

2 I was fully prepared to say, "Oh, this was only the case in PHP 4", but nope, I was wrong. This is still standard behaviour, for better or for worse... generally worse. At least PHP 5.3 has namespaces now, so that at least mitigates the problem of having colliding helper function definitions when you source multiple 3rd party libraries.

3 ...And if it's not telling you that, you're going to have a lot of fun when you think you've changed your default file permissions.

4 Yup, that was a pretty long digression, even for me.

5 However, we can yet break cd by overwriting any other functions that override "cd". I have yet to address this issue in my code. I am very much open to suggestions of how to delegate back to a pre-existing "cd" function.

6 Although if you directly call an executable by its full pathname, you execute that file. This is why you'll see a lot of scripts do things like call variables like $_CP where the value in $_CP is "/bin/cp", it's to make sure the executable is called, not the builtin, because depending on the shell, the builtin might do things... differently, especially if BusyBox - the King, Queen, God6a, and Country of builtins - is likely to be involved in any conceivable way.

6a "God is a builtin" is the name of my next album.

7 Although in /bin/sh7a only global variables are supported, so you can imagine how much fun that is.

7a In Metalocalypse, Doctor Rockso did cocaine; in my previous job, I did /bin/sh...

8 $0 is reserved for the name of the program. Even if you're in a function. In fact, in general, you're probably better off going with trying to convert $@ to an array, and using that, if you can. Think of it as trading readability for unambiguity8a.

8a It's not contradictory, it's a virtue I learned from working with perl for 2 years.

9 Badly.

10 I mean this in the old-timey sense of the word. You know that phrase, "The exception that proves the rule". In that case, the verb "prove" actually means "to test", rather than irrefutably support a statement.

11 Again, this is where I'm not a fan of either the output from __gitdir() or that of dirname. If I'm in the top-level directory, __gitdir 'returns' ".git" rather than a full path. Given that dirname tells me that the directory containing ".git" is "."... Both values are technically correct. However, if you ask me where I am, and I give the answer, "I am here", my answer will be "technically correct", but potentially as useful as a caffeinated frog riding a centrifuge11a.

11a That is to say, there may be a use for it, but within a narrow scope. If you can think of a genuine use for a caffeinated frog riding a centrifuge, I welcome any comments on this blog.

12 Along with my technically inaccurate explanations of shells seem to work. (I need to make a post on how I prefer, and always will prefer, a useful story over a complicated truth... although I appreciate the need for both)



September 2015


Most Popular Tags

Style Credit

Expand Cut Tags

No cut tags
Page generated Jul. 21st, 2017 06:43 am
Powered by Dreamwidth Studios