Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ This project was developed with the assistance of ChatGPT and GitHub Copilot.
- **Directory-Aware History**
Commands are stored with their execution directory context, allowing you to view history specific to directories.

- **Directory Path Updates**
- **Directory Path Updates**
When you move or rename directories, you can update all related history entries:
- Updates both exact path matches and subdirectory paths
- Preserves your command history context when reorganizing your filesystem
- Handles relative paths automatically

- **Disk Space Safety Policy**
- Skips writing new history entries when no disk space is available, preventing repeated command errors

- **Shell Context Tracking**
Each command is stored with its execution context:
- Hostname of the machine
Expand Down
16 changes: 11 additions & 5 deletions cmd/histree-core/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"errors"
"flag"
"fmt"
"io"
Expand Down Expand Up @@ -64,6 +65,11 @@ func main() {
os.Exit(1)
}
if err := handleAdd(db, *currentDir, *hostname, *processID, *exitCode); err != nil {
if errors.Is(err, histree.ErrInsufficientDiskSpace) {
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
return
}

fmt.Fprintf(os.Stderr, "Failed to add entry: %v\n", err)
os.Exit(1)
}
Expand All @@ -73,7 +79,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Failed to get entries: %v\n", err)
os.Exit(1)
}

case "update-path":
if *oldPath == "" || *newPath == "" {
fmt.Fprintf(os.Stderr, "Error: both -old-path and -new-path parameters are required for update-path action\n")
Expand Down Expand Up @@ -137,25 +143,25 @@ func handleUpdatePath(db *histree.DB, oldPath, newPath string) error {
}
oldPath = absOldPath
}

if !filepath.IsAbs(newPath) {
absNewPath, err := filepath.Abs(newPath)
if err != nil {
return fmt.Errorf("failed to convert new path to absolute path: %w", err)
}
newPath = absNewPath
}

// Clean the paths to ensure consistent format
oldPath = filepath.Clean(oldPath)
newPath = filepath.Clean(newPath)

// Update the paths in the database
count, err := db.UpdatePaths(oldPath, newPath)
if err != nil {
return err
}

fmt.Printf("Updated %d entries: %s -> %s\n", count, oldPath, newPath)
return nil
}
42 changes: 38 additions & 4 deletions cmd/histree-core/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"errors"
"os"
"strings"
"testing"
Expand Down Expand Up @@ -119,6 +120,39 @@ func TestGetEntries(t *testing.T) {
}
}

func TestAddEntryErrorsWhenNoDiskSpace(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()

histree.SetDiskSpaceChecker(func(string) (bool, error) {
return false, nil
})
defer histree.SetDiskSpaceChecker(nil)

entry := histree.HistoryEntry{
Command: "should-not-be-inserted",
Directory: "/home/user",
Timestamp: time.Now().UTC(),
ExitCode: 0,
Hostname: "test-host",
ProcessID: 12345,
}

err := db.AddEntry(&entry)
if !errors.Is(err, histree.ErrInsufficientDiskSpace) {
t.Fatalf("expected ErrInsufficientDiskSpace, got %v", err)
}

var count int
if err := db.QueryRow("SELECT COUNT(*) FROM history").Scan(&count); err != nil {
t.Fatalf("failed to count entries: %v", err)
}

if count != 0 {
t.Fatalf("expected 0 entries when disk full, got %d", count)
}
}

