Comments:"charles leifer | "j" for switching directories - hacking "cd" with python"
URL:http://charlesleifer.com/blog/-j-for-switching-directories---improving-the-cd-command-/
I've started to use python to create "glue" scripts to automate repetitive tasks. This is a new habit I've tried to get into, and it requires that I be mindful when I'm working to spot problem areas.
I was thinking about tasks that could benefit from a little automation and my bash history was instructive. Below are my 10 most commonly-used commands:
19 curl 20 fab 28 ./runtests.py 33 pip 67 pacman 75 ./manage.py 114 vim 248 cd 349 git 520 ls
If you're curious how yours looks, try the following command:
cat ~/.bash_history | sed "s|sudo ||g" | cut -d " " -f 1 | sort | uniq -c | sort -n
I decided to see if I could improve my usage of "cd".
Improving the "cd" command
Everyone uses cd
a lot, I'm no exception. Because I use virtualenvs for my
python projects, I'm often "cutting" through several layers of crap to get to
what I actually want to edit.
The two annoyances I was trying to fix were:
There are directories I use a lot, but making bash aliases for them is not maintainable. I should be able to get to them quickly. I have to keep a mental map of the directory tree to go from one nested directory to another -- e.g. cd ../../some-other-dir/foo/. Rather than backtracking, it would be nice to "jump".The solution I came up with stores directories I use (the entire path),
and then I can perform a search of that history using a partial path. In the example above, I'd just type j foo
instead.
Finding the best match
The biggest challenge with this script was deciding how the search should work. The way I calculate the "best" match for the given input is to iterate through the history and count the number of dangling characters after the match.
So if I am searching for "scr" and iterating through my directory history, I would calculate the following scores:
- /home/charles/photos/screenshots/ --
9
- /home/charles/tmp/scrap/ --
3
- /home/charles/.screenlayout/ --
10
- /home/charles/code/scraper/ --
5
- /home/charles/code/something/ --
Inf
Here is a little python function that calculates this "cruft":
defcruft(directory,search):pos=directory.rfind(search)# rfind will return -1 if no matchifpos>=0:returnlen(directory)-pos-len(needle)returnfloat('Inf')
In the example above, since ~/tmp/scrap/
has the least amount of extra
junk, the program will select that as the best match.
The other neat part is that the directories will be stored sorted last-used to most-recently-used. That way, in the event of a tie, I will be sure to pick the match that was used most recently. Here is what the search function looks like:
defsearch_history(history,needle):nlen=len(needle)min_cruft=float('Inf')match=Nonefordirectoryinhistory:pos=directory.rfind(needle)ifpos>=0:# how much extra cruft?extra=len(directory)-pos-nlenifextra<=min_cruft:min_cruft=extramatch=directoryreturnmatchorfull_filename(needle)
Putting things together
The final trick is some short-circuit logic that will skip the search if the input exactly matches a directory -- this way I can "cd" into directories that are not in the history yet.
The rest of the code is basically plumbing to load and save the history file, and a helper function to generate a full path for a directory. The program itself simply outputs a "cd" statement, so you will need to add a function to your .bashrc to evaluate the output of the program.
All together, here it is:
#!/usr/bin/env python"""Usage: Instead of using "cd", use "j"Add this to .bashrcj () { $(jmp $@)}"""importosimportsysenv_homedir=os.environ['HOME']db_file=os.path.join(env_homedir,'.j.db')HIST_SIZE=1000deffull_filename(partial):returnos.path.abspath(os.path.join(os.getcwd(),os.path.expanduser(partial)))defsearch_history(history,needle):nlen=len(needle)min_cruft=float('Inf')match=Nonefordirectoryinhistory:pos=directory.rfind(needle)ifpos>=0:# how much extra cruft?extra=len(directory)-pos-nlenifextra<=min_cruft:min_cruft=extramatch=directoryreturnmatchorfull_filename(needle)defread_history():hist_hash={}ifos.path.exists(db_file):withopen(db_file)asdb_file_fh:history=db_file_fh.read().split('\n')fori,iteminenumerate(history):hist_hash[item]=ielse:history=[]returnhistory,hist_hashdefwrite_history(history):withopen(db_file,'w')asdb_file_fh:iflen(history)>(HIST_SIZE*1.4):history=history[-HIST_SIZE:]db_file_fh.write('\n'.join(history))defsave_match(history,hist_hash,match):idx=hist_hash.get(match)ifidxisnotNone:history.pop(idx)history.append(match)write_history(history)if__name__=='__main__':ifnotlen(sys.argv)>1:# Handle the case when you just type "j"print'cd %s'%env_homedirsys.exit(0)history,hist_hash=read_history()needle=sys.argv[1]match=full_filename(needle)ifos.path.isdir(match):# Handle the case when the input is a valid directorysave_match(history,hist_hash,match)else:# Perform a search for a partial matchmatch=search_history(history,needle)ifos.path.isdir(match):save_match(history,hist_hash,match)else:match=needleprint'cd %s'%(match)
Here is how I might use it:
~ $ j code/peewee/ # cd into "~/code/peewee/"peewee $ j ~/tmp/scrap/ # cd into ~/tmp/scrap/scrap $ j pee # cd back into "~/code/peewee"peewee $ j # cd back to "~"
Reading more
I hope you found this post interesting. There are a couple other projects that provide even more sophisticated "smart cd", I recommend you check them out if you're interested:
If you have any suggestions or improvements I'd be interested in hearing them, feel free to leave a comment!
Andrew | April 2013, at 09:49
Incidentally, this:
cat ~/.bash_history | sed "s|sudo ||g" | cut -d " " -f 1 | sort | uniq -c | sort -n
would earn a "useless cat" award [1]. Utilities like sed, awk, and grep take the file name as the final argument:
sed "s|sudo ||g" ~/.bash_history | cut -d " " -f 1 | sort | uniq -c | sort -n
[1] http://partmaps.org/era/unix/award.html