*Patch*

>From eed27ce56dfccef103d51379215c38a8438ec998 Mon Sep 17 00:00:00 2001
From: Jason Stein <jstein...@gmail.com>
Date: Sun, 13 Jul 2025 11:08:24 -0700
Subject: [PATCH] Add dh (directory history) builtin

- Implement dh builtin for frequency-based directory navigation
- Add comprehensive test suite with run-dh, dh.tests, and dh.right
- Integrate with bash builtin system via Makefile.in updates
- Support options: -c (clear), -i (show counts), -m N (limit), -w (write)
- Support navigation: dh N (go to #N), dh N-M (go to #N then up M levels)
- Support partial matching for directory names
- Persistent history stored in ~/.bash_dirhistory
- Frequency-based sorting with most used directories having lower numbers
---
 builtins/Makefile.in |   4 +-
 builtins/cd.def      |   6 +
 builtins/common.h    |   3 +
 builtins/dh.def      | 807 +++++++++++++++++++++++++++++++++++++++++++
 tests/dh.right       |  20 ++
 tests/dh.tests       |  57 +++
 tests/run-dh         |   4 +
 7 files changed, 899 insertions(+), 2 deletions(-)
 create mode 100644 builtins/dh.def
 create mode 100644 tests/dh.right
 create mode 100644 tests/dh.tests
 create mode 100644 tests/run-dh

diff --git a/builtins/Makefile.in b/builtins/Makefile.in
index 7e0ec3242..53779c249 100644
--- a/builtins/Makefile.in
+++ b/builtins/Makefile.in
@@ -141,7 +141,7 @@ RL_LIBSRC = $(topdir)/lib/readline
 DEFSRC =  $(srcdir)/alias.def $(srcdir)/bind.def $(srcdir)/break.def \
          $(srcdir)/builtin.def $(srcdir)/caller.def \
          $(srcdir)/cd.def $(srcdir)/colon.def \
-         $(srcdir)/command.def $(srcdir)/declare.def $(srcdir)/echo.def \
+         $(srcdir)/command.def $(srcdir)/declare.def $(srcdir)/dh.def
$(srcdir)/echo.def \
          $(srcdir)/enable.def $(srcdir)/eval.def $(srcdir)/getopts.def \
          $(srcdir)/exec.def $(srcdir)/exit.def $(srcdir)/fc.def \
          $(srcdir)/fg_bg.def $(srcdir)/hash.def $(srcdir)/help.def \
@@ -159,7 +159,7 @@ STATIC_SOURCE = common.c evalstring.c evalfile.c
getopt.c bashgetopt.c \

 OFILES = builtins.o \
        alias.o bind.o break.o builtin.o caller.o cd.o colon.o command.o \
-       common.o declare.o echo.o enable.o eval.o evalfile.o \
+       common.o declare.o dh.o echo.o enable.o eval.o evalfile.o \
        evalstring.o exec.o exit.o fc.o fg_bg.o hash.o help.o history.o \
        jobs.o kill.o let.o mapfile.o \
        pushd.o read.o return.o set.o setattr.o shift.o source.o \
diff --git a/builtins/cd.def b/builtins/cd.def
index 5b39cb52b..71e933e5c 100644
--- a/builtins/cd.def
+++ b/builtins/cd.def
@@ -618,6 +618,9 @@ change_to_directory (char *newdir, int nolinks, int xattr)
       else
        set_working_directory (tdir);

+      /* Add to directory history */
+      add_to_dir_history (tdir);
+
       free (tdir);
       return (1);
     }
@@ -652,6 +655,9 @@ change_to_directory (char *newdir, int nolinks, int xattr)
       else
        free (t);

+      /* Add to directory history */
+      add_to_dir_history (tdir);
+
       r = 1;
     }
   else
diff --git a/builtins/common.h b/builtins/common.h
index a169f494c..95a441309 100644
--- a/builtins/common.h
+++ b/builtins/common.h
@@ -242,6 +242,9 @@ extern int builtin_unbind_variable (const char *);
 extern SHELL_VAR *builtin_find_indexed_array (char *, int);
 extern int builtin_arrayref_flags (WORD_DESC *, int);

+/* Functions from dh.def */
+extern void add_to_dir_history (char *);
+
 /* variables from evalfile.c */
 extern int sourcelevel;

