Make history behave like common shells

ober

f2a2a8e139f18efe34c4d84ccee381e086603c89

diff --git a/README.md b/README.md
index 075910d..a3f83cf 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,25 @@ make compat-smoke
 The Oils spec runner under `test/` measures shell compatibility. Benchmark
 scripts live here so speed work stays close to the shell implementation.
 
+## History
+
+Interactive `jsh` writes normal line-oriented shell history to `$HISTFILE`
+(default: `~/.jsh_history`). Set `HISTSIZE` and `HISTFILESIZE` to control the
+in-memory and saved entry limits.
+
+History recording can be disabled from startup config with the bash-style
+history option:
+
+```sh
+# ~/.jshrc
+case "$PWD/" in
+  "$HOME/mine/obersh/"*) set +o history ;;
+esac
+```
+
+For memory-only session history without saving a file, unset `HISTFILE`, set
+`HISTFILE=`, or set `HISTFILE=/dev/null`.
+
 ## Layout
 
 ```text
diff --git a/builtins.ss b/builtins.ss
index 353b672..3434729 100644
--- a/builtins.ss
+++ b/builtins.ss
@@ -691,7 +691,9 @@
                 (let ((exec-fn (*execute-input*)))
                   (when exec-fn
                     (exec-fn exit-trap env)))))
-            (history-save!)
+            (when (and (env-option? env "history")
+                       (not (env-option? env "nolog")))
+              (history-save!))
             (exit code))))))
 
 ;; return [n]
diff --git a/environment.ss b/environment.ss
index 5b69539..2035432 100644
--- a/environment.ss
+++ b/environment.ss
@@ -589,7 +589,8 @@
   ;; Return known options with their status
   (let ((opts (shell-environment-options env))
         (known '("allexport" "braceexpand" "emacs" "errexit" "errtrace"
-                  "functrace" "hashall" "histexpand" "interactive-comments"
+                  "functrace" "hashall" "histexpand" "history"
+                  "interactive-comments"
                   "keyword" "monitor" "noclobber" "noexec" "nofollow" "noglob"
                   "nolog" "notify" "nounset" "onecmd" "physical"
                   "pipefail" "posix" "privileged" "verbose" "vi" "xtrace")))
@@ -716,6 +717,7 @@
   ;; Default shell options
   (env-option-set! env "hashall" #t)
   (env-option-set! env "braceexpand" #t)
+  (env-option-set! env "history" #t)
   (env-option-set! env "interactive-comments" #t)
   ;; Default shopt options
   (env-shopt-set! env "cmdhist" #t)
diff --git a/history.ss b/history.ss
index 87bd6f7..c4cea4d 100644
--- a/history.ss
+++ b/history.ss
@@ -1,8 +1,8 @@
 ;;; history.ss - Command history for jsh
 ;;;
 ;;; Entries stored as 3-element vectors: #(timestamp cwd command)
-;;; File format: EPOCH\tCWD\tCOMMAND  (tab-separated, one per line)
-;;; Backward-compatible: plain text lines loaded as #(0 "" command)
+;;; File format: COMMAND (one per line), matching normal bash/zsh history.
+;;; Backward-compatible: old EPOCH\tCWD\tCOMMAND lines are still loaded.
 
 (export #t)
 (import :std/sugar
@@ -53,17 +53,30 @@
 
 ;;; --- Public interface ---
 
+(def (history-persistent-file? file)
+  (and (string? file)
+       (> (string-length file) 0)
+       (not (string=? file "/dev/null"))))
+
 (def (history-init! histfile histsize (filesize #f))
-  (set! *history*
-    (make-history-state
-      (make-vector (or histsize 1000) #f)
-      0
-      (or histsize 1000)
-      (expand-tilde (or histfile "~/.jsh_history"))
-      (or filesize (* 2 (or histsize 1000)))
-      (list)    ;; control
-      (list)))  ;; ignore patterns
-  (history-load!))
+  (let* ((size (max 0 (or histsize 1000)))
+         (raw-file (or histfile "~/.jsh_history"))
+         (file (and (history-persistent-file? raw-file)
+                    (expand-tilde raw-file)))
+         (file-size (max 1 (or filesize (* 2 (max 1 size))))))
+    (if (= size 0)
+      (set! *history* #f)
+      (begin
+        (set! *history*
+          (make-history-state
+            (make-vector size #f)
+            0
+            size
+            file
+            file-size
+            (list)    ;; control
+            (list)))  ;; ignore patterns
+        (history-load!)))))
 
 ;; Add a line to history (with timestamp and CWD)
 (def (history-add! line)
@@ -569,8 +582,9 @@
       line)))
 
 ;;; --- File I/O ---
-;;; Format: EPOCH\tCWD\tCOMMAND  (tab-separated)
-;;; Backward-compatible: plain text lines (no tabs) loaded as timestamp=0, cwd=""
+;;; Format: COMMAND (one per line)
+;;; Backward-compatible: old EPOCH\tCWD\tCOMMAND lines are loaded as metadata
+;;; entries, but new saves write only commands so the file is shell-compatible.
 
 (def (history-save!)
   ;; If an override is registered (e.g. encrypted save after ,unlock), call it.
@@ -583,21 +597,26 @@
           (with-catch
            (lambda (e) #!void)  ;; silently fail
            (lambda ()
-             (call-with-output-file file
-               (lambda (port)
-                 (for-each
-                   (lambda (entry)
-                     (fprintf port "~a\t~a\t~a~n"
-                              (history-entry-timestamp entry)
-                              (history-entry-cwd entry)
-                              (history-entry-command entry)))
-                   entries)))
-             ;; History contains every command typed — paths, hostnames,
-             ;; the occasional secret pasted in by accident. Other users
-             ;; on the box have no business reading it. We chmod even
-             ;; though umask is usually 0022, because users routinely
-             ;; unset umask and we don't want to inherit their choice.
-             (ffi-chmod file #o600))))))))
+             (when file
+               (let ((port #f))
+                 (dynamic-wind
+                  (lambda ()
+                    (set! port (open-output-file file 'truncate)))
+                  (lambda ()
+                   (for-each
+                    (lambda (entry)
+                      (display (history-entry-command entry) port)
+                      (newline port))
+                    entries))
+                  (lambda ()
+                    (when port
+                      (close-output-port port)))))
+               ;; History contains every command typed — paths, hostnames,
+               ;; the occasional secret pasted in by accident. Other users
+               ;; on the box have no business reading it. We chmod even
+               ;; though umask is usually 0022, because users routinely
+               ;; unset umask and we don't want to inherit their choice.
+               (ffi-chmod file #o600)))))))))
 
 ;; L-3: bound per-line length. read-line on a multi-GB single-line
 ;; file would attempt to allocate the whole thing. 64KiB is far past
@@ -635,7 +654,7 @@
       (with-catch
        (lambda (e) #!void)
        (lambda ()
-         (when (file-exists? file)
+         (when (and file (file-exists? file))
            (call-with-input-file file
              (lambda (port)
                (let loop ((seen 0))
diff --git a/main.ss b/main.ss
index 99df1a4..5654859 100644
--- a/main.ss
+++ b/main.ss
@@ -49,6 +49,29 @@
       (with-catch (lambda (e) #!void)
                   (lambda () (hook tag detail env))))))
 
+(def (history-option-enabled? env)
+  (and (env-option? env "history")
+       (not (env-option? env "nolog"))))
+
+(def (history-control-symbols controls)
+  (if (and controls (> (string-length controls) 0))
+    (let loop ([parts (string-split-chars controls ":")]
+               [result (list)])
+      (cond
+        ((null? parts) (reverse result))
+        ((string=? (car parts) "ignoreboth")
+         (loop (cdr parts) (cons 'ignoredups (cons 'ignorespace result))))
+        ((string=? (car parts) "ignoredups")
+         (loop (cdr parts) (cons 'ignoredups result)))
+        ((string=? (car parts) "ignorespace")
+         (loop (cdr parts) (cons 'ignorespace result)))
+        (else (loop (cdr parts) result))))
+    (list)))
+
+(def (history-apply-env! env)
+  (history-set-control! (history-control-symbols (env-get env "HISTCONTROL")))
+  (*jsh-history-enabled* (and *history* (history-option-enabled? env))))
+
 ;;; --- CLI argument parsing ---
 
 (def (parse-args args)
@@ -214,11 +237,12 @@
 
 (def (repl env)
   ;; Initialize history
-  (let ([histfile (or (env-get env "HISTFILE") "~/.jsh_history")]
-        [histsize (or (string->number (or (env-get env "HISTSIZE") "1000")) 1000)])
+  (let ([histfile (or (env-get env "HISTFILE") "")]
+        [histsize (or (string->number (or (env-get env "HISTSIZE") "1000")) 1000)]
+        [histfilesize (or (string->number (or (env-get env "HISTFILESIZE") "2000")) 2000)])
     (*jsh-histfile* histfile)
-    (history-init! histfile histsize)
-    (*jsh-history-enabled* #t))
+    (history-init! histfile histsize histfilesize)
+    (history-apply-env! env))
   ;; Set up signal handlers
   (setup-default-signal-handlers!)
   (release-user-fds!)
@@ -238,6 +262,7 @@
   (let ([edit-mode (if (env-option? env "vi") 'vi 'emacs)])
     ;; Main REPL loop
     (let loop ([cmd-num 1])
+      (history-apply-env! env)
       (let* (;; Build prompt
              [ps1 (or (env-get env "PS1") "$ ")]
              [prompt-str (expand-prompt ps1
@@ -547,9 +572,10 @@
                (run-logout! env))
              ;; Save history.
              (let ([save-fn (*jsh-history-save-fn*)])
-               (cond
-                 (save-fn (save-fn))
-                 (else (history-save!))))
+               (when (history-option-enabled? env)
+                 (cond
+                   (save-fn (save-fn))
+                   (else (history-save!)))))
              (exit 0))
            ;; Non-interactive: read from stdin
             (let ([status (execute-stdin env)])
diff --git a/test/test-jsh.ss b/test/test-jsh.ss
index cdb00ce..7704afd 100644
--- a/test/test-jsh.ss
+++ b/test/test-jsh.ss
@@ -10,7 +10,7 @@
         make-shell-environment shell-environment?
         env-set! env-get shell-environment-last-status env-set-last-status!)
   (only (jsh history)
-        *history* history-init! history-add-raw!
+        *history* history-init! history-add! history-add-raw! history-save!
         history-count history-list history-search history-unique-commands)
   (only (jsh lexer) tokenize)
   (only (jsh parser) parse-complete-command)
@@ -20,6 +20,21 @@
 (define pass-count 0)
 (define fail-count 0)
 
+(define test-history-file "/tmp/jsh-history-test")
+
+(define (delete-test-history-file!)
+  (when (file-exists? test-history-file)
+    (delete-file test-history-file)))
+
+(define (read-test-history-lines)
+  (call-with-input-file test-history-file
+    (lambda (port)
+      (let loop ([lines '()])
+        (let ([line (get-line port)])
+          (if (eof-object? line)
+              (reverse lines)
+              (loop (cons line lines))))))))
+
 (define-syntax check
   (syntax-rules (=>)
     [(_ expr => expected)
@@ -81,6 +96,28 @@
 (check (history-search "echo") => '("echo one" "echo two"))
 (check-true (list? (history-unique-commands)))
 
+(delete-test-history-file!)
+(history-init! test-history-file 100 100)
+(history-add! "echo saved")
+(history-add! "pwd")
+(history-save!)
+(check (read-test-history-lines) => '("echo saved" "pwd"))
+
+(delete-test-history-file!)
+(call-with-output-file test-history-file
+  (lambda (port)
+    (display "123\t/tmp\techo old\n" port)))
+(history-init! test-history-file 100 100)
+(check (history-list) => '("echo old"))
+
+(delete-test-history-file!)
+(history-init! "" 100 100)
+(history-add! "echo memory only")
+(history-save!)
+(check (history-list) => '("echo memory only"))
+(check (file-exists? test-history-file) => #f)
+(delete-test-history-file!)
+
 (printf "--- registry ---~n")
 (builtin-register! "unit-test" (lambda (args env) 0))
 (check-true (builtin? "unit-test"))