From 2d9ad818deecac24a9c06e2aaa0f1851ea559c1f Mon Sep 17 00:00:00 2001
From: Luke Schlather <luke.schlather@gmail.com>
Date: Fri, 13 Nov 2026 03:55:17 +0000
Subject: [PATCH] Add inc_append_history shopt option

When set, each complete interactive command is appended to $HISTFILE
after it is read and parsed and before it is executed, so it survives
if the shell is killed before the next prompt.

Because incremental appending can leave nothing to save at exit,
HISTFILESIZE truncation at exit now runs whenever HISTFILE is set,
not only when the shell saved lines.
---
 CHANGES              |  3 ++
 bashhist.c           | 81 ++++++++++++++++++++++++++---------------
 bashhist.h           |  2 +
 builtins/shopt.def   |  2 +
 doc/bash.1           |  9 +++++
 doc/bashref.texi     |  6 +++
 eval.c               | 12 +++++-
 tests/complete.right |  1 +
 tests/history.right  | 51 ++++++++++++++++++++++++++
 tests/history.tests  |  1 +
 tests/history10.sub  | 37 +++++++++++++++++++
 tests/history7.sub   | 87 ++++++++++++++++++++++++--------------------
 tests/shopt.right    |  7 +++-
 tests/shopt.tests    |  1 +
 14 files changed, 228 insertions(+), 72 deletions(-)
 create mode 100644 tests/history10.sub

diff --git a/CHANGES b/CHANGES
index 62cfa472..d6964cf5 100644
--- a/CHANGES
+++ b/CHANGES
@@ -37,6 +37,9 @@ a. The history library no longer skips blank lines while it is reading a
 a. The `install-strip' and `strip' Makefile targets now deal with cross-
    compiling.
 
+b. New `inc_append_history' shopt option to append each complete interactive
+   command to $HISTFILE before executing it.
+
 4. New Features in Readline
 
 ------------------------------------------------------------------------------
diff --git a/bashhist.c b/bashhist.c
index 03971c44..dfeaa3cb 100644
--- a/bashhist.c
+++ b/bashhist.c
@@ -158,6 +158,10 @@ int literal_history;
    exit, even if the history has been stifled. */
 int force_append_history;
 
+/* Non-zero means to append each complete interactive command to the history
+   file after it has been read and parsed, but before it is executed. */
+int incremental_append_history;
+
 /* A nit for picking at history saving.  Flags have the following values:
 
    Value == 0 means save all lines parsed by the shell on the history.
@@ -482,6 +486,24 @@ maybe_append_history (char *filename)
   return (result);
 }
 
+/* Append a complete interactive command to HISTFILE before executing it.
+   This is the automatic equivalent of running `history -a' after each
+   command, but runs early enough to preserve the command if the shell or
+   terminal disappears while the command is executing. */
+void
+maybe_incremental_append_history (void)
+{
+  char *hf;
+
+  if (incremental_append_history == 0 || interactive == 0 ||
+      remember_on_history == 0 || history_lines_this_session == 0)
+    return;
+
+  hf = get_string_value ("HISTFILE");
+  if (hf && *hf)
+    maybe_append_history (hf);
+}
+
 /* If this is an interactive shell, then append the lines executed
    this session to the history file. */
 int
@@ -491,41 +513,42 @@ maybe_save_shell_history (void)
   char *hf;
 
   result = 0;
+  hf = get_string_value ("HISTFILE");
+  if (hf == 0 || *hf == 0)
+    return (result);
+
   if (history_lines_this_session > 0)
     {
-      hf = get_string_value ("HISTFILE");
-
-      if (hf && *hf)
+      /* If the file doesn't exist, then create it. */
+      if (file_exists (hf) == 0)
 	{
-	  /* If the file doesn't exist, then create it. */
-	  if (file_exists (hf) == 0)
-	    {
-	      int file;
-	      file = open (hf, O_CREAT | O_TRUNC | O_WRONLY, 0600);
-	      if (file != -1)
-		close (file);
-	    }
-
-	  /* Now actually append the lines if the history hasn't been
-	     stifled.  If the history has been stifled, rewrite the
-	     history file. */
-	  using_history ();
-	  if (history_lines_this_session <= where_history () || force_append_history)
-	    {
-	      result = append_history (history_lines_this_session, hf);
-	      history_lines_in_file += history_lines_this_session;
-	    }
-	  else
-	    {
-	      result = write_history (hf);
-	      history_lines_in_file = history_lines_written_to_file;
-	      /* history_lines_in_file = where_history () + history_base - 1; */
-	    }
-	  history_lines_this_session = 0;
+	  int file;
+	  file = open (hf, O_CREAT | O_TRUNC | O_WRONLY, 0600);
+	  if (file != -1)
+	    close (file);
+	}
 
-	  sv_histsize ("HISTFILESIZE");
+      /* Now actually append the lines if the history hasn't been
+	 stifled.  If the history has been stifled, rewrite the
+	 history file. */
+      using_history ();
+      if (history_lines_this_session <= where_history () || force_append_history)
+	{
+	  result = append_history (history_lines_this_session, hf);
+	  history_lines_in_file += history_lines_this_session;
+	}
+      else
+	{
+	  result = write_history (hf);
+	  history_lines_in_file = history_lines_written_to_file;
+	  /* history_lines_in_file = where_history () + history_base - 1; */
 	}
+      history_lines_this_session = 0;
     }
