feat: add installation instructions and fix dot syntax stashing

- Add detailed installation instructions to README
- Fix dot syntax stashing to use parent directory as target
- Fix cross-filesystem stashing issues
- Update test script to handle module paths correctly
This commit is contained in:
glenneth1 2024-12-06 08:39:12 +03:00
parent 269afd966d
commit db11c09f3a
5 changed files with 305 additions and 136 deletions

110
README.md
View File

@ -1,71 +1,119 @@
# Stash # Stash
`stash` is a command-line utility written in Guile Scheme that helps manage symbolic links (symlinks) for files and directories. This tool is inspired by GNU Stow but written in Guile Scheme. It provides advanced path handling and conflict resolution, allowing users to manage their dotfiles and configuration files effectively. `stash` is a command-line utility written in Guile Scheme that helps organize your files by moving them to a target location and creating symbolic links (symlinks) in their original location. While it's great for managing dotfiles, it works with any directories you want to organize.
## Installation
1. **Prerequisites**:
- Guile Scheme 3.0.9 or later
- A Unix-like environment (Linux/macOS)
2. **Installation Steps**:
```sh
# Clone the repository
git clone https://github.com/yourusername/stash.git
cd stash
# Add to your ~/.guile load path
mkdir -p ~/.guile.d/site/3.0
ln -s $(pwd)/modules/stash ~/.guile.d/site/3.0/
# Optional: Add a convenient alias to your shell config (~/.bashrc or ~/.zshrc)
echo 'alias stash="guile -L $(pwd) $(pwd)/stash.scm"' >> ~/.bashrc
source ~/.bashrc
```
3. **Verify Installation**:
```sh
# Test if stash works
stash --help
```
## Key Features ## Key Features
- **Advanced Path Handling**: Robust support for dot syntax, home directory expansion, and both absolute and relative paths - **Flexible Usage**: Works with any directories, not just config files
- **Flexible Usage**: Supports both dot syntax (.) and explicit source/target paths - **Interactive Mode**: Option to interactively specify target directory
- **Symlink Management**: Creates and manages symlinks while maintaining correct directory structure - **Recursive Processing**: Can process entire directory trees
- **Conflict Resolution**: Handles existing files and symlinks gracefully - **Advanced Path Handling**: Supports home directory expansion and relative paths
- **Comprehensive Logging**: Detailed logging of all operations for tracking and debugging - **Symlink Management**: Creates and manages symlinks while maintaining directory structure
- **Ignore Patterns**: Supports local and global ignore patterns
## Usage ## Usage
There are two ways to use stash: Stash offers several ways to organize your files:
1. Using explicit source and target directories: 1. **Interactive Mode** (easiest for beginners):
```sh ```sh
guile -L . stash.scm --target=<target-dir> --source=<package-dir> # Move Pictures directory to a backup location
guile -L . stash.scm --source ~/Pictures --interactive
``` ```
2. Using the dot syntax (similar to GNU Stow): 2. **Explicit Paths**:
```sh ```sh
cd ~/.dotfiles/.config/package # Move Documents to backup while keeping symlink
guile -L . stash.scm --source ~/Documents/notes --target ~/backup/notes
# Move project to code archive
guile -L . stash.scm --source ~/projects/webapp --target ~/code/archive/webapp
```
3. **Recursive Mode** (for entire directory trees):
```sh
# Archive entire projects directory
guile -L . stash.scm --source ~/projects --target ~/archive/projects --recursive
```
4. **Dot Syntax** (after files are stashed):
```sh
# Recreate symlink for previously stashed directory
cd ~/backup/notes
guile -L . stash.scm . guile -L . stash.scm .
``` ```
When using the dot syntax (`.`), stash will: ## Common Use Cases
1. Use the current directory as the package directory 1. **Organizing Dotfiles**:
2. Use the parent directory as the stow directory
3. Create symlinks in the corresponding location under your home directory
### Examples
1. Explicit paths:
```sh ```sh
# Move ~/.config/rofi to ~/.dotfiles/.config and create symlink # Move config files to dotfiles repo
guile -L . stash.scm --target=~/.dotfiles/.config --source=~/.config/rofi guile -L . stash.scm --source ~/.config --target ~/.dotfiles/config --recursive
``` ```
2. Dot syntax: 2. **Backing Up Documents**:
```sh ```sh
# Move to your package directory # Move documents to external drive
cd ~/.dotfiles/.config/rofi guile -L . stash.scm --source ~/Documents --target /media/backup/docs --recursive
# Create symlink at ~/.config/rofi ```
guile -L . stash.scm .
3. **Project Organization**:
```sh
# Archive old project while keeping it accessible
guile -L . stash.scm --source ~/projects/old-app --target ~/archive/projects/old-app
``` ```
## Path Handling ## Path Handling
Stash handles various path formats: Stash handles various path formats:
- Absolute paths: `/home/user/.config` - Absolute paths: `/home/user/documents`
- Relative paths: `../config`, `./config` - Relative paths: `../documents`, `./notes`
- Home directory: `~/.config` - Home directory: `~/documents`
- Dot notation: `.`, `..` - Dot notation: `.`, `..`
## Ignore Patterns ## Ignore Patterns
Stash supports two types of ignore files: Stash supports two types of ignore files:
- `.stash-local-ignore`: Package-specific ignore patterns - `.stash-local-ignore`: Directory-specific ignore patterns
- `.stash-global-ignore`: Global ignore patterns for all packages - `.stash-global-ignore`: Global ignore patterns
Default ignore patterns: Default ignore patterns:

View File

@ -4,7 +4,7 @@
#:use-module (stash log) ;; Import log-action, current-timestamp #:use-module (stash log) ;; Import log-action, current-timestamp
#:use-module (stash paths) ;; Import expand-home, concat-path, ensure-config-path #:use-module (stash paths) ;; Import expand-home, concat-path, ensure-config-path
#:use-module (stash conflict) ;; Import prompt-user-for-action, handle-conflict #:use-module (stash conflict) ;; Import prompt-user-for-action, handle-conflict
#:export (move-source-to-target create-symlink-in-parent delete-directory)) #:export (move-source-to-target create-symlink delete-directory mkdir-p execute-operations file-is-symlink?))
;;; Helper function to quote shell arguments ;;; Helper function to quote shell arguments
(define (shell-quote-argument arg) (define (shell-quote-argument arg)
@ -37,18 +37,66 @@
(handle-conflict target-source-dir source-dir delete-directory log-action) ;; Conflict handling (handle-conflict target-source-dir source-dir delete-directory log-action) ;; Conflict handling
;; If the target directory doesn't exist, proceed with the move ;; If the target directory doesn't exist, proceed with the move
(begin (begin
(rename-file source-dir target-source-dir) ;; Try rename-file first (fast but only works on same device)
(display (format #f "Moved ~a to ~a\n" source-dir target-source-dir)) (catch 'system-error
(log-action (format #f "Moved ~a to ~a" source-dir target-source-dir)))) (lambda ()
(rename-file source-dir target-source-dir)
(display (format #f "Moved ~a to ~a\n" source-dir target-source-dir))
(log-action (format #f "Moved ~a to ~a" source-dir target-source-dir)))
(lambda args
;; If rename-file fails, fall back to cp -R and rm -rf
(system (string-append "cp -R " (shell-quote-argument source-dir) " " (shell-quote-argument target-source-dir)))
(system (string-append "rm -rf " (shell-quote-argument source-dir)))
(display (format #f "Moved (via copy) ~a to ~a\n" source-dir target-source-dir))
(log-action (format #f "Moved (via copy) ~a to ~a" source-dir target-source-dir))))))
target-source-dir)) ;; Return the path of the moved source directory target-source-dir)) ;; Return the path of the moved source directory
;;; Helper function to create a symlink in the parent directory of the original source ;;; Helper function to create a symlink
(define (create-symlink-in-parent source target) (define (create-symlink source target)
"Create a symlink in the parent directory of the original source." "Create a symlink from source to target."
(let ((parent-dir (dirname target))) (when (file-exists? source)
;; Create parent directory if it doesn't exist (delete-file source))
(if (not (file-exists? parent-dir)) (let ((source-dir (dirname source)))
(mkdir parent-dir #o755)) (when (not (file-exists? source-dir))
;; Create the symlink (mkdir-p source-dir)))
(symlink source target) (format #t "Creating symlink: ~a -> ~a~%" source target)
(log-action (format #f "Created symlink ~a -> ~a" target source)))) (symlink target source)
(log-action (format #f "Created symlink ~a -> ~a" source target)))
;;; Helper function to check if a path is a symlink
(define (file-is-symlink? path)
"Check if a path is a symbolic link."
(catch 'system-error
(lambda ()
(eq? 'symlink (stat:type (lstat path))))
(lambda args #f)))
;;; Helper function to create parent directories recursively
(define (mkdir-p path)
"Create directory and parent directories if they don't exist."
(display (format #f "Creating parent directory: ~a\n" path))
(let ((parent (dirname path)))
(when (not (file-exists? parent))
(mkdir-p parent))
(when (not (file-exists? path))
(mkdir path #o755))))
;;; Helper function to execute operations
(define (execute-operations operations)
"Execute a list of operations."
(for-each
(lambda (op)
(case (car op)
((mkdir) (mkdir-p (cadr op)))
((symlink) (create-symlink (cadr op) (caddr op)))
((move) (move-source-to-target (cadr op) (caddr op)))
((delete) (delete-directory (cadr op)))
(else (display (format #f "Unknown operation: ~a\n" op)))))
operations))
;;; Export list
(export mkdir-p
execute-operations
move-source-to-target
create-symlink
file-is-symlink?)

View File

@ -1,38 +1,50 @@
;; stash-help.scm --- Help message module for Stash ;; stash-help.scm --- Help message module for Stash
(define-module (stash help) (define-module (stash help)
#:export (display-help)) #:use-module (ice-9 format)
#:export (display-help
display-version))
;;; Function to display help message ;;; Function to display help message
(define (display-help) (define (display-help)
"Display help message explaining how to use the program." "Display help message explaining how to use the program."
(display " (display "\
Usage: stash.scm [--source <source-dir> --target <target-dir>] [options] Usage: stash.scm [OPTION...] [.]
stash.scm [-s <source-dir> -t <target-dir>] [options]
stash.scm .
Stash is a Guile Scheme utility for managing symlinks with conflict resolution. Stash is a symlink management utility that helps organize your files by moving them
to a target location and creating symbolic links in their original location.
Options: Options:
--source, -s Specify the source directory to be moved. -s, --source=DIR Source directory to stash
--target, -t Specify the target directory where the symlink should be created. -t, --target=DIR Target directory where files will be stashed
--version, -v Show the current version of the program. -r, --recursive Recursively process directories under source
--help, -h Display this help message. -i, --interactive Interactively prompt for target directory
--no-folding Disable directory tree folding. -h, --help Display this help
--simulate Show what would happen without making changes. -v, --version Display version information
--adopt Import existing files into the stow directory.
--restow Remove and recreate all symlinks.
--delete Remove all symlinks for a package.
Examples: Examples:
1. Using explicit paths: # Using dot syntax (after files are stashed):
guile -L . stash.scm --source ~/.config/test --target ~/.dotfiles/ cd ~/.dotfiles/config/nvim
guile -L . stash.scm -s ~/.config/test -t ~/.dotfiles/ stash.scm . # Creates symlink in ~/.config/nvim
2. Using dot syntax (like GNU Stow): # Stash a single directory:
cd ~/.dotfiles/.config/test stash.scm -s ~/Documents/notes -t ~/backup/notes # Move notes and create symlink
guile -L . stash.scm .
# Stash with interactive target selection:
stash.scm -s ~/Pictures -i # Will prompt for target directory
This will create symlinks in ~/.config/test pointing to ~/.dotfiles/.config/test # Recursively stash an entire directory:
") stash.scm -s ~/.config -t ~/.dotfiles/config -r # Stash all config files
(exit 0))
# Stash any directory to any location:
stash.scm -s ~/projects/web -t ~/backup/code/web # Not limited to dotfiles
Note: Stash works with any directories, not just config files or dotfiles.
You can use it to organize any files by moving them to a backup/storage
location while maintaining easy access through symlinks.
For more information, visit: https://codeberg.org/glenneth/stash
"))
(define (display-version)
(display "stash version 0.1.0-alpha.1\n"))

View File

@ -62,30 +62,29 @@
(define (plan-operations tree package) (define (plan-operations tree package)
(let* ((source-base (package-path package)) (let* ((source-base (package-path package))
(target-base (package-target package)) (target-base (package-target package))
(source-name (basename source-base))) (source-name (package-name package))
(target-path (string-append target-base "/" source-name)))
(format #t "Source base: ~a~%" source-base) (format #t "Source base: ~a~%" source-base)
(format #t "Target base: ~a~%" target-base) (format #t "Target base: ~a~%" target-base)
(format #t "Source name: ~a~%" source-name) (format #t "Source name: ~a~%" source-name)
(format #t "Target path: ~a~%" target-path)
;; Create symlink for the entire directory ;; Create parent directory if it doesn't exist
(let* ((target-path (string-append (getenv "HOME") "/.config/" source-name))) (let ((target-dir (dirname target-path)))
(format #t "Target path: ~a~%" target-path) (when (not (file-exists? target-dir))
(format #t "Creating parent directory: ~a~%" target-dir)
;; Create parent directory if it doesn't exist (mkdir-p target-dir)))
(let ((target-dir (dirname target-path)))
(when (not (file-exists? target-dir)) ;; Remove existing directory or file
(format #t "Creating parent directory: ~a~%" target-dir) (when (file-exists? target-path)
(mkdir target-dir #o755))) (format #t "Removing existing path: ~a~%" target-path)
(system (string-append "rm -rf " target-path)))
;; Remove existing directory or file
(when (file-exists? target-path) ;; Move source to target
(format #t "Removing existing path: ~a~%" target-path) (format #t "Moving ~a to ~a~%" source-base target-path)
(system (string-append "rm -rf " target-path))) (rename-file source-base target-path)
;; Create symlink ;; Create symlink
(let ((source-target (if (string-prefix? (getenv "HOME") source-base) (format #t "Creating symlink: ~a -> ~a~%" source-base target-path)
(string-append target-base "/" source-name) (symlink target-path source-base)
source-base))) '()))
(format #t "Creating symlink: ~a -> ~a~%" target-path source-target)
(symlink source-target target-path)
'()))))

150
stash.scm
View File

@ -35,6 +35,8 @@
(define-module (stash) (define-module (stash)
#:use-module (ice-9 getopt-long) #:use-module (ice-9 getopt-long)
#:use-module (ice-9 ftw)
#:use-module (ice-9 rdelim)
#:use-module (stash help) #:use-module (stash help)
#:use-module (stash colors) #:use-module (stash colors)
#:use-module (stash log) #:use-module (stash log)
@ -46,28 +48,38 @@
#:use-module (srfi srfi-1) #:use-module (srfi srfi-1)
#:use-module (srfi srfi-19)) #:use-module (srfi srfi-19))
;;; Color constants
(define blue-text "\x1b[0;34m")
(define yellow-text "\x1b[0;33m")
(define red-text "\x1b[0;31m")
;;; Command-line options ;;; Command-line options
(define %options (define %options
'((target (value #t) (single-char #\t)) '((target (value #t) (single-char #\t))
(source (value #t) (single-char #\s)) (source (value #t) (single-char #\s))
(recursive (value #f) (single-char #\r))
(interactive (value #f) (single-char #\i))
(help (value #f) (single-char #\h)) (help (value #f) (single-char #\h))
(version (value #f) (single-char #\v)) (version (value #f) (single-char #\v))))
(no-folding (value #f) (single-char #\n))
(simulate (value #f) (single-char #\S))
(adopt (value #f) (single-char #\a))
(restow (value #f) (single-char #\R))
(delete (value #f) (single-char #\D))))
;;; Function to handle dot directory stashing ;;; Function to handle dot directory stashing
(define (handle-dot-stash current-dir) (define (handle-dot-stash)
"Handle stashing when using the '.' syntax. Uses parent as stow dir and $HOME as target." "Handle stashing when using the '.' syntax. Uses parent directory as target."
(let* ((pkg-dir (canonicalize-path current-dir)) (let* ((current-dir (canonicalize-path (getcwd)))
(pkg-dir (if (file-is-symlink? current-dir)
(canonicalize-path (readlink current-dir))
current-dir))
(pkg-name (basename pkg-dir))
(stow-dir (dirname pkg-dir)) (stow-dir (dirname pkg-dir))
(home-dir (getenv "HOME"))) (target-dir (dirname stow-dir))
(format #t "pkg-dir: ~a~%" pkg-dir) (ignore-patterns (read-ignore-patterns pkg-dir)))
(format #t "stow-dir: ~a~%" stow-dir) (format #t "Package directory: ~a~%" pkg-dir)
(format #t "home-dir: ~a~%" home-dir) (format #t "Stow directory: ~a~%" stow-dir)
(values pkg-dir stow-dir home-dir))) (format #t "Target directory: ~a~%" target-dir)
(if (file-is-symlink? current-dir)
(format #t "Directory is already stashed at: ~a~%" pkg-dir)
(let ((package (make-package pkg-name pkg-dir target-dir ignore-patterns)))
(process-package package)))))
;;; Version function ;;; Version function
(define (display-version) (define (display-version)
@ -90,45 +102,95 @@
(no-folding? (assoc-ref options 'no-folding))) (no-folding? (assoc-ref options 'no-folding)))
(plan-operations tree package))) (plan-operations tree package)))
;;; Prompt user for target directory path
(define (prompt-for-target source-path)
"Prompt user for target directory path."
(display (color-message (string-append "\nSource directory: " source-path "\n") blue-text))
(display (color-message "Enter target directory path (where files will be stashed): " yellow-text))
(let ((input (read-line)))
(if (string-null? input)
(begin
(display (color-message "Target directory cannot be empty. Please try again.\n" red-text))
(prompt-for-target source-path))
(canonicalize-path (expand-home input)))))
;;; Main entry point ;;; Main entry point
(define (main args) (define (main args)
"Main function to parse arguments and execute the program." "Main function to parse arguments and execute the program."
(setenv "GUILE_AUTO_COMPILE" "0") (setenv "GUILE_AUTO_COMPILE" "0")
(let* ((options (getopt-long args %options)) (let* ((options (getopt-long args %options))
(source-dir (assoc-ref options 'source)) (help-wanted? (option-ref options 'help #f))
(target-dir (assoc-ref options 'target)) (version-wanted? (option-ref options 'version #f))
(non-option-args (option-ref options '() '()))) (recursive? (option-ref options 'recursive #f))
(interactive? (option-ref options 'interactive #f))
(source (option-ref options 'source #f))
(target (option-ref options 'target #f)))
(cond (cond
((assoc-ref options 'help) (display-help)) (help-wanted? (display-help) (exit 0))
((assoc-ref options 'version) (display-version)) (version-wanted? (display-version) (exit 0))
;; Handle "stash ." syntax
((and (= (length non-option-args) 1) ;; Handle dot syntax
(string=? (car non-option-args) ".")) ((and (= (length (option-ref options '() '())) 1)
(call-with-values (string=? (car (option-ref options '() '())) "."))
(lambda () (handle-dot-stash (getcwd))) (handle-dot-stash))
(lambda (pkg-dir stow-dir home-dir)
(let ((package (make-package ;; Handle interactive mode
(basename pkg-dir) ((and source interactive?)
pkg-dir (let ((target-path (prompt-for-target (canonicalize-path source))))
home-dir (handle-explicit-stash source target-path recursive?)))
(read-ignore-patterns pkg-dir))))
(handle-stow package options))))) ;; Handle explicit paths with optional recursion
;; Handle traditional --source --target syntax ((and source target)
((not (and target-dir source-dir)) (handle-explicit-stash source target recursive?))
(display (color-message "Error: Either use '.' in a package directory or provide both --source and --target arguments.\n" red-text))
(display (color-message "Usage: stash.scm [--target <target-dir> --source <source-dir>] | [.]\n" yellow-text))
(exit 1))
(else (else
(let* ((expanded-source (expand-home source-dir)) (display-help)
(expanded-target (expand-home target-dir)) (exit 1)))))
(package (make-package
(basename expanded-source) (define (handle-explicit-stash source target recursive?)
expanded-source "Handle stashing with explicit source and target paths."
expanded-target (let* ((source-path (canonicalize-path source))
(read-ignore-patterns expanded-source)))) (target-path (canonicalize-path target))
(handle-stow package options)))))) (package-name (basename source-path))
(ignore-patterns (read-ignore-patterns source-path)))
(if recursive?
(handle-recursive-stash source-path target-path)
(let ((package (make-package package-name source-path target-path ignore-patterns)))
(process-package package)))))
(define (handle-recursive-stash source target)
"Recursively process directories under source."
(let* ((source-path (canonicalize-path source))
(target-path (canonicalize-path target))
(source-name (basename source-path))
(target-config-path (string-append target-path "/" source-name))
(entries (if (file-is-directory? source-path)
(scandir source-path)
(list (basename source-path))))
(valid-entries (filter (lambda (entry)
(and (not (member entry '("." "..")))
(file-is-directory? (string-append source-path "/" entry))))
entries)))
;; First ensure the config directory exists in target
(if (not (file-exists? target-config-path))
(mkdir-p target-config-path))
;; Then process each subdirectory
(for-each
(lambda (entry)
(let* ((source-dir (string-append source-path "/" entry))
(package-name entry)
(ignore-patterns (read-ignore-patterns source-dir)))
(let ((package (make-package package-name source-dir target-config-path ignore-patterns)))
(process-package package))))
valid-entries)))
(define (process-package package)
"Process a single package for stashing."
(let* ((tree (analyze-tree package))
(operations (plan-operations tree package)))
(execute-operations operations)))
;; Entry point for stash ;; Entry point for stash
(main (command-line)) (main (command-line))