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` 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
- **Advanced Path Handling**: Robust support for dot syntax, home directory expansion, and both absolute and relative paths
- **Flexible Usage**: Supports both dot syntax (.) and explicit source/target paths
- **Symlink Management**: Creates and manages symlinks while maintaining correct directory structure
- **Conflict Resolution**: Handles existing files and symlinks gracefully
- **Comprehensive Logging**: Detailed logging of all operations for tracking and debugging
- **Flexible Usage**: Works with any directories, not just config files
- **Interactive Mode**: Option to interactively specify target directory
- **Recursive Processing**: Can process entire directory trees
- **Advanced Path Handling**: Supports home directory expansion and relative paths
- **Symlink Management**: Creates and manages symlinks while maintaining directory structure
- **Ignore Patterns**: Supports local and global ignore patterns
## 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
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
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 .
```
When using the dot syntax (`.`), stash will:
## Common Use Cases
1. Use the current directory as the package directory
2. Use the parent directory as the stow directory
3. Create symlinks in the corresponding location under your home directory
### Examples
1. Explicit paths:
1. **Organizing Dotfiles**:
```sh
# Move ~/.config/rofi to ~/.dotfiles/.config and create symlink
guile -L . stash.scm --target=~/.dotfiles/.config --source=~/.config/rofi
# Move config files to dotfiles repo
guile -L . stash.scm --source ~/.config --target ~/.dotfiles/config --recursive
```
2. Dot syntax:
2. **Backing Up Documents**:
```sh
# Move to your package directory
cd ~/.dotfiles/.config/rofi
# Create symlink at ~/.config/rofi
guile -L . stash.scm .
# Move documents to external drive
guile -L . stash.scm --source ~/Documents --target /media/backup/docs --recursive
```
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
Stash handles various path formats:
- Absolute paths: `/home/user/.config`
- Relative paths: `../config`, `./config`
- Home directory: `~/.config`
- Absolute paths: `/home/user/documents`
- Relative paths: `../documents`, `./notes`
- Home directory: `~/documents`
- Dot notation: `.`, `..`
## Ignore Patterns
Stash supports two types of ignore files:
- `.stash-local-ignore`: Package-specific ignore patterns
- `.stash-global-ignore`: Global ignore patterns for all packages
- `.stash-local-ignore`: Directory-specific ignore patterns
- `.stash-global-ignore`: Global ignore patterns
Default ignore patterns:

View File

