From c06dc3c3fca8a71699ef9ca8e44aaa6b5817c6ae Mon Sep 17 00:00:00 2001 From: fuba Date: Fri, 7 Nov 2025 03:34:19 +0900 Subject: [PATCH 1/7] Remove retention limit support --- README.md | 8 +++- cmd/histree-core/main.go | 10 ++--- cmd/histree-core/main_test.go | 40 ++++++++++++++++++-- pkg/histree/histree.go | 70 +++++++++++++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 60ee291..1074dbc 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,17 @@ 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 +- **Retention Controls & Safety Policies** + - Configure how many history records to keep with the `-max-entries` flag + - Automatically prunes the oldest records when the limit is exceeded + - 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 @@ -69,6 +74,7 @@ import "github.com/fuba/histree-core/pkg/histree" -dir string Current directory for filtering entries -format string Output format: json, simple, or verbose (default "simple") -limit int Number of entries to retrieve (default 100) +-max-entries int Maximum number of history entries to keep (0 for unlimited) -hostname Hostname for command history (required for add action) -pid Process ID of the shell (required for add action) -exit int Exit code of the command diff --git a/cmd/histree-core/main.go b/cmd/histree-core/main.go index 3328451..05e343e 100644 --- a/cmd/histree-core/main.go +++ b/cmd/histree-core/main.go @@ -73,7 +73,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") @@ -137,7 +137,7 @@ func handleUpdatePath(db *histree.DB, oldPath, newPath string) error { } oldPath = absOldPath } - + if !filepath.IsAbs(newPath) { absNewPath, err := filepath.Abs(newPath) if err != nil { @@ -145,17 +145,17 @@ func handleUpdatePath(db *histree.DB, oldPath, newPath string) error { } 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 } diff --git a/cmd/histree-core/main_test.go b/cmd/histree-core/main_test.go index 3069711..73df442 100644 --- a/cmd/histree-core/main_test.go +++ b/cmd/histree-core/main_test.go @@ -119,6 +119,38 @@ func TestGetEntries(t *testing.T) { } } +func TestAddEntrySkipsWhenNoDiskSpace(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, + } + + if err := db.AddEntry(&entry); err != nil { + t.Fatalf("expected no error when disk full, 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) { @@ -217,7 +249,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{ { @@ -282,9 +314,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) { diff --git a/pkg/histree/histree.go b/pkg/histree/histree.go index 6ab05c9..9a95f76 100644 --- a/pkg/histree/histree.go +++ b/pkg/histree/histree.go @@ -4,6 +4,9 @@ package histree import ( "database/sql" "fmt" + "os" + "path/filepath" + "syscall" "time" _ "github.com/mattn/go-sqlite3" @@ -39,6 +42,7 @@ type HistoryEntry struct { // DB represents a histree database connection type DB struct { *sql.DB + path string } // OpenDB initializes and returns a new database connection @@ -58,7 +62,7 @@ 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 @@ -137,7 +141,16 @@ 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) + if err != nil { + return fmt.Errorf("failed to check disk space: %w", err) + } + + if !hasSpace { + return nil + } + + if _, err := db.Exec( "INSERT INTO history (command, directory, timestamp, exit_code, hostname, process_id) VALUES (?, ?, ?, ?, ?, ?)", entry.Command, entry.Directory, @@ -145,13 +158,62 @@ func (db *DB) AddEntry(entry *HistoryEntry) error { entry.ExitCode, entry.Hostname, entry.ProcessID, - ) - if err != nil { + ); err != nil { return fmt.Errorf("failed to insert entry: %w", err) } + return nil } +var checkDiskSpace = hasSufficientDiskSpace + +// SetDiskSpaceChecker allows tests to override the disk space check logic. +// Passing nil restores the default checker. +func SetDiskSpaceChecker(fn func(string) (bool, error)) { + if fn == nil { + checkDiskSpace = hasSufficientDiskSpace + return + } + checkDiskSpace = fn +} + +func hasSufficientDiskSpace(dbPath string) (bool, error) { + dir := filepath.Dir(dbPath) + if dir == "" { + dir = "." + } + + if err := ensureDirExists(dir); err != nil { + return false, err + } + + 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 +} + +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 + } + + 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) { tx, err := db.Begin() From 15a7fc93a852e21c8b331f0fa8d6a08e528d6c9d Mon Sep 17 00:00:00 2001 From: fuba Date: Fri, 7 Nov 2025 04:04:09 +0900 Subject: [PATCH 2/7] Update README for disk space safeguard --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 1074dbc..e5f50c8 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,7 @@ This project was developed with the assistance of ChatGPT and GitHub Copilot. - Preserves your command history context when reorganizing your filesystem - Handles relative paths automatically -- **Retention Controls & Safety Policies** - - Configure how many history records to keep with the `-max-entries` flag - - Automatically prunes the oldest records when the limit is exceeded +- **Disk Space Safety Policy** - Skips writing new history entries when no disk space is available, preventing repeated command errors - **Shell Context Tracking** @@ -74,7 +72,6 @@ import "github.com/fuba/histree-core/pkg/histree" -dir string Current directory for filtering entries -format string Output format: json, simple, or verbose (default "simple") -limit int Number of entries to retrieve (default 100) --max-entries int Maximum number of history entries to keep (0 for unlimited) -hostname Hostname for command history (required for add action) -pid Process ID of the shell (required for add action) -exit int Exit code of the command From 723bb06e6feb70a53c325fa05d1cd006a4acaa8b Mon Sep 17 00:00:00 2001 From: fuba Date: Fri, 7 Nov 2025 22:42:49 +0900 Subject: [PATCH 3/7] Handle disk space errors explicitly --- cmd/histree-core/main.go | 6 ++++ cmd/histree-core/main_test.go | 8 ++++-- pkg/histree/disk_unix.go | 17 ++++++++++++ pkg/histree/disk_windows.go | 22 +++++++++++++++ pkg/histree/histree.go | 52 +++++++++++++++++++++++++++-------- 5 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 pkg/histree/disk_unix.go create mode 100644 pkg/histree/disk_windows.go diff --git a/cmd/histree-core/main.go b/cmd/histree-core/main.go index 05e343e..8096f8f 100644 --- a/cmd/histree-core/main.go +++ b/cmd/histree-core/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "flag" "fmt" "io" @@ -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) } diff --git a/cmd/histree-core/main_test.go b/cmd/histree-core/main_test.go index 73df442..f603fb7 100644 --- a/cmd/histree-core/main_test.go +++ b/cmd/histree-core/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "os" "strings" "testing" @@ -119,7 +120,7 @@ func TestGetEntries(t *testing.T) { } } -func TestAddEntrySkipsWhenNoDiskSpace(t *testing.T) { +func TestAddEntryErrorsWhenNoDiskSpace(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -137,8 +138,9 @@ func TestAddEntrySkipsWhenNoDiskSpace(t *testing.T) { ProcessID: 12345, } - if err := db.AddEntry(&entry); err != nil { - t.Fatalf("expected no error when disk full, got %v", err) + err := db.AddEntry(&entry) + if !errors.Is(err, histree.ErrInsufficientDiskSpace) { + t.Fatalf("expected ErrInsufficientDiskSpace, got %v", err) } var count int diff --git a/pkg/histree/disk_unix.go b/pkg/histree/disk_unix.go new file mode 100644 index 0000000..c10c3af --- /dev/null +++ b/pkg/histree/disk_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package histree + +import ( + "fmt" + "syscall" +) + +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 +} diff --git a/pkg/histree/disk_windows.go b/pkg/histree/disk_windows.go new file mode 100644 index 0000000..d0fe653 --- /dev/null +++ b/pkg/histree/disk_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package histree + +import ( + "fmt" + "syscall" +) + +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 +} diff --git a/pkg/histree/histree.go b/pkg/histree/histree.go index 9a95f76..5e34534 100644 --- a/pkg/histree/histree.go +++ b/pkg/histree/histree.go @@ -3,10 +3,11 @@ package histree import ( "database/sql" + "errors" "fmt" "os" "path/filepath" - "syscall" + "sync" "time" _ "github.com/mattn/go-sqlite3" @@ -47,6 +48,15 @@ type DB struct { // 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) @@ -70,6 +80,15 @@ 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; @@ -147,7 +166,7 @@ func (db *DB) AddEntry(entry *HistoryEntry) error { } if !hasSpace { - return nil + return fmt.Errorf("%w: unable to record command history entry in %s", ErrInsufficientDiskSpace, db.path) } if _, err := db.Exec( @@ -164,17 +183,23 @@ func (db *DB) AddEntry(entry *HistoryEntry) error { return nil } - -var checkDiskSpace = hasSufficientDiskSpace +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 { - checkDiskSpace = hasSufficientDiskSpace + diskSpaceCheckerFn = hasSufficientDiskSpace return } - checkDiskSpace = fn + diskSpaceCheckerFn = fn } func hasSufficientDiskSpace(dbPath string) (bool, error) { @@ -183,16 +208,19 @@ func hasSufficientDiskSpace(dbPath string) (bool, error) { dir = "." } - if err := ensureDirExists(dir); err != nil { - return false, err + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return false, fmt.Errorf("database directory %s does not exist", dir) + } + return false, fmt.Errorf("failed to access database directory %s: %w", dir, err) } - var stat syscall.Statfs_t - if err := syscall.Statfs(dir, &stat); err != nil { - return false, fmt.Errorf("failed to stat filesystem: %w", err) + if !info.IsDir() { + return false, fmt.Errorf("path %s is not a directory", dir) } - return stat.Bavail > 0, nil + return platformHasSufficientDiskSpace(dir) } func ensureDirExists(dir string) error { From 805eb34f27b68bdd36a15f3dd619fb6f0f6023a9 Mon Sep 17 00:00:00 2001 From: fuba Date: Sat, 8 Nov 2025 11:20:40 +0900 Subject: [PATCH 4/7] Relax disk space checker path guard --- pkg/histree/histree.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg/histree/histree.go b/pkg/histree/histree.go index 5e34534..dc77102 100644 --- a/pkg/histree/histree.go +++ b/pkg/histree/histree.go @@ -209,17 +209,20 @@ func hasSufficientDiskSpace(dbPath string) (bool, error) { } info, err := os.Stat(dir) - if err != nil { - if os.IsNotExist(err) { - return false, fmt.Errorf("database directory %s does not exist", 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) } - if !info.IsDir() { - return false, fmt.Errorf("path %s is not a directory", dir) - } - return platformHasSufficientDiskSpace(dir) } From 2ff97e9df56399bce3e95fa4ebb14c41c8a5e819 Mon Sep 17 00:00:00 2001 From: fuba Date: Fri, 6 Feb 2026 13:06:47 +0900 Subject: [PATCH 5/7] Fix edge cases in disk space checker - Skip disk space check for :memory: and empty path databases - Resolve symlinks before checking disk space to check actual filesystem - Find existing parent directory when target directory is deleted - Allow write on errors (safe fallback since history is non-critical) - Add comprehensive unit tests for edge cases Co-Authored-By: Claude Opus 4.5 --- pkg/histree/disk_test.go | 192 +++++++++++++++++++++++++++++++++++++++ pkg/histree/histree.go | 49 +++++++++- 2 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 pkg/histree/disk_test.go diff --git a/pkg/histree/disk_test.go b/pkg/histree/disk_test.go new file mode 100644 index 0000000..3e26e18 --- /dev/null +++ b/pkg/histree/disk_test.go @@ -0,0 +1,192 @@ +package histree + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHasSufficientDiskSpace_MemoryDB(t *testing.T) { + // In-memory database should always return true (skip disk check) + hasSpace, err := hasSufficientDiskSpace(":memory:") + if err != nil { + t.Fatalf("unexpected error for :memory: db: %v", err) + } + if !hasSpace { + t.Error("expected true for :memory: database") + } +} + +func TestHasSufficientDiskSpace_EmptyPath(t *testing.T) { + // Empty path should always return true (skip disk check) + hasSpace, err := hasSufficientDiskSpace("") + if err != nil { + t.Fatalf("unexpected error for empty path: %v", err) + } + if !hasSpace { + t.Error("expected true for empty path") + } +} + +func TestHasSufficientDiskSpace_NonExistentDirectory(t *testing.T) { + // Non-existent directory should find parent and check, or return true + tmpDir := t.TempDir() + nonExistentPath := filepath.Join(tmpDir, "does", "not", "exist", "test.db") + + hasSpace, err := hasSufficientDiskSpace(nonExistentPath) + if err != nil { + t.Fatalf("unexpected error for non-existent directory: %v", err) + } + // Should find tmpDir as parent and check disk space there + // Since tmpDir exists and likely has space, should return true + if !hasSpace { + t.Error("expected true for non-existent directory with existing parent") + } +} + +func TestHasSufficientDiskSpace_ExistingDirectory(t *testing.T) { + // Existing directory should work normally + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + hasSpace, err := hasSufficientDiskSpace(dbPath) + if err != nil { + t.Fatalf("unexpected error for existing directory: %v", err) + } + // tmpDir is a real directory with space, should return true + if !hasSpace { + t.Error("expected true for existing directory with space") + } +} + +func TestHasSufficientDiskSpace_Symlink(t *testing.T) { + // Test symlink resolution + tmpDir := t.TempDir() + realDir := filepath.Join(tmpDir, "real") + linkDir := filepath.Join(tmpDir, "link") + + if err := os.Mkdir(realDir, 0755); err != nil { + t.Fatalf("failed to create real directory: %v", err) + } + + if err := os.Symlink(realDir, linkDir); err != nil { + t.Skipf("symlinks not supported on this system: %v", err) + } + + dbPath := filepath.Join(linkDir, "test.db") + hasSpace, err := hasSufficientDiskSpace(dbPath) + if err != nil { + t.Fatalf("unexpected error for symlink path: %v", err) + } + if !hasSpace { + t.Error("expected true for symlink path") + } +} + +func TestHasSufficientDiskSpace_PathIsFile(t *testing.T) { + // If the "directory" is actually a file, should return error + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "somefile") + + // Create a file instead of a directory + if err := os.WriteFile(filePath, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + // Try to use this file as if it were a directory containing a db + dbPath := filepath.Join(filePath, "test.db") + hasSpace, err := hasSufficientDiskSpace(dbPath) + + // filepath.Dir(dbPath) == filePath, which is a file not a directory + if err == nil { + t.Error("expected error when parent path is a file, not a directory") + } + if hasSpace { + t.Error("expected false when parent path is a file") + } +} + +func TestFindExistingParent(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + name string + dir string + expected string + }{ + { + name: "non-existent nested path", + dir: filepath.Join(tmpDir, "a", "b", "c"), + expected: tmpDir, + }, + { + name: "existing directory", + dir: tmpDir, + expected: filepath.Dir(tmpDir), // Parent of tmpDir + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := findExistingParent(tc.dir) + if tc.expected != "" && result == "" { + t.Errorf("expected to find parent, got empty string") + } + if result != "" { + // Verify the returned path exists + if _, err := os.Stat(result); err != nil { + t.Errorf("returned parent %q does not exist: %v", result, err) + } + } + }) + } +} + +func TestFindExistingParent_RootPath(t *testing.T) { + // Testing with absolute non-existent path + result := findExistingParent("/nonexistent/deeply/nested/path") + // Should eventually find "/" on Unix systems + if result == "" { + t.Log("findExistingParent returned empty for root-relative path (may be expected on some systems)") + } else { + if _, err := os.Stat(result); err != nil { + t.Errorf("returned parent %q does not exist: %v", result, err) + } + } +} + +func TestSetDiskSpaceChecker(t *testing.T) { + // Test that SetDiskSpaceChecker works correctly + originalChecker := diskSpaceCheckerFn + + // Set custom checker + customCalled := false + SetDiskSpaceChecker(func(path string) (bool, error) { + customCalled = true + return false, nil + }) + + // Verify custom checker is called + _, _ = checkDiskSpace("/some/path") + if !customCalled { + t.Error("custom checker was not called") + } + + // Reset to nil (should restore default) + SetDiskSpaceChecker(nil) + + // Verify default is restored + diskSpaceCheckerMu.RLock() + currentChecker := diskSpaceCheckerFn + diskSpaceCheckerMu.RUnlock() + + // Can't directly compare functions, but we can check it's not nil + if currentChecker == nil { + t.Error("checker should not be nil after reset") + } + + // Restore original for other tests + diskSpaceCheckerMu.Lock() + diskSpaceCheckerFn = originalChecker + diskSpaceCheckerMu.Unlock() +} diff --git a/pkg/histree/histree.go b/pkg/histree/histree.go index dc77102..1d1107d 100644 --- a/pkg/histree/histree.go +++ b/pkg/histree/histree.go @@ -203,11 +203,22 @@ func SetDiskSpaceChecker(fn func(string) (bool, error)) { } func hasSufficientDiskSpace(dbPath string) (bool, error) { + // Skip disk space check for in-memory databases + if dbPath == ":memory:" || dbPath == "" { + return true, nil + } + dir := filepath.Dir(dbPath) if dir == "" { dir = "." } + // Try to resolve symlinks to check the actual filesystem + if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolvedDir + } + // If symlink resolution fails, continue with original path + info, err := os.Stat(dir) switch { case err == nil: @@ -217,13 +228,43 @@ func hasSufficientDiskSpace(dbPath string) (bool, error) { 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. + // Directory doesn't exist (possibly deleted after OpenDB). + // Try to find an existing parent directory to check disk space. + if parent := findExistingParent(dir); parent != "" { + dir = parent + } else { + // Cannot determine disk space - allow write to avoid blocking history + return true, nil + } default: - return false, fmt.Errorf("failed to access database directory %s: %w", dir, err) + // Other errors (permission denied, etc.) - allow write to avoid blocking history + // as history recording is not a critical operation + return true, nil + } + + hasSpace, err := platformHasSufficientDiskSpace(dir) + if err != nil { + // If we can't determine disk space, allow write rather than blocking + return true, nil } + return hasSpace, nil +} - return platformHasSufficientDiskSpace(dir) +// findExistingParent traverses up the directory tree to find an existing parent directory. +// Returns empty string if no existing parent can be found. +func findExistingParent(dir string) string { + for { + parent := filepath.Dir(dir) + if parent == dir { + // Reached root or can't go higher + break + } + if info, err := os.Stat(parent); err == nil && info.IsDir() { + return parent + } + dir = parent + } + return "" } func ensureDirExists(dir string) error { From 87068a7d44acbbd91c0e55a9da78a910d45dcf5d Mon Sep 17 00:00:00 2001 From: fuba Date: Fri, 6 Feb 2026 13:09:39 +0900 Subject: [PATCH 6/7] Add disk space thresholds and low space warning - Set minimum disk space threshold to 1MB (below this, writes blocked) - Set warning threshold to 10MB (below this, show warning) - Add CheckDiskSpaceWarning() method to DB for checking low space - Add FormatBytes() helper for human-readable byte sizes - Show warning message on stderr when disk space is running low - Add tests for thresholds and FormatBytes Co-Authored-By: Claude Opus 4.5 --- cmd/histree-core/main.go | 6 ++++ pkg/histree/disk_test.go | 61 +++++++++++++++++++++++++++++++++++++ pkg/histree/disk_unix.go | 32 +++++++++++++++++-- pkg/histree/disk_windows.go | 33 ++++++++++++++++++-- pkg/histree/histree.go | 59 +++++++++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 5 deletions(-) diff --git a/cmd/histree-core/main.go b/cmd/histree-core/main.go index 8096f8f..aa23b9e 100644 --- a/cmd/histree-core/main.go +++ b/cmd/histree-core/main.go @@ -74,6 +74,12 @@ func main() { os.Exit(1) } + // Check for low disk space warning after successful write + if warning := db.CheckDiskSpaceWarning(); warning != nil { + fmt.Fprintf(os.Stderr, "Warning: Low disk space (%s remaining). History recording may fail soon.\n", + histree.FormatBytes(warning.AvailableBytes)) + } + case "get": if err := handleGet(db, *limit, *currentDir, histree.OutputFormat(*format)); err != nil { fmt.Fprintf(os.Stderr, "Failed to get entries: %v\n", err) diff --git a/pkg/histree/disk_test.go b/pkg/histree/disk_test.go index 3e26e18..72dadec 100644 --- a/pkg/histree/disk_test.go +++ b/pkg/histree/disk_test.go @@ -190,3 +190,64 @@ func TestSetDiskSpaceChecker(t *testing.T) { diskSpaceCheckerFn = originalChecker diskSpaceCheckerMu.Unlock() } + +func TestFormatBytes(t *testing.T) { + tests := []struct { + bytes uint64 + expected string + }{ + {0, "0 bytes"}, + {512, "512 bytes"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1024 * 1024, "1.0 MB"}, + {1024 * 1024 * 10, "10.0 MB"}, + {1024 * 1024 * 1024, "1.0 GB"}, + {1024 * 1024 * 1024 * 2, "2.0 GB"}, + } + + for _, tc := range tests { + t.Run(tc.expected, func(t *testing.T) { + result := FormatBytes(tc.bytes) + if result != tc.expected { + t.Errorf("FormatBytes(%d) = %q, want %q", tc.bytes, result, tc.expected) + } + }) + } +} + +func TestCheckDiskSpaceWarning_MemoryDB(t *testing.T) { + // In-memory database should not produce warnings + db := &DB{path: ":memory:"} + warning := db.CheckDiskSpaceWarning() + if warning != nil { + t.Error("expected no warning for :memory: database") + } +} + +func TestCheckDiskSpaceWarning_EmptyPath(t *testing.T) { + // Empty path should not produce warnings + db := &DB{path: ""} + warning := db.CheckDiskSpaceWarning() + if warning != nil { + t.Error("expected no warning for empty path") + } +} + +func TestDiskSpaceThresholds(t *testing.T) { + // Verify threshold constants are sensible + if minFreeDiskBytes >= warnFreeDiskBytes { + t.Errorf("minFreeDiskBytes (%d) should be less than warnFreeDiskBytes (%d)", + minFreeDiskBytes, warnFreeDiskBytes) + } + + // Verify minimum is at least 1MB + if minFreeDiskBytes < 1024*1024 { + t.Errorf("minFreeDiskBytes (%d) should be at least 1MB", minFreeDiskBytes) + } + + // Verify warning threshold is reasonable (at least 5MB) + if warnFreeDiskBytes < 5*1024*1024 { + t.Errorf("warnFreeDiskBytes (%d) should be at least 5MB", warnFreeDiskBytes) + } +} diff --git a/pkg/histree/disk_unix.go b/pkg/histree/disk_unix.go index c10c3af..d7c66c4 100644 --- a/pkg/histree/disk_unix.go +++ b/pkg/histree/disk_unix.go @@ -7,11 +7,39 @@ import ( "syscall" ) +// Disk space thresholds for history recording. +// SQLite with WAL mode needs space for the main DB file, WAL file (.wal), and +// shared memory file (.shm). +const ( + // minFreeDiskBytes is the minimum required - below this, writes are blocked + minFreeDiskBytes = 1 * 1024 * 1024 // 1MB + + // warnFreeDiskBytes is the warning threshold - below this, a warning is shown + warnFreeDiskBytes = 10 * 1024 * 1024 // 10MB +) + func platformHasSufficientDiskSpace(dir string) (bool, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, err + } + return availableBytes >= minFreeDiskBytes, nil +} + +func platformGetAvailableDiskSpace(dir string) (uint64, 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 0, fmt.Errorf("failed to stat filesystem: %w", err) } - return stat.Bavail > 0, nil + // Calculate available bytes: available blocks * block size + return uint64(stat.Bavail) * uint64(stat.Bsize), nil +} + +func platformShouldWarnDiskSpace(dir string) (bool, uint64, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, 0, err + } + return availableBytes < warnFreeDiskBytes && availableBytes >= minFreeDiskBytes, availableBytes, nil } diff --git a/pkg/histree/disk_windows.go b/pkg/histree/disk_windows.go index d0fe653..bb11e1f 100644 --- a/pkg/histree/disk_windows.go +++ b/pkg/histree/disk_windows.go @@ -7,16 +7,43 @@ import ( "syscall" ) +// Disk space thresholds for history recording. +// SQLite with WAL mode needs space for the main DB file, WAL file (.wal), and +// shared memory file (.shm). +const ( + // minFreeDiskBytes is the minimum required - below this, writes are blocked + minFreeDiskBytes = 1 * 1024 * 1024 // 1MB + + // warnFreeDiskBytes is the warning threshold - below this, a warning is shown + warnFreeDiskBytes = 10 * 1024 * 1024 // 10MB +) + func platformHasSufficientDiskSpace(dir string) (bool, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, err + } + return availableBytes >= minFreeDiskBytes, nil +} + +func platformGetAvailableDiskSpace(dir string) (uint64, error) { pathPtr, err := syscall.UTF16PtrFromString(dir) if err != nil { - return false, fmt.Errorf("failed to encode path for disk query: %w", err) + return 0, 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 0, fmt.Errorf("failed to query free disk space: %w", err) } - return freeBytesAvailable > 0, nil + return freeBytesAvailable, nil +} + +func platformShouldWarnDiskSpace(dir string) (bool, uint64, error) { + availableBytes, err := platformGetAvailableDiskSpace(dir) + if err != nil { + return false, 0, err + } + return availableBytes < warnFreeDiskBytes && availableBytes >= minFreeDiskBytes, availableBytes, nil } diff --git a/pkg/histree/histree.go b/pkg/histree/histree.go index 1d1107d..1ebec33 100644 --- a/pkg/histree/histree.go +++ b/pkg/histree/histree.go @@ -80,10 +80,69 @@ func (db *DB) Close() error { return db.DB.Close() } +// CheckDiskSpaceWarning checks if disk space is running low (but not yet critical). +// Returns a warning if space is below the warning threshold but above the critical threshold. +// Returns nil if there's plenty of space or if the check cannot be performed. +func (db *DB) CheckDiskSpaceWarning() *DiskSpaceWarning { + // Skip for in-memory databases + if db.path == ":memory:" || db.path == "" { + return nil + } + + dir := filepath.Dir(db.path) + if dir == "" { + dir = "." + } + + // Try to resolve symlinks + if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolvedDir + } + + // Check if directory exists + if _, err := os.Stat(dir); err != nil { + return nil // Can't check, don't warn + } + + shouldWarn, availableBytes, err := platformShouldWarnDiskSpace(dir) + if err != nil { + return nil // Can't check, don't warn + } + + if shouldWarn { + return &DiskSpaceWarning{AvailableBytes: availableBytes} + } + return nil +} + // 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") +// DiskSpaceWarning contains information about low disk space conditions. +type DiskSpaceWarning struct { + AvailableBytes uint64 +} + +// FormatBytes returns a human-readable string representation of bytes. +func FormatBytes(bytes uint64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case bytes >= GB: + return fmt.Sprintf("%.1f GB", float64(bytes)/GB) + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/MB) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/KB) + default: + return fmt.Sprintf("%d bytes", bytes) + } +} + var ( diskSpaceCheckerMu sync.RWMutex diskSpaceCheckerFn = hasSufficientDiskSpace From 2e84b514cfba2408660c4af084bfbf5581525266 Mon Sep 17 00:00:00 2001 From: fuba Date: Fri, 6 Feb 2026 13:10:00 +0900 Subject: [PATCH 7/7] Update README with disk space threshold details Co-Authored-By: Claude Opus 4.5 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e5f50c8..bf9d60d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ This project was developed with the assistance of ChatGPT and GitHub Copilot. - Handles relative paths automatically - **Disk Space Safety Policy** - - Skips writing new history entries when no disk space is available, preventing repeated command errors + - Warns when disk space falls below 10MB + - Blocks writes when disk space falls below 1MB (to prevent SQLite corruption) + - Handles edge cases gracefully (symlinks, deleted directories, in-memory databases) - **Shell Context Tracking** Each command is stored with its execution context: