*Patch*
>From eed27ce56dfccef103d51379215c38a8438ec998 Mon Sep 17 00:00:00 2001
From: Jason Stein <[email protected]>
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 <[email protected]> wrote:
> *Per Lawrence **Velázquez*
> Sending a new email to [email protected]. 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
>