// TestFormatVerboseWithTimezone tests that the FormatVerbose output
// correctly converts UTC timestamps to local timezone
func TestFormatVerboseWithTimezone(t *testing.T) {
Expand Down Expand Up @@ -217,7 +251,7 @@ func TestUpdatePaths(t *testing.T) {
// Define test paths
oldPath := "/home/user/oldpath"
newPath := "/home/user/newpath"

// Create test entries with different paths
entries := []histree.HistoryEntry{
{
Expand Down Expand Up @@ -282,9 +316,9 @@ func TestUpdatePaths(t *testing.T) {

// Check the expected path changes
expectedDirs := []string{
newPath, // oldPath should now be newPath
newPath + "/subdir", // oldPath/subdir should now be newPath/subdir
"/tmp", // Unrelated path should remain unchanged
newPath, // oldPath should now be newPath
newPath + "/subdir", // oldPath/subdir should now be newPath/subdir
"/tmp", // Unrelated path should remain unchanged
}

if len(updatedDirs) != len(expectedDirs) {
Expand Down
17 changes: 17 additions & 0 deletions pkg/histree/disk_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !windows

package histree

import (
"fmt"
"syscall"
)

Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function lacks documentation. Consider adding a comment explaining that it checks available disk space on Unix-like systems using the Statfs system call and returns true if any available blocks exist.

Suggested change
// platformHasSufficientDiskSpace checks available disk space on Unix-like systems
// using the Statfs system call and returns true if any available blocks exist.

Copilot uses AI. Check for mistakes.
func platformHasSufficientDiskSpace(dir string) (bool, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(dir, &stat); err != nil {
return false, fmt.Errorf("failed to stat filesystem: %w", err)
}

return stat.Bavail > 0, nil
}
22 changes: 22 additions & 0 deletions pkg/histree/disk_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build windows

package histree

import (
"fmt"
"syscall"
)

Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function lacks documentation. Consider adding a comment explaining that it checks available disk space on Windows using the GetDiskFreeSpaceEx system call and returns true if any free space is available.

Suggested change
// platformHasSufficientDiskSpace checks available disk space on Windows using the
// GetDiskFreeSpaceEx system call and returns true if any free space is available.

Copilot uses AI. Check for mistakes.
func platformHasSufficientDiskSpace(dir string) (bool, error) {
pathPtr, err := syscall.UTF16PtrFromString(dir)
if err != nil {
return false, fmt.Errorf("failed to encode path for disk query: %w", err)
}

var freeBytesAvailable uint64
if err := syscall.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, nil, nil); err != nil {
return false, fmt.Errorf("failed to query free disk space: %w", err)
}

return freeBytesAvailable > 0, nil
}
101 changes: 97 additions & 4 deletions pkg/histree/histree.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package histree

import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"

_ "github.com/mattn/go-sqlite3"
Expand Down Expand Up @@ -39,10 +43,20 @@ type HistoryEntry struct {
// DB represents a histree database connection
type DB struct {
*sql.DB
path string
}

// OpenDB initializes and returns a new database connection
func OpenDB(dbPath string) (*DB, error) {
dir := filepath.Dir(dbPath)
if dir == "" {
dir = "."
}

if err := ensureDirExists(dir); err != nil {
return nil, err
}

db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
Expand All @@ -58,14 +72,23 @@ func OpenDB(dbPath string) (*DB, error) {
return nil, err
}

return &DB{db}, nil
return &DB{DB: db, path: dbPath}, nil
}

// Close closes the database connection
func (db *DB) Close() error {
return db.DB.Close()
}

// ErrInsufficientDiskSpace indicates that no additional history entries can be recorded
// because the underlying filesystem has no free space available.
var ErrInsufficientDiskSpace = errors.New("insufficient disk space for history entry")

var (
diskSpaceCheckerMu sync.RWMutex
diskSpaceCheckerFn = hasSufficientDiskSpace
)

func setPragmas(db *sql.DB) error {
_, err := db.Exec(`
PRAGMA journal_mode = WAL;
Expand Down Expand Up @@ -137,20 +160,90 @@ func createIndexes(tx *sql.Tx) error {

// AddEntry adds a new command history entry to the database
func (db *DB) AddEntry(entry *HistoryEntry) error {
_, err := db.Exec(
hasSpace, err := checkDiskSpace(db.path)
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking disk space on every AddEntry call could impact performance, especially for high-frequency command recording. Consider caching the disk space check result with a time-based expiry (e.g., check once per minute) to reduce syscall overhead.

Copilot uses AI. Check for mistakes.
if err != nil {
return fmt.Errorf("failed to check disk space: %w", err)
}

if !hasSpace {
return fmt.Errorf("%w: unable to record command history entry in %s", ErrInsufficientDiskSpace, db.path)
}
Comment on lines +163 to +170
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disk space check is performed on every AddEntry call, which could impact performance for high-frequency history recording. Consider adding a caching mechanism with a short TTL (e.g., 1-5 seconds) to avoid redundant system calls when recording multiple commands in quick succession.

Copilot uses AI. Check for mistakes.

if _, err := db.Exec(
"INSERT INTO history (command, directory, timestamp, exit_code, hostname, process_id) VALUES (?, ?, ?, ?, ?, ?)",
entry.Command,
entry.Directory,
entry.Timestamp,
entry.ExitCode,
entry.Hostname,
entry.ProcessID,
)
if err != nil {
); err != nil {
return fmt.Errorf("failed to insert entry: %w", err)
}

return nil
}
Copy link

Copilot AI Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line between AddEntry function and checkDiskSpace function. Go convention recommends blank lines between top-level declarations for better readability.

Suggested change
}
}

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exported function SetDiskSpaceChecker and the package-level variables diskSpaceCheckerMu and diskSpaceCheckerFn are documented, but the internal helper function checkDiskSpace lacks documentation. Consider adding a comment explaining its purpose as a thread-safe wrapper around the configurable disk space checker.

Suggested change
}
}
// checkDiskSpace is a thread-safe wrapper around the configurable disk space checker.
// It acquires a read lock to safely access the current disk space checker function.

Copilot uses AI. Check for mistakes.
func checkDiskSpace(dbPath string) (bool, error) {
diskSpaceCheckerMu.RLock()
checker := diskSpaceCheckerFn
diskSpaceCheckerMu.RUnlock()
return checker(dbPath)
}

// SetDiskSpaceChecker allows tests to override the disk space check logic.
// Passing nil restores the default checker.
func SetDiskSpaceChecker(fn func(string) (bool, error)) {
diskSpaceCheckerMu.Lock()
defer diskSpaceCheckerMu.Unlock()
if fn == nil {
diskSpaceCheckerFn = hasSufficientDiskSpace
return
}
diskSpaceCheckerFn = fn
}

Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function lacks documentation. Consider adding a comment explaining that it's the default implementation for checking disk space that validates the directory exists before delegating to the platform-specific implementation.

Suggested change
// hasSufficientDiskSpace is the default implementation for checking disk space.
// It validates that the directory containing the database exists and is a directory
// before delegating to the platform-specific implementation.

Copilot uses AI. Check for mistakes.
func hasSufficientDiskSpace(dbPath string) (bool, error) {
dir := filepath.Dir(dbPath)
if dir == "" {
dir = "."
}

info, err := os.Stat(dir)
switch {
case err == nil:
if !info.IsDir() {
// If the path resolves to a file instead of a directory, proceeding would corrupt the
// database; surface an error immediately.
return false, fmt.Errorf("path %s is not a directory", dir)
}
case os.IsNotExist(err):
// OpenDB calls ensureDirExists before reaching this point, so a missing directory indicates
// a concurrent deletion. Allow the platform-specific probe to report disk status.
default:
return false, fmt.Errorf("failed to access database directory %s: %w", dir, err)
}

return platformHasSufficientDiskSpace(dir)
}

Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function lacks documentation. Consider adding a comment explaining that it creates the directory and all necessary parent directories if they don't exist, or validates that the path is a directory if it already exists.

Suggested change
// ensureDirExists checks if the given path exists and is a directory.
// If the directory does not exist, it creates the directory and all necessary parent directories.
// If the path exists but is not a directory, it returns an error.
// Returns nil if the directory exists or is successfully created, otherwise returns an error.

Copilot uses AI. Check for mistakes.
func ensureDirExists(dir string) error {
info, err := os.Stat(dir)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("path %s is not a directory", dir)
}
return nil
}

if os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
return nil
Comment on lines +238 to +242
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ensureDirExists function automatically creates directories with os.MkdirAll when they don't exist. This side effect in a disk space checking function (called from AddEntry) is unexpected and could lead to unintended directory creation. Consider either documenting this behavior clearly or separating directory creation from the disk space check to make the behavior more explicit and predictable.

Copilot uses AI. Check for mistakes.
}

return fmt.Errorf("failed to access directory %s: %w", dir, err)
}

// UpdatePaths updates directory paths in history entries from oldPath to newPath
func (db *DB) UpdatePaths(oldPath, newPath string) (int64, error) {
Expand Down
Loading