+
+  /* Incremental appending may have saved all of this session's history
+     already.  Enforce the file size limit even when nothing remains to save. */
+  sv_histsize ("HISTFILESIZE");
   return (result);
 }
 
diff --git a/bashhist.h b/bashhist.h
index e3ca4fd8..566b76f7 100644
--- a/bashhist.h
+++ b/bashhist.h
@@ -43,6 +43,7 @@ extern int remember_on_history;
 extern int enable_history_list;		/* value for `set -o history' */
 extern int literal_history;		/* controlled by `shopt lithist' */
 extern int force_append_history;
+extern int incremental_append_history;
 extern int history_lines_this_session;
 extern int history_lines_in_file;
 extern int history_expansion;
@@ -76,6 +77,7 @@ extern void load_history (void);
 extern void save_history (void);
 extern char *bash_default_histfile (void);
 extern int maybe_append_history (char *);
+extern void maybe_incremental_append_history (void);
 extern int maybe_save_shell_history (void);
 extern char *pre_process_line (char *, int, int);
 extern void maybe_add_history (char *);
diff --git a/builtins/shopt.def b/builtins/shopt.def
index cf6f6be1..3aa67b2f 100644
--- a/builtins/shopt.def
+++ b/builtins/shopt.def
@@ -226,6 +226,7 @@ static struct {
   { "gnu_errfmt", &gnu_error_format, (shopt_set_func_t *)NULL },
 #if defined (HISTORY)
   { "histappend", &force_append_history, (shopt_set_func_t *)NULL },
+  { "inc_append_history", &incremental_append_history, (shopt_set_func_t *)NULL },
 #endif
 #if defined (READLINE)
   { "histreedit", &history_reediting, (shopt_set_func_t *)NULL },
@@ -393,6 +394,7 @@ reset_shopt_options (void)
 #endif
 
 #if defined (HISTORY)
+  incremental_append_history = 0;
   literal_history = 0;
   force_append_history = 0;
   command_oriented_history = 1;
diff --git a/doc/bash.1 b/doc/bash.1
index 5af3492f..db8a311d 100644
--- a/doc/bash.1
+++ b/doc/bash.1
@@ -12128,6 +12128,15 @@ of the
 .B HISTFILE
 variable when the shell exits, rather than overwriting the file.
 .TP 8
+.B inc_append_history
+If set, \fBbash\fP appends each complete command to the file named by the value
+of the
+.SM
+.B HISTFILE
+variable after the command is read and before it is executed, rather than
+waiting until the shell exits.
+Multi-line commands are appended after \fBbash\fP has read the complete command.
+.TP 8
 .B histreedit
 If set, and
 .B readline
diff --git a/doc/bashref.texi b/doc/bashref.texi
index 138a0f9b..23efd1d0 100644
--- a/doc/bashref.texi
+++ b/doc/bashref.texi
@@ -6470,6 +6470,12 @@ If set, the history list is appended to the file named by the value
 of the @env{HISTFILE}
 variable when the shell exits, rather than overwriting the file.
 
+@item inc_append_history
+If set, Bash appends each complete command to the file named by the value
+of the @env{HISTFILE} variable after the command is read and before it is
+executed, rather than waiting until the shell exits.
+Multi-line commands are appended after Bash has read the complete command.
+
 @item histreedit
 If set, and Readline is being used,
 the user is given the opportunity to re-edit a failed history substitution.
diff --git a/eval.c b/eval.c
index bbf5d88b..f381eaf2 100644
--- a/eval.c
+++ b/eval.c
@@ -74,7 +74,7 @@ reader_loop (void)
 
   while (EOF_Reached == 0)
     {
-      int code;
+      int code, parse_result;
 
       code = setjmp_nosigs (top_level);
 
@@ -144,7 +144,15 @@ reader_loop (void)
       (void) alloca (0);
 #endif
 
-      if (read_command () == 0)
+      parse_result = read_command ();
+
+#if defined (HISTORY)
+      /* The parser has assembled any multi-line input into a complete history
+	 entry, but no part of the command has executed yet. */
+      maybe_incremental_append_history ();
+#endif
+
+      if (parse_result == 0)
 	{
 	  if (interactive_shell == 0 && read_but_dont_execute)
 	    {
diff --git a/tests/complete.right b/tests/complete.right
index 1b7893b9..3584c783 100644
--- a/tests/complete.right
+++ b/tests/complete.right
@@ -205,6 +205,7 @@ globskipdots
 globstar
 gnu_errfmt
 histappend
+inc_append_history
 histreedit
 histverify
 hostcomplete
diff --git a/tests/history.right b/tests/history.right
index 5e1ab36b..7b26c58b 100644
--- a/tests/history.right
+++ b/tests/history.right
@@ -317,6 +317,52 @@ e
     7  echo 9
     8  echo 10
     5  echo 10
+inc_append_history off
+$ 1
+$ 2
+$ 3
+$ exit
+6
+$ 1
+$ 2
+$ 3
+$ 4
+$ 5
+$ exit
+6
+$ 1
+$ 2
+$ exit
+4
+$ e 1
+$ e 2
+$ e 3
+$ exit
+3
+e 1
+e 2
+e 3
+$ x 1
+$ x 2
+$ x 3
+$ x 4
+$ x 5
+$ exit
+3
+x 3
+x 4
+x 5
+$ y 1
+$ y 2
+$ exit
+2
+y 1
+y 2
+$ 1
+$ 2
+$ exit
+0
+inc_append_history on
 $ 1
 $ 2
 $ 3
@@ -394,3 +440,8 @@ EOF
 
 
     5  echo three
+inc_append_history disabled: empty
+shopt -s inc_append_history
+printf '%s\n' simple-marker
+for word in multi; do   printf '%s\n' "$word"; done
+kill -KILL $$
diff --git a/tests/history.tests b/tests/history.tests
index fd3f65aa..33db19e5 100644
--- a/tests/history.tests
+++ b/tests/history.tests
@@ -135,3 +135,4 @@ ${THIS_SH} ./history6.sub
 ${THIS_SH} ./history7.sub
 ${THIS_SH} ./history8.sub
 ${THIS_SH} ./history9.sub
+${THIS_SH} ./history10.sub 2>/dev/null
diff --git a/tests/history10.sub b/tests/history10.sub
new file mode 100644
index 00000000..20c20c0e
--- /dev/null
+++ b/tests/history10.sub
@@ -0,0 +1,37 @@
+# Verify that inc_append_history writes complete commands before execution,
+# so they survive even when an interactive shell cannot save history at exit.
+
+histfile=$TMPDIR/inc-append-history-$$
+trap 'rm -f "$histfile"' 0
+unset HISTTIMEFORMAT
+
+: > "$histfile"
+(
+  HISTFILE=$histfile HISTSIZE=-1 HISTFILESIZE=-1 PS1= PS2= \
+    ${THIS_SH} --noprofile --norc -i <<'EOF'
+printf '%s\n' default-marker
+kill -KILL $$
+EOF
+) >/dev/null 2>&1
+
+if test -s "$histfile"; then
+  echo "inc_append_history: history written while option disabled"
+else
+  echo "inc_append_history disabled: empty"
+fi
+
+: > "$histfile"
+(
+  HISTFILE=$histfile HISTSIZE=-1 HISTFILESIZE=-1 PS1= PS2= \
+    ${THIS_SH} --noprofile --norc -i <<'EOF'
+shopt -s inc_append_history
+printf '%s\n' simple-marker
+for word in multi
+do
+  printf '%s\n' "$word"
+done
+kill -KILL $$
+EOF
+) >/dev/null 2>&1
+
+cat "$histfile"
diff --git a/tests/history7.sub b/tests/history7.sub
index 9c9d27b4..2b38df38 100644
--- a/tests/history7.sub
+++ b/tests/history7.sub
@@ -17,58 +17,67 @@
 : ${THIS_SH:=./bash}
 : ${TMPDIR:=/var/tmp}
 
-export HISTTIMEFORMAT=
-export HISTFILESIZE=3
-export HISTFILE=$TMPDIR/hist-$$
-export PS1='$ '
+# Run the same truncation cases with incremental appending disabled and enabled.
+for history_option in +O -O
+do
+  case $history_option in
+    +O) echo 'inc_append_history off' ;;
+    -O) echo 'inc_append_history on' ;;
+  esac
 
-rm -f $HISTFILE
+  export HISTTIMEFORMAT=
+  export HISTFILESIZE=3
+  export HISTFILE=$TMPDIR/hist-$$
+  export PS1='$ '
 
-# exactly the number of lines
-${THIS_SH} --norc -in <<<$'1\n2\n3' 
-wc -l < $HISTFILE | _cut_leading_spaces
+  rm -f $HISTFILE
 
-rm -f $HISTFILE
+  # exactly the number of lines
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'1\n2\n3'
+  wc -l < $HISTFILE | _cut_leading_spaces
 
-# truncating to fewer lines
-${THIS_SH} --norc -in <<<$'1\n2\n3\n4\n5' 
-wc -l < $HISTFILE | _cut_leading_spaces
+  rm -f $HISTFILE
 
-rm -f $HISTFILE
+  # truncating to fewer lines
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'1\n2\n3\n4\n5'
+  wc -l < $HISTFILE | _cut_leading_spaces
 
-# the history file contains fewer lines than $HISTFILESIZE
-${THIS_SH} --norc -in <<<$'1\n2' 
-wc -l < $HISTFILE | _cut_leading_spaces
+  rm -f $HISTFILE
 
-rm -f $HISTFILE
+  # the history file contains fewer lines than $HISTFILESIZE
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'1\n2'
+  wc -l < $HISTFILE | _cut_leading_spaces
 
-# now we try it without timestamps
-unset HISTTIMEFORMAT
+  rm -f $HISTFILE
 
-# exactly the number of lines
-${THIS_SH} --norc -in <<<$'e 1\ne 2\ne 3' 
-wc -l < $HISTFILE | _cut_leading_spaces
-cat $HISTFILE
+  # now we try it without timestamps
+  unset HISTTIMEFORMAT
 
-rm -f $HISTFILE
+  # exactly the number of lines
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'e 1\ne 2\ne 3'
+  wc -l < $HISTFILE | _cut_leading_spaces
+  cat $HISTFILE
 
-# truncating to fewer lines
-${THIS_SH} --norc -in <<<$'x 1\nx 2\nx 3\nx 4\nx 5' 
-wc -l < $HISTFILE | _cut_leading_spaces
-cat $HISTFILE
+  rm -f $HISTFILE
 
-rm -f $HISTFILE
+  # truncating to fewer lines
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'x 1\nx 2\nx 3\nx 4\nx 5'
+  wc -l < $HISTFILE | _cut_leading_spaces
+  cat $HISTFILE
 
-# the history file contains fewer lines than $HISTFILESIZE
-${THIS_SH} --norc -in <<<$'y 1\ny 2' 
-wc -l < $HISTFILE | _cut_leading_spaces
-cat $HISTFILE
+  rm -f $HISTFILE
 
-rm -f $HISTFILE
+  # the history file contains fewer lines than $HISTFILESIZE
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'y 1\ny 2'
+  wc -l < $HISTFILE | _cut_leading_spaces
+  cat $HISTFILE
 
-# we want to truncate the history file to zero length
-HISTFILESIZE=0
-${THIS_SH} --norc -in <<<$'1\n2' 
-wc -l < $HISTFILE | _cut_leading_spaces
+  rm -f $HISTFILE
 
-rm -f $HISTFILE
+  # we want to truncate the history file to zero length
+  HISTFILESIZE=0
+  ${THIS_SH} --norc "$history_option" inc_append_history -in <<<$'1\n2'
+  wc -l < $HISTFILE | _cut_leading_spaces
+
+  rm -f $HISTFILE
+done
diff --git a/tests/shopt.right b/tests/shopt.right
index 2d47b575..bf1c23d3 100644
--- a/tests/shopt.right
+++ b/tests/shopt.right
@@ -34,6 +34,7 @@ shopt -s globskipdots
 shopt -u globstar
 shopt -u gnu_errfmt
 shopt -u histappend
+shopt -u inc_append_history
 shopt -u histreedit
 shopt -u histverify
 shopt -s hostcomplete
@@ -105,6 +106,7 @@ shopt -u failglob
 shopt -u globstar
 shopt -u gnu_errfmt
 shopt -u histappend
+shopt -u inc_append_history
 shopt -u histreedit
 shopt -u histverify
 shopt -u huponexit
@@ -151,6 +153,7 @@ failglob            	off
 globstar            	off
 gnu_errfmt          	off
 histappend          	off
+inc_append_history  	off
 histreedit          	off
 histverify          	off
 huponexit           	off
@@ -308,7 +311,7 @@ verbose        	off
 vi             	off
 xtrace         	off
 --
-./shopt.tests: line 106: shopt: xyz1: invalid shell option name
-./shopt.tests: line 107: shopt: xyz1: invalid option name
+./shopt.tests: line 107: shopt: xyz1: invalid shell option name
+./shopt.tests: line 108: shopt: xyz1: invalid option name
 expand_aliases      	on
 expand_aliases      	on
diff --git a/tests/shopt.tests b/tests/shopt.tests
index c31870cf..035e55e3 100644
--- a/tests/shopt.tests
+++ b/tests/shopt.tests
@@ -26,6 +26,7 @@ shopt -s expand_aliases
 shopt -u extglob
 shopt -u histreedit
 shopt -u histappend
+shopt -u inc_append_history
 shopt -u histverify
 shopt -s hostcomplete
 shopt -u huponexit
-- 
2.53.0