diff --git a/builtins/dh.def b/builtins/dh.def
new file mode 100644
index 000000000..ddc19c60e
--- /dev/null
+++ b/builtins/dh.def
@@ -0,0 +1,807 @@
+This file is dh.def, from which is created dh.c.
+It implements the builtin "dh" in Bash.
+
+Copyright (C) 2025 Free Software Foundation, Inc.
+
+This file is part of GNU Bash, the Bourne Again SHell.
+
+Bash is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+Bash is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with Bash.  If not, see <http://www.gnu.org/licenses/>.
+
+$PRODUCES dh.c
+
+#include <config.h>
+
+#if defined (HAVE_UNISTD_H)
+#  include <unistd.h>
+#endif
+
+#include "../bashansi.h"
+#include "../bashintl.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <errno.h>
+#include <string.h>
+#include "../shell.h"
+#include "../general.h"
+#include "../builtins.h"
+#include "common.h"
+#include "bashgetopt.h"
+
+/* Directory history entry structure */
+struct dir_entry {
+  char *directory;
+  int count;
+  struct dir_entry *next;
+};
+
+/* Global directory history list */
+static struct dir_entry *dir_history = NULL;
+
+/* Function declarations */
+void add_to_dir_history (char *dir);
+static struct dir_entry *find_dir_entry (char *dir);
+static void free_dir_history (void);
+static void display_dir_history (int show_counts, int max_entries);
+static char *get_dirhistory_file (void);
+static void load_dir_history (void);
+static void save_dir_history (void);
+static int compare_dir_entries (const void *a, const void *b);
+int change_to_dir_by_number (int dir_number);
+static struct dir_entry **find_matching_dirs (char *partial_name, int
*match_count);
+static void display_matching_dirs (struct dir_entry **matches, int
match_count);
+
+$BUILTIN dh
+$FUNCTION dh_builtin
+$SHORT_DOC dh [-ciw] [-m N] [N|partial_name]
+Directory history navigation.
+
+Display the directories that have been visited using the cd command,
+ordered by frequency of access. Directories are numbered with the most
+frequently accessed directory having index 1 (displayed last in the list).
+
+Options:
+  -c            clear the directory history
+  -i            show visit counts in parentheses
+  -m N          limit display to N most recent directories
+  -w            write directory history to file
+
+Usage:
+  dh                            display directory list
+  dh -i                         display directory list with visit counts
+  dh -m 10                      display only the 10 most recent directories
+  dh -w                         save directory history to file
+  dh N                          change to directory number N from the list
+  dh N-M                        change to directory number N, then go
up M levels
+  dh partial_name               change to directory matching
partial_name, or show matches
+
+Examples:
+  dh 5                          change to the 5th directory in history
+  dh 5-2                        change to the 5th directory, then go
up 2 parent directories
+  dh -m 5 -i                    show only 5 most recent directories
with visit counts
+  dh proj                       change to directory containing 'proj'
in its basename
+  dh bash                       change to directory containing
'bash', or show all matches
+
+Exit Status:
+Returns success unless an invalid option is given.
+$END
+
+/* Add a directory to the history or increment its count */
+void
+add_to_dir_history (char *dir)
+{
+  struct dir_entry *entry;
+  char *canonical_dir;
+
+  if (dir == NULL || *dir == '\0')
+    return;
+
+  /* Load history on first use */
+  static int history_loaded = 0;
+  if (!history_loaded)
+    {
+      load_dir_history ();
+      history_loaded = 1;
+    }
+
+  /* Canonicalize the directory path to avoid duplicates */
+  canonical_dir = sh_realpath (dir, NULL);
+  if (canonical_dir == NULL)
+    {
+      /* If realpath fails, try sh_canonpath with proper flags */
+      canonical_dir = sh_canonpath (dir, PATH_CHECKDOTDOT|PATH_CHECKEXISTS);
+      if (canonical_dir == NULL)
+        canonical_dir = savestring (dir);
+    }
+
+  /* Try to find existing entry */
+  entry = find_dir_entry (canonical_dir);
+  if (entry)
+    {
+      entry->count++;
+      free (canonical_dir);
+      save_dir_history (); /* Save after updating count */
+      return;
+    }
+
+  /* Create new entry */
+  entry = (struct dir_entry *)xmalloc (sizeof (struct dir_entry));
+  entry->directory = canonical_dir; /* Use canonical path */
+  entry->count = 1;
+  entry->next = dir_history;
+  dir_history = entry;
+
+  save_dir_history (); /* Save after adding new directory */
+}
+
+/* Get the directory history file path */
+static char *
+get_dirhistory_file (void)
+{
+  char *env_file, *home, *file;
+
+  /* Check for DIRHISTFILE environment variable first */
+  env_file = get_string_value ("DIRHISTFILE");
+  if (env_file && *env_file)
+    return savestring (env_file);
+
+  /* Default to ~/.bash_dirhistory in home directory */
+  home = get_string_value ("HOME");
+  if (home && *home)
+    {
+      file = (char *)xmalloc (strlen (home) + 20);
+      sprintf (file, "%s/.bash_dirhistory", home);
+      return file;
+    }
+
+  /* Fallback to current directory */
+  return savestring (".bash_dirhistory");
+}
+
+/* Load directory history from file */
+static void
+load_dir_history (void)
+{
+  char *filename, *line;
+  FILE *file;
+  int count;
+  struct dir_entry *entry;
+
+  filename = get_dirhistory_file ();
+  if (!filename)
+    return;
+
+  file = fopen (filename, "r");
+  free (filename);
+
+  if (!file)
+    return; /* File doesn't exist or can't be read */
+
+  /* Read entries in format: count:directory */
+  line = (char *)xmalloc (1024);
+  while (fgets (line, 1024, file))
+    {
+      char *colon, *dir;
+
+      /* Remove trailing newline */
+      if (line[strlen(line) - 1] == '\n')
+        line[strlen(line) - 1] = '\0';
+
+      /* Parse count:directory format */
+      colon = strchr (line, ':');
+      if (!colon)
+        continue;
+
+      *colon = '\0';
+      count = atoi (line);
+      dir = colon + 1;
+
+      if (count > 0 && *dir != '\0')
+        {
+          char *canonical_dir;
+          struct dir_entry *existing_entry;
+
+          /* Canonicalize the directory path to match what
add_to_dir_history does */
+          canonical_dir = sh_realpath (dir, NULL);
+          if (canonical_dir == NULL)
+            {
+              /* If realpath fails, try sh_canonpath with proper flags */
+              canonical_dir = sh_canonpath (dir,
PATH_CHECKDOTDOT|PATH_CHECKEXISTS);
+              if (canonical_dir == NULL)
+                canonical_dir = savestring (dir);
+            }
+
+          /* Check if we already have this canonical path */
+          existing_entry = find_dir_entry (canonical_dir);
+          if (existing_entry)
+            {
+              /* Merge counts - use the higher count */
+              if (count > existing_entry->count)
+                existing_entry->count = count;
+              free (canonical_dir);
+            }
+          else
+            {
+              /* Create new entry with canonical path */
+              entry = (struct dir_entry *)xmalloc (sizeof (struct dir_entry));
+              entry->directory = canonical_dir;
+              entry->count = count;
+              entry->next = dir_history;
+              dir_history = entry;
+            }
+        }
+    }
+
+  free (line);
+  fclose (file);
+}
+
+/* Save directory history to file */
+static void
+save_dir_history (void)
+{
+  char *filename;
+  FILE *file;
+  struct dir_entry *entry;
+
+  filename = get_dirhistory_file ();
+  if (!filename)
+    return;
+
+  file = fopen (filename, "w");
+  if (!file)
+    {
+      builtin_error (_("cannot write to %s: %s"), filename, strerror (errno));
+      free (filename);
+      return;
+    }
+
+  /* Write entries in format: count:directory */
+  for (entry = dir_history; entry; entry = entry->next)
+    {
+      fprintf (file, "%d:%s\n", entry->count, entry->directory);
+    }
+
+  fclose (file);
+  free (filename);
+}
+
+/* Find a directory entry in the history */
+static struct dir_entry *
+find_dir_entry (char *dir)
+{
+  struct dir_entry *entry;
+
+  for (entry = dir_history; entry; entry = entry->next)
+    {
+      if (STREQ (entry->directory, dir))
+        return entry;
+    }
+  return NULL;
+}
+
+/* Free all directory history entries */
+static void
+free_dir_history (void)
+{
+  struct dir_entry *entry, *next;
+
+  for (entry = dir_history; entry; entry = next)
+    {
+      next = entry->next;
+      free (entry->directory);
+      free (entry);
+    }
+  dir_history = NULL;
+
+  /* Also save to file when clearing */
+  save_dir_history ();
+}
+
+/* Comparison function for qsort - sort by count (ascending for
display, but index numbers are reversed) */
+static int
+compare_dir_entries (const void *a, const void *b)
+{
+  struct dir_entry * const *entry_a = (struct dir_entry * const *)a;
+  struct dir_entry * const *entry_b = (struct dir_entry * const *)b;
+
+  return (*entry_a)->count - (*entry_b)->count;
+}
+
+/* Display the directory history sorted by count */
+static void
+display_dir_history (int show_counts, int max_entries)
+{
+  struct dir_entry *entry;
+  struct dir_entry **entries;
+  int count, i, display_count, start_index;
+
+  /* Load history on first use */
+  static int history_loaded = 0;
+  if (!history_loaded)
+    {
+      load_dir_history ();
+      history_loaded = 1;
+    }
+
+  /* Count entries */
+  count = 0;
+  for (entry = dir_history; entry; entry = entry->next)
+    count++;
+
+  if (count == 0)
+    {
+      printf (_("No directories in history.\n"));
+      return;
+    }
+
+  /* Create array for sorting */
+  entries = (struct dir_entry **)xmalloc (count * sizeof (struct dir_entry *));
+
+  i = 0;
+  for (entry = dir_history; entry; entry = entry->next)
+    entries[i++] = entry;
+
+  /* Sort by count (ascending - least frequent first, but index
numbers will be reversed) */
+  qsort (entries, count, sizeof (struct dir_entry *), compare_dir_entries);
+
+  /* Determine how many entries to display and starting index */
+  if (max_entries > 0 && max_entries < count)
+    {
+      display_count = max_entries;
+      start_index = count - max_entries;  /* Show the most recent
(highest count) entries */
+    }
+  else
+    {
+      display_count = count;
+      start_index = 0;
+    }
+
+  /* Display sorted entries with reversed index numbers (most
frequent = index 1, displayed last) */
+  for (i = start_index; i < start_index + display_count; i++)
+    {
+      if (show_counts)
+        printf ("%5d  %s (%d)\n", count - i, entries[i]->directory,
entries[i]->count);
+      else
+        printf ("%5d  %s\n", count - i, entries[i]->directory);
+    }
+
+  free (entries);
+}
+
+/* Change to a directory by its number in the history list,
optionally going up levels */
+int
+change_to_dir_by_number_with_levels (int dir_number, int levels_up)
+{
+  struct dir_entry *entry;
+  struct dir_entry **entries;
+  int count, i;
+  char *target_dir, *modified_dir;
+  char *path_copy, *current_pos;
+
+  /* Load history on first use */
+  static int history_loaded = 0;
+  if (!history_loaded)
+    {
+      load_dir_history ();
+      history_loaded = 1;
+    }
+
+  /* Count entries */
+  count = 0;
+  for (entry = dir_history; entry; entry = entry->next)
+    count++;
+
+  if (count == 0)
+    {
+      builtin_error (_("no directories in history"));
+      return (EXECUTION_FAILURE);
+    }
+
+  if (dir_number < 1 || dir_number > count)
+    {
+      builtin_error (_("directory number %d out of range (1-%d)"),
dir_number, count);
+      return (EXECUTION_FAILURE);
+    }
+
+  /* Create array for sorting (same order as display) */
+  entries = (struct dir_entry **)xmalloc (count * sizeof (struct dir_entry *));
+
+  i = 0;
+  for (entry = dir_history; entry; entry = entry->next)
+    entries[i++] = entry;
+
+  /* Sort by count (ascending - least frequent first, but index
numbers will be reversed) */
+  qsort (entries, count, sizeof (struct dir_entry *), compare_dir_entries);
+
+  /* Get the target directory using reversed indexing (1 = most
frequent, at end of sorted array) */
+  target_dir = entries[count - dir_number]->directory;
+
+  /* If levels_up is 0, use the directory as-is */
+  if (levels_up == 0)
+    {
+      modified_dir = target_dir;
+    }
+  else
+    {
+      /* Create a copy of the path to modify */
+      path_copy = savestring (target_dir);
+      modified_dir = path_copy;
+
+      /* Remove trailing slashes except for root */
+      int len = strlen (path_copy);
+      while (len > 1 && path_copy[len - 1] == '/')
+        {
+          path_copy[len - 1] = '\0';
+          len--;
+        }
+
+      /* Go up the specified number of levels */
+      for (i = 0; i < levels_up && strlen (path_copy) > 1; i++)
+        {
+          current_pos = strrchr (path_copy, '/');
+          if (current_pos && current_pos != path_copy)
+            *current_pos = '\0';
+          else if (current_pos == path_copy)
+            {
+              /* We're at root level, can't go higher */
+              path_copy[1] = '\0';  /* Keep just "/" */
+              break;
+            }
+        }
+
+      modified_dir = path_copy;
+    }
+
+  /* Print the directory we're changing to */
+  printf ("%s\n", modified_dir);
+
+  /* Change to the directory and update bash's internal state */
+  if (chdir (modified_dir) == 0)
+    {
+      char *old_pwd;
+
+      /* Get current PWD for OLDPWD */
+      old_pwd = get_string_value ("PWD");
+      if (old_pwd)
+        bind_variable ("OLDPWD", old_pwd, 0);
+
+      /* Update shell's working directory and environment variables */
+      set_working_directory (modified_dir);
+
+      /* Update PWD environment variable */
+      bind_variable ("PWD", modified_dir, 0);
+
+      free (entries);
+      if (levels_up > 0)
+        free (path_copy);
+      return (EXECUTION_SUCCESS);
+    }
+  else
+    {
+      builtin_error (_("cannot change to directory '%s': %s"),
modified_dir, strerror (errno));
+      free (entries);
+      if (levels_up > 0)
+        free (path_copy);
+      return (EXECUTION_FAILURE);
+    }
+}
+
+/* Parse directory number with optional levels up (N-M format)
+   Returns 1 if valid format, 0 if invalid
+   Sets dir_number and levels_up accordingly */
+static int
+parse_dir_with_levels (char *word, int *dir_number, int *levels_up)
+{
+  char *dash_pos, *endptr;
+  long num1, num2;
+
+  *levels_up = 0;  /* Default to no levels up */
+
+  /* Look for dash */
+  dash_pos = strchr (word, '-');
+  if (dash_pos == NULL)
+    {
+      /* No dash, just a simple number */
+      if (!all_digits (word))
+        return 0;
+
+      *dir_number = atoi (word);
+      return (*dir_number > 0) ? 1 : 0;
+    }
+
+  /* Found dash, parse both numbers */
+  *dash_pos = '\0';  /* Temporarily split the string */
+
+  /* Check first part (directory number) */
+  if (!all_digits (word))
+    {
+      *dash_pos = '-';  /* Restore the string */
+      return 0;
+    }
+
+  num1 = strtol (word, &endptr, 10);
+  if (*endptr != '\0' || num1 <= 0)
+    {
+      *dash_pos = '-';  /* Restore the string */
+      return 0;
+    }
+
+  /* Check second part (levels up) */
+  if (!all_digits (dash_pos + 1))
+    {
+      *dash_pos = '-';  /* Restore the string */
+      return 0;
+    }
+
+  num2 = strtol (dash_pos + 1, &endptr, 10);
+  if (*endptr != '\0' || num2 < 0)
+    {
+      *dash_pos = '-';  /* Restore the string */
+      return 0;
+    }
+
+  *dash_pos = '-';  /* Restore the string */
+
+  *dir_number = (int)num1;
+  *levels_up = (int)num2;
+
+  return 1;
+}
+
+/* Change to a directory by its number in the history list */
+int
+change_to_dir_by_number (int dir_number)
+{
+  return change_to_dir_by_number_with_levels (dir_number, 0);
+}
+
+/* The dh builtin */
+int
+dh_builtin (WORD_LIST *list)
+{
+  int opt, clear_history, show_counts, write_history, max_entries;
+  char *word;
+  int dir_number, levels_up;
+
+  clear_history = 0;
+  show_counts = 0;
+  write_history = 0;
+  max_entries = 0;  /* 0 means show all entries */
+
+  /* Check for numeric argument first (before parsing options) */
+  if (list && list->word && list->word->word)
+    {
+      word = list->word->word;
+      /* Check if it's a directory number with optional levels (N or
N-M format) */
+      if (parse_dir_with_levels (word, &dir_number, &levels_up))
+        {
+          return (change_to_dir_by_number_with_levels (dir_number, levels_up));
+        }
+    }
+
+  reset_internal_getopt ();
+  while ((opt = internal_getopt (list, "cim:w")) != -1)
+    {
+      switch (opt)
+        {
+        case 'c':
+          clear_history = 1;
+          break;
+        case 'i':
+          show_counts = 1;
+          break;
+        case 'm':
+          max_entries = atoi (list_optarg);
+          if (max_entries <= 0)
+            {
+              builtin_error (_("invalid max entries: %s"), list_optarg);
+              return (EX_USAGE);
+            }
+          break;
+        case 'w':
+          write_history = 1;
+          break;
+        CASE_HELPOPT;
+        default:
+          builtin_usage ();
+          return (EX_USAGE);
+        }
+    }
+  list = loptend;
+
+  /* Check for numeric argument or partial directory name after options */
+  if (list && list->word && list->word->word)
+    {
+      word = list->word->word;
+      if (parse_dir_with_levels (word, &dir_number, &levels_up))
+        {
+          return (change_to_dir_by_number_with_levels (dir_number, levels_up));
+        }
+      else
+        {
+          /* Try partial directory matching */
+          struct dir_entry **matches;
+          int match_count;
+
+          matches = find_matching_dirs (word, &match_count);
+
+          if (match_count == 0)
+            {
+              builtin_error (_("no directories match '%s'"), word);
+              return (EXECUTION_FAILURE);
+            }
+          else if (match_count == 1)
+            {
+              /* Single match - change to that directory */
+              printf ("%s\n", matches[0]->directory);
+              if (chdir (matches[0]->directory) == 0)
+                {
+                  char *old_pwd;
+
+                  /* Get current PWD for OLDPWD */
+                  old_pwd = get_string_value ("PWD");
+                  if (old_pwd)
+                    bind_variable ("OLDPWD", old_pwd, 0);
+
+                  /* Update shell's working directory and environment
variables */
+                  set_working_directory (matches[0]->directory);
+
+                  /* Update PWD environment variable */
+                  bind_variable ("PWD", matches[0]->directory, 0);
+
+                  free (matches);
+                  return (EXECUTION_SUCCESS);
+                }
+              else
+                {
+                  builtin_error (_("cannot change to directory '%s': %s"),
+                                matches[0]->directory, strerror (errno));
+                  free (matches);
+                  return (EXECUTION_FAILURE);
+                }
+            }
+          else
+            {
+              /* Multiple matches - display them */
+              display_matching_dirs (matches, match_count);
+              free (matches);
+              return (EXECUTION_SUCCESS);
+            }
+        }
+    }
+
+  if (clear_history)
+    {
+      free_dir_history ();
+      return (EXECUTION_SUCCESS);
+    }
+
+  if (write_history)
+    {
+      save_dir_history ();
+      return (EXECUTION_SUCCESS);
+    }
+
+  display_dir_history (show_counts, max_entries);
+  return (EXECUTION_SUCCESS);
+}
+
+/* Search for directories matching a partial name */
+static struct dir_entry **
+find_matching_dirs (char *partial_name, int *match_count)
+{
+  struct dir_entry *entry;
+  struct dir_entry **matches;
+  char *basename_dir, *basename_partial;
+  int allocated_matches = 10;
+  int count = 0;
+
+  if (!partial_name || !*partial_name)
+    {
+      *match_count = 0;
+      return NULL;
+    }
+
+  /* Load history if not already loaded */
+  static int history_loaded = 0;
+  if (!history_loaded)
+    {
+      load_dir_history ();
+      history_loaded = 1;
+    }
+
+  matches = (struct dir_entry **)xmalloc (allocated_matches * sizeof
(struct dir_entry *));
+  basename_partial = strrchr (partial_name, '/');
+  if (basename_partial)
+    basename_partial++;
+  else
+    basename_partial = partial_name;
+
+  /* Search through directory history */
+  for (entry = dir_history; entry; entry = entry->next)
+    {
+      /* Get the basename of the directory */
+      basename_dir = strrchr (entry->directory, '/');
+      if (basename_dir)
+        basename_dir++;
+      else
+        basename_dir = entry->directory;
+
+      /* Check if the basename contains the partial name OR if the
full path contains it */
+      if (strstr (basename_dir, basename_partial) != NULL ||
+          strstr (entry->directory, basename_partial) != NULL)
+        {
+          /* Expand array if needed */
+          if (count >= allocated_matches)
+            {
+              allocated_matches *= 2;
+              matches = (struct dir_entry **)xrealloc (matches,
+                          allocated_matches * sizeof (struct dir_entry *));
+            }
+          matches[count++] = entry;
+        }
+    }
+
+  *match_count = count;
+  if (count == 0)
+    {
+      free (matches);
+      return NULL;
+    }
+
+  return matches;
+}
+
+/* Display matching directories with their numbers */
+static void
+display_matching_dirs (struct dir_entry **matches, int match_count)
+{
+  struct dir_entry *entry;
+  struct dir_entry **sorted_entries;
+  int i, count, index;
+
+  if (match_count == 0)
+    return;
+
+  /* Sort all directories to assign consistent numbers */
+  count = 0;
+  for (entry = dir_history; entry; entry = entry->next)
+    count++;
+
+  sorted_entries = (struct dir_entry **)xmalloc (count * sizeof
(struct dir_entry *));
+  i = 0;
+  for (entry = dir_history; entry; entry = entry->next)
+    sorted_entries[i++] = entry;
+
+  qsort (sorted_entries, count, sizeof (struct dir_entry *),
compare_dir_entries);
+
+  printf ("Multiple matches found:\n");
+
+  /* Display matches with their numbers */
+  for (i = 0; i < match_count; i++)
+    {
+      /* Find the index of this match in the sorted list */
+      for (index = 0; index < count; index++)
+        {
+          if (sorted_entries[index] == matches[i])
+            {
+              printf ("%5d  %s\n", count - index, matches[i]->directory);
+              break;
+            }
+        }
+    }
+
+  free (sorted_entries);
+}
diff --git a/tests/dh.right b/tests/dh.right
new file mode 100644
index 000000000..5ecd42105
--- /dev/null
+++ b/tests/dh.right
@@ -0,0 +1,20 @@
+Testing dh builtin basic functionality
+Empty history test:
+PASS: Empty history message
+History display test:
+PASS: History contains dir1
+PASS: History contains dir2
+Visit counts test:
+PASS: Visit counts shown
+Limited display test:
+PASS: Limited display works
+Clear history test:
+PASS: History cleared
+Error handling test:
+PASS: Invalid max entries error
+PASS: Out of range error
+Help output test:
+PASS: Help text available
+Partial matching test:
+PASS: Multiple matches detected
+dh builtin tests completed
diff --git a/tests/dh.tests b/tests/dh.tests
new file mode 100644
index 000000000..572c56a40
--- /dev/null
+++ b/tests/dh.tests
@@ -0,0 +1,57 @@
+# tests for the dh builtin (directory history)
+
+# Test basic functionality
+echo "Testing dh builtin basic functionality"
+
+# Start with clean history
+dh -c
+
+# Test 1: Empty history
+echo "Empty history test:"
+dh | grep -q "No directories in history" && echo "PASS: Empty history
message" || echo "FAIL: Empty history message"
+
+# Test 2: Build and display history
+mkdir -p /tmp/dhtest$$/dir1 /tmp/dhtest$$/dir2 2>/dev/null
+cd /tmp/dhtest$$/dir1
+cd /tmp/dhtest$$/dir2
+cd /tmp/dhtest$$/dir1  # increase frequency
+
+echo "History display test:"
+dh | grep -q "dir1" && echo "PASS: History contains dir1" || echo
"FAIL: History missing dir1"
+dh | grep -q "dir2" && echo "PASS: History contains dir2" || echo
"FAIL: History missing dir2"
+
+# Test 3: Visit counts
+echo "Visit counts test:"
+dh -i | grep -q "(2)" && echo "PASS: Visit counts shown" || echo
"FAIL: Visit counts not shown"
+
+# Test 4: Limited display
+echo "Limited display test:"
+count=$(dh -m 1 | grep -v "directories in history" | wc -l)
+test "$count" -eq 1 && echo "PASS: Limited display works" || echo
"FAIL: Limited display failed"
+
+# Test 5: Clear history
+echo "Clear history test:"
+dh -c
+dh | grep -q "No directories in history" && echo "PASS: History
cleared" || echo "FAIL: History not cleared"
+
+# Test 6: Error handling
+echo "Error handling test:"
+dh -m 0 2>&1 | grep -q "invalid max entries" && echo "PASS: Invalid
max entries error" || echo "FAIL: Missing error for invalid max"
+# Build some history for the out of range test
+cd /tmp/dhtest$$/dir1
+cd /tmp/dhtest$$/dir2
+dh 999 2>&1 | grep -q "out of range" && echo "PASS: Out of range
error" || echo "FAIL: Missing out of range error"
+
+# Test 7: Help output
+echo "Help output test:"
+dh --help 2>&1 | grep -q "Directory history navigation" && echo
"PASS: Help text available" || echo "FAIL: Help text missing"
+
+# Test 8: Partial matching
+cd /tmp/dhtest$$/dir1
+cd /tmp/dhtest$$/dir2/subdir 2>/dev/null || mkdir -p
/tmp/dhtest$$/dir2/subdir && cd /tmp/dhtest$$/dir2/subdir
+echo "Partial matching test:"
+dh dir 2>&1 | grep -q "Multiple matches" && echo "PASS: Multiple
matches detected" || echo "FAIL: Multiple matches not detected"
+
+# Cleanup
+rm -rf /tmp/dhtest$$ 2>/dev/null
+echo "dh builtin tests completed"
diff --git a/tests/run-dh b/tests/run-dh
new file mode 100644
index 000000000..78b8dea98
--- /dev/null
+++ b/tests/run-dh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+${THIS_SH} ./dh.tests > ${BASH_TSTOUT} 2>&1
+diff ${BASH_TSTOUT} dh.right && rm -f ${BASH_TSTOUT}