@ -4,7 +4,7 @@
#:use-module (stash log) ;; Import log-action, current-timestamp
#:use-module (stash paths) ;; Import expand-home, concat-path, ensure-config-path
#: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
(define (shell-quote-argument arg)
@ -37,18 +37,66 @@
(handle-conflict target-source-dir source-dir delete-directory log-action) ;; Conflict handling
;; If the target directory doesn't exist, proceed with the move
(begin
(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))))
;; Try rename-file first (fast but only works on same device)
(catch 'system-error
(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
;;; Helper function to create a symlink in the parent directory of the original source
(define (create-symlink-in-parent source target)
"Create a symlink in the parent directory of the original source."
(let ((parent-dir (dirname target)))
;; Create parent directory if it doesn't exist
(if (not (file-exists? parent-dir))
(mkdir parent-dir #o755))
;; Create the symlink
(symlink source target)
(log-action (format #f "Created symlink ~a -> ~a" target source))))
;;; Helper function to create a symlink
(define (create-symlink source target)
"Create a symlink from source to target."
(when (file-exists? source)
(delete-file source))
(let ((source-dir (dirname source)))
(when (not (file-exists? source-dir))
(mkdir-p source-dir)))
(format #t "Creating symlink: ~a -> ~a~%" source target)
(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
(define-module (stash help)
#:export (display-help))
#:use-module (ice-9 format)
#:export (display-help
display-version))
;;; Function to display help message
(define (display-help)
"Display help message explaining how to use the program."
(display "
Usage: stash.scm [--source <source-dir> --target <target-dir>] [options]
stash.scm [-s <source-dir> -t <target-dir>] [options]
stash.scm .
(display "\
Usage: stash.scm [OPTION...] [.]
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:
--source, -s Specify the source directory to be moved.
--target, -t Specify the target directory where the symlink should be created.
--version, -v Show the current version of the program.
--help, -h Display this help message.
--no-folding Disable directory tree folding.
--simulate Show what would happen without making changes.
--adopt Import existing files into the stow directory.
--restow Remove and recreate all symlinks.
--delete Remove all symlinks for a package.
-s, --source=DIR Source directory to stash
-t, --target=DIR Target directory where files will be stashed
-r, --recursive Recursively process directories under source
-i, --interactive Interactively prompt for target directory
-h, --help Display this help
-v, --version Display version information
Examples:
1. Using explicit paths:
guile -L . stash.scm --source ~/.config/test --target ~/.dotfiles/
guile -L . stash.scm -s ~/.config/test -t ~/.dotfiles/
# Using dot syntax (after files are stashed):
cd ~/.dotfiles/config/nvim
stash.scm . # Creates symlink in ~/.config/nvim
2. Using dot syntax (like GNU Stow):
cd ~/.dotfiles/.config/test
guile -L . stash.scm .
# Stash a single directory:
stash.scm -s ~/Documents/notes -t ~/backup/notes # Move notes and create symlink
# 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
")
(exit 0))
# Recursively stash an entire directory:
stash.scm -s ~/.config -t ~/.dotfiles/config -r # Stash all config files
# 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)
(let* ((source-base (package-path 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 "Target base: ~a~%" target-base)
(format #t "Source name: ~a~%" source-name)
(format #t "Target path: ~a~%" target-path)
;; Create symlink for the entire directory
(let* ((target-path (string-append (getenv "HOME") "/.config/" source-name)))
(format #t "Target path: ~a~%" target-path)
;; Create parent directory if it doesn't exist
(let ((target-dir (dirname target-path)))
(when (not (file-exists? target-dir))
(format #t "Creating parent directory: ~a~%" target-dir)
(mkdir target-dir #o755)))
;; Remove existing directory or file
(when (file-exists? target-path)
(format #t "Removing existing path: ~a~%" target-path)
(system (string-append "rm -rf " target-path)))
;; Create symlink
(let ((source-target (if (string-prefix? (getenv "HOME") source-base)
(string-append target-base "/" source-name)
source-base)))
(format #t "Creating symlink: ~a -> ~a~%" target-path source-target)
(symlink source-target target-path)
'()))))
;; Create parent directory if it doesn't exist
(let ((target-dir (dirname target-path)))
(when (not (file-exists? target-dir))
(format #t "Creating parent directory: ~a~%" target-dir)
(mkdir-p target-dir)))
;; Remove existing directory or file
(when (file-exists? target-path)
(format #t "Removing existing path: ~a~%" target-path)
(system (string-append "rm -rf " target-path)))
;; Move source to target
(format #t "Moving ~a to ~a~%" source-base target-path)
(rename-file source-base target-path)
;; Create symlink
(format #t "Creating symlink: ~a -> ~a~%" source-base target-path)
(symlink target-path source-base)
'()))

150
stash.scm
View File

@ -35,6 +35,8 @@
(define-module (stash)
#:use-module (ice-9 getopt-long)
#:use-module (ice-9 ftw)
#:use-module (ice-9 rdelim)
#:use-module (stash help)
#:use-module (stash colors)
#:use-module (stash log)
@ -46,28 +48,38 @@
#:use-module (srfi srfi-1)
#: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
(define %options
'((target (value #t) (single-char #\t))
(source (value #t) (single-char #\s))
(recursive (value #f) (single-char #\r))
(interactive (value #f) (single-char #\i))
(help (value #f) (single-char #\h))
(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))))
(version (value #f) (single-char #\v))))
;;; Function to handle dot directory stashing
(define (handle-dot-stash current-dir)
"Handle stashing when using the '.' syntax. Uses parent as stow dir and $HOME as target."
(let* ((pkg-dir (canonicalize-path current-dir))
(define (handle-dot-stash)
"Handle stashing when using the '.' syntax. Uses parent directory as target."
(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))
(home-dir (getenv "HOME")))
(format #t "pkg-dir: ~a~%" pkg-dir)
(format #t "stow-dir: ~a~%" stow-dir)
(format #t "home-dir: ~a~%" home-dir)
(values pkg-dir stow-dir home-dir)))
(target-dir (dirname stow-dir))
(ignore-patterns (read-ignore-patterns pkg-dir)))
(format #t "Package directory: ~a~%" pkg-dir)
(format #t "Stow directory: ~a~%" stow-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
(define (display-version)
@ -90,45 +102,95 @@
(no-folding? (assoc-ref options 'no-folding)))
(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
(define (main args)
"Main function to parse arguments and execute the program."
(setenv "GUILE_AUTO_COMPILE" "0")
(let* ((options (getopt-long args %options))
(source-dir (assoc-ref options 'source))
(target-dir (assoc-ref options 'target))
(non-option-args (option-ref options '() '())))
(help-wanted? (option-ref options 'help #f))
(version-wanted? (option-ref options 'version #f))
(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
((assoc-ref options 'help) (display-help))
((assoc-ref options 'version) (display-version))
;; Handle "stash ." syntax
((and (= (length non-option-args) 1)
(string=? (car non-option-args) "."))
(call-with-values
(lambda () (handle-dot-stash (getcwd)))
(lambda (pkg-dir stow-dir home-dir)
(let ((package (make-package
(basename pkg-dir)
pkg-dir
home-dir
(read-ignore-patterns pkg-dir))))
(handle-stow package options)))))
;; Handle traditional --source --target syntax
((not (and target-dir source-dir))
(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))
(help-wanted? (display-help) (exit 0))
(version-wanted? (display-version) (exit 0))
;; Handle dot syntax
((and (= (length (option-ref options '() '())) 1)
(string=? (car (option-ref options '() '())) "."))
(handle-dot-stash))
;; Handle interactive mode
((and source interactive?)
(let ((target-path (prompt-for-target (canonicalize-path source))))
(handle-explicit-stash source target-path recursive?)))
;; Handle explicit paths with optional recursion
((and source target)
(handle-explicit-stash source target recursive?))
(else
(let* ((expanded-source (expand-home source-dir))
(expanded-target (expand-home target-dir))
(package (make-package
(basename expanded-source)
expanded-source
expanded-target
(read-ignore-patterns expanded-source))))
(handle-stow package options))))))
(display-help)
(exit 1)))))
(define (handle-explicit-stash source target recursive?)
"Handle stashing with explicit source and target paths."
(let* ((source-path (canonicalize-path source))
(target-path (canonicalize-path target))
(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
(main (command-line))