4

I wrote simple command that lets me run the last N commands from terminal history. It looks like this: $> r 3 which will replay the last 3 commands.

I have the following alias in my bash profile: alias r="history -w; runlast $1"

And then the following simple perl script for the runlast command:

#!/usr/bin/env perl
use strict;
use warnings;

my $lines = $ARGV[0] || exit;

my @last_commands = split /\n/, 
    `bash -ic 'set -o history; history' | tail -$lines`;

@last_commands = 
    grep { $_ !~ /(^r |^history |^rm )/ } 
    map { local $_ = $_; s/^\s+\d+\s+//; $_ } 
    @last_commands;

foreach my $cmd (@last_commands) {
  system("$cmd");
}

This works but my bash profile has aliases and other features (e.g. color output) I want the perl script to have access to. How do I load the bash profile for perl so it runs the bash commands with my bash profile? I read somewhere that if you "source the bash profile" for perl you can get it to work. So I tried adding source ~/.bash_profile; to my r command alias but that didn't have an effect. I'm not sure if I was doing that correctly, though.

3
  • See also How to call a function (defined in shell script) in a Perl script and Running system command under interactive bash shell Commented Aug 19, 2018 at 6:41
  • 3
    Its generally a bad idea to use aliases in a script because that means the behaviour can vary depending on where it is run, which is a support nightmare. It is better to use functions instead. Commented Aug 19, 2018 at 6:52
  • 1
    $1 in the alias definition doesn't mean what you hope it means. Anyway, the only thing you really need to know about aliases is, use functions instead. Commented Aug 19, 2018 at 8:02

3 Answers 3

3

The system forks a process in which it runs a shell, which is non-login and non-interactive; so no initialization is done and you get no aliases. Also note that the shell used is /bin/sh, which is generally a link to another shell. This is often bash but not always, so run bash explicitly.

To circumvent this you need to source the file with aliases, but as bash man page says

Aliases are not expanded when the shell is not interactive, unless the expand_aliases shell option is set using shopt (see the description of shopt under SHELL BUILTIN COMMANDS below).

Thus you need shopt -s expand_aliases, as mentioned. But there is another screw: on that same physical line aliases are not yet available; so it won't work like this in a one-liner.

I'd also recommend to put aliases in .bashrc, or in a separate file that is sourced.

Solutions

  • Add shopt -s expand_aliases to your ~/.bashrc, and before the aliases are defined (or the file with them sourced), and run bash as a login shell

    system('/bin/bash', '-cl', 'source ~/.bashrc; command');
    

    where -l is short for --login.

    In my tests the source ~/.bashrc wasn't needed; however, the man page says

    When bash is invoked as an interactive login shell, or as a non-interactive shell with the --login option, it first reads and executes commands from the file /etc/profile, if that file exists. After reading that file, it looks for ~/.bash_profile, ~/.bash_login, and ~/.profile, in that order, and reads and executes commands from the first one that exists and is readable.

    and goes on to specify that ~/.bashrc is read when an interactive shel that is not login runs. So I added explicit sourcing.

    In my tests sourcing .bashrc (with shopt added) while not running as a login shell didn't work, and I am not sure why.

    This is a little heavy-handed. Also, initialization may be undesirable to run from a script.

  • Source ~/.bashrc and issue shopt command, and then a newline before the command

    system('/bin/bash', '-c', 
        'source ~/.bashrc; shopt -s expand_aliases\ncommand');
    

    Really. It works.

Finally, is this necessary? It asks for trouble, and there is probably a better design.

Other comments

  • The backticks (qx) is context-aware. If it's used in list context – its return assigned to an array, for example – then the command's output is returned as a list of lines. When you use it as the argument for split then it is in the scalar context though, when all output is returned in one string. Just drop split

    my @last_commands = `bash -ic 'set -o history; history $lines`;
    

    where I also use history N to get last N lines. In this case the newlines stay.

  • history N returns last N lines of history so there is no need to pipe to last

  • Regex substitution in a map can be done without changing the original

    map { s/^\s+\d+\s+//r } @last_commands;
    

    With /r modifier the s/// operator returns the new string, not changing the original. This "non-destructive substitution" has been available since v5.14

  • No need to explicitly use $_ in the last grep, and no need for parenthesis in regex

    grep { not /^r |^history |^rm ?/ } ...
    

    or

    grep { not /^(?:r|history|rm)[ ]?/ } ...
    

    where parens are now needed, but as it is only for grouping the ?: makes it not capture the match. I use [ ] to emphasize that that space is intended; this is not necessary.

    I also added ? to make space optional since history (and r?) may have no space.

Sign up to request clarification or add additional context in comments.

Comments

3

The proper solution is to have your Perl script just print the commands, and make your current interactive shell eval the string printed from your history. (I would probably get rid of Perl entirely but that's beside the point here.)

If the commands get evaluated in the current shell, you avoid many contextual problems which would be very hard or even intractable with system() or generally anything involving a new process. For example, a subprocess cannot have access to non-exported variables in the current shell. var="foo", echo "$var"; r 1 is going to be very hard to solve correctly with your current approach. Using the current interactive shell will also naturally and easily solve the problems you were having with trying to get a noninteractive subshell act like an interactive one.

Aliases suck anyway, so let's redefine r as a function:

r(){
    history -w
    eval $(printlast "$1")
}

... where refactoring runlast into a different script printlast is a trivial additional requirement. Or maybe just turn it into a (much simpler!) shell function:

printlast () {
    history "$1" |
    perl -ne 's/^\s*\d+\s+\*?//; print unless m/^(history|rm?)($|\s)'
}

With this, you can also get rid of history -w from the r definition.

Notice how we are using Perl where it is useful; but the main functionality makes sense to keep in the shell when you're dealing with the shell.

9 Comments

` eval "$(printlast "$1")"` doesn't seem to work. I modified my perl script to return the commands separated by semicolons. The eval bash command returns -bash: 2: command not found
@StevieD Thanks for the feedback, I didn't have a chance to test the code until now. I removed the quotes around the argument to eval (duh) and switched back to Perl for the history post-processing for portability and predictability.
Works now. My bash skills are non-existent and the quoting or not quoting of stuff confuses the hell out of me. I've never tried to learn bash though. Guess it's time as I want to do more to automate things. I don't see any advantage to keeping things in bash as much as possible, though. Perl is great at these kinds of tasks and, I think, much more readable and easier to maintain.
The second paragraph explains in some detail why you need to solve this particular problem in Bash. I completely agree that modern scripting languages are much preferred for many things, though I now prefer Python over Perl.
I get why you need bash to output the result of the commands. I'm just saying I would not try to replace the entire perl script with bash in this case.
|
1

You can't source in a Bash script into a Perl script. The bash_profile has to be sourced in by the shell that executes the command. When Perl runs system, it forks a new shell each time.

You have to source in the bash_profile for each command that you run through system:

system('source ~/.bash_profile; ' + $cmd);

One more thing, system invokes a non-interactive shell. So, your Bash aliases defined in .bash_profile won't work unless you invoke:

shopt -s expand_aliases

inside that script

3 Comments

Thanks. Where in the script does the shopt go? In the system call?
I tried system('source ~/.bash_profile; shopt -s expand_aliases; ' . $cmd); but the aliases are not working. I'm probably doing it wrong. I also put shopt in the .bash_profile script.
The real beef is "don't do that".

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.