On Sun, Jul 13, 2025 at 1:19 PM jason stein <jstein...@gmail.com> wrote:

> *Per Lawrence **Velázquez*
> Sending a new email to bug-bash@gnu.org.  Original was sent to
> help-bash@gnu-org which Lawrence indicated was the incorrect mailing list
> (Thanks Lawrence).
>
> Hey All,
> I developed a new builtin and I was wondering what is the process for
> getting it added to the official release (e.g. reviews, votes, etc).
>
> *Pull Request:*
> https://github.com/jstein916/bash/pull/1
>
> *What the command dh does:*
> it adds a new command called "dh"  which stands for directory history.
> The new command is similar to history but deals exclusively with the
> directories that have been cd to.  It creates a .bash_dirhistory file
> similar to history.
>
> *Help from bash:*
> dh: dh [-ciw] [-m N] [N|partial_name]
>     Directory history navigation.
>
>     Display the directories that have been visited using the cd command,
>     ordered by frequency of access. Directories are numbered with the most
>     frequently accessed directory having index 1 (displayed last in the
> list).
>
>     Options:
>       -c            clear the directory history
>       -i            show visit counts in parentheses
>       -m N          limit display to N most recent directories
>       -w            write directory history to file
>
>     Usage:
>       dh                            display directory list
>       dh -i                         display directory list with visit
> counts
>       dh -m 10                      display only the 10 most recent
> directories
>       dh -w                         save directory history to file
>       dh N                          change to directory number N from the
> list
>       dh N-M                        change to directory number N, then go
> up M levels
>       dh partial_name               change to directory matching
> partial_name, or show matches
>
>     Examples:
>       dh 5                          change to the 5th directory in history
>       dh 5-2                        change to the 5th directory, then go
> up 2 parent directories
>       dh -m 5 -i                    show only 5 most recent directories
> with visit counts
>       dh proj                       change to directory containing 'proj'
> in its basename
>       dh bash                       change to directory containing 'bash',
> or show all matches
>
>     Exit Status:
>     Returns success unless an invalid option is given.
>
>
> *Examples:*
> Jason@PC MSYS /c/Projects/bash
> $ cd /tmp/test1
>
> Jason@PC MSYS /tmp/test1
> $ cd /tmp/test2/
>
> Jason@PC MSYS /tmp/test2
> $ cd /tmp/test3
>
> Jason@PC MSYS /tmp/test3
> $ dh
> 3 /tmp/test3
> 2 /tmp/test2
> 1 /tmp/test1
>
> Jason@PC MSYS /tmp/test3
> $ dh 31
> bash: dh: directory number 31 out of range (1-3)
>
> Jason@PC MSYS /tmp/test3
> $ dh 1
> /tmp/test1
>
> Jason@PC MSYS /tmp/test1
> $ dh test3
> /tmp/test3
>
> Jason@PC MSYS /tmp/test3
> $ dh
> 3 /tmp/test3
> 2 /tmp/test2
> 1 /tmp/test1
>
> Jason@PC MSYS /tmp/test3
> $ dh 2-1
> /tmp
>
> Jason@PC MSYS /tmp
>
>
> $ dh tmp
> Multiple matches found:
>     1  /tmp/test3
>     3  /tmp/test2
>     2  /tmp/test1
>
>
> Thanks
>
> Jason
>

Reply via email to