diff --git a/Justfile b/Justfile index b6fb2ee..9b8a8a2 100644 --- a/Justfile +++ b/Justfile @@ -111,6 +111,21 @@ release: dist-macos dist-linux @ls -lh dist/ +# Run all tests (unit + integration) +test: build test-unit test-integration + +# Run unit tests only +test-unit: + go test -v ./... + +# Run integration tests only +test-integration: build + ./spec/tests/runner.sh ./try + +# Run tests with race detection +test-race: + go test -race -v ./... + # Format code fmt: go fmt ./... diff --git a/main.go b/main.go index c8d1ac4..2ecd550 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "encoding/json" "fmt" "io/fs" @@ -132,14 +133,27 @@ type model struct { quitting bool inputMode bool newName string - confirmDelete bool - deleteTarget *tryEntry + // Delete mode (batch delete) + deleteMode bool // Whether we're in delete mode (marking items) + markedForDeletion []string // Paths marked for deletion + confirmDelete bool // Whether we're in the YES confirmation dialog + deleteConfirmBuffer string // Buffer for typing "YES" + // Rename mode + renameMode bool + renameTarget *tryEntry + renameName string + renameError string // Validation error message for rename, empty if valid + // Text editing cursor position (shared across input modes) + cursorPos int + // Delete error message to show user + deleteError string } type selection struct { Type string Path string CloneURL string // For clone operations + OldPath string // For rename operations } var ( @@ -192,6 +206,17 @@ var ( warningStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("214")) + + // Style for items marked for deletion (red background) + markedStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("52")). + Foreground(lipgloss.Color("196")) + + // Style for delete mode footer + deleteModeStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("52")). + Foreground(lipgloss.Color("255")). + Bold(true) ) func getConfigPath() string { @@ -353,9 +378,9 @@ func getResolvedConfig() (*Config, error) { config.Path = tryPath } - if tryShell := os.Getenv("TRY_SHELL"); tryShell != "" { - config.Shell = tryShell - } + // Note: TRY_SHELL is handled directly in detectUserShell() + // to allow shell names like "bash" without requiring absolute paths. + // Config.Shell (from config file) requires absolute path validation. // Validate and sanitize the final config if err := config.Validate(); err != nil { @@ -642,6 +667,120 @@ func isAlphaNum(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') } +// Text editing helper functions for cursor-aware editing + +// insertCharAt inserts a character at the cursor position and returns the new string and cursor +func insertCharAt(text string, pos int, char rune) (string, int) { + runes := []rune(text) + if pos < 0 { + pos = 0 + } + if pos > len(runes) { + pos = len(runes) + } + newRunes := make([]rune, len(runes)+1) + copy(newRunes[:pos], runes[:pos]) + newRunes[pos] = char + copy(newRunes[pos+1:], runes[pos:]) + return string(newRunes), pos + 1 +} + +// insertStringAt inserts a string at the cursor position and returns the new string and cursor +func insertStringAt(text string, pos int, insert string) (string, int) { + runes := []rune(text) + insertRunes := []rune(insert) + if pos < 0 { + pos = 0 + } + if pos > len(runes) { + pos = len(runes) + } + newRunes := make([]rune, len(runes)+len(insertRunes)) + copy(newRunes[:pos], runes[:pos]) + copy(newRunes[pos:], insertRunes) + copy(newRunes[pos+len(insertRunes):], runes[pos:]) + return string(newRunes), pos + len(insertRunes) +} + +// deleteCharBackward deletes the character before the cursor and returns the new string and cursor +func deleteCharBackward(text string, pos int) (string, int) { + runes := []rune(text) + if pos <= 0 || pos > len(runes) { + return text, pos + } + newRunes := make([]rune, len(runes)-1) + copy(newRunes[:pos-1], runes[:pos-1]) + copy(newRunes[pos-1:], runes[pos:]) + return string(newRunes), pos - 1 +} + +// deleteToEnd deletes from cursor to end of line and returns the new string +func deleteToEnd(text string, pos int) string { + runes := []rune(text) + if pos < 0 { + pos = 0 + } + if pos >= len(runes) { + return text + } + return string(runes[:pos]) +} + +// deleteWordBackward deletes the word before the cursor and returns the new string and cursor +func deleteWordBackward(text string, pos int) (string, int) { + runes := []rune(text) + if pos <= 0 || pos > len(runes) { + return text, pos + } + + // Skip any trailing spaces + newPos := pos + for newPos > 0 && runes[newPos-1] == ' ' { + newPos-- + } + + // Delete until next space or beginning + for newPos > 0 && runes[newPos-1] != ' ' { + newPos-- + } + + newRunes := make([]rune, len(runes)-(pos-newPos)) + copy(newRunes[:newPos], runes[:newPos]) + copy(newRunes[newPos:], runes[pos:]) + return string(newRunes), newPos +} + +// runeLen returns the number of runes in a string +func runeLen(s string) int { + return len([]rune(s)) +} + +// renderTextWithCursor renders text with a block cursor at the given position +func renderTextWithCursor(text string, pos int, textStyle, cursorStyle lipgloss.Style) string { + runes := []rune(text) + if pos < 0 { + pos = 0 + } + if pos > len(runes) { + pos = len(runes) + } + + var result strings.Builder + for i, r := range runes { + if i == pos { + // Render cursor character with cursor style + result.WriteString(cursorStyle.Render(string(r))) + } else { + result.WriteString(textStyle.Render(string(r))) + } + } + // If cursor is at end, show a block cursor + if pos == len(runes) { + result.WriteString(cursorStyle.Render(" ")) + } + return result.String() +} + // isValidSearchInput checks if the input string contains only valid characters for search func isValidSearchInput(input string) bool { for _, char := range input { @@ -655,33 +794,77 @@ func isValidSearchInput(input string) bool { return len(input) > 0 } -// Pre-compiled GitHub URL patterns -var githubPatterns = []struct { +// Pre-compiled git URL patterns for various hosting services +// Git URL patterns for various hosting services +var gitURLPatterns = []struct { regex *regexp.Regexp - format string + format string // $1=host or user, $2=path or repo (depends on pattern) }{ + // GitHub patterns {regexp.MustCompile(`^https?://github\.com/([\w-]+)/([\w\.-]+?)(?:\.git)?/?$`), "https://github.com/$1/$2.git"}, {regexp.MustCompile(`^github\.com/([\w-]+)/([\w\.-]+?)(?:\.git)?/?$`), "https://github.com/$1/$2.git"}, {regexp.MustCompile(`^git@github\.com:([\w-]+)/([\w\.-]+?)(?:\.git)?$`), "https://github.com/$1/$2.git"}, {regexp.MustCompile(`^gh:([\w-]+)/([\w\.-]+?)$`), "https://github.com/$1/$2.git"}, + + // GitLab patterns + {regexp.MustCompile(`^https?://gitlab\.com/([\w-]+(?:/[\w-]+)*)/([\w\.-]+?)(?:\.git)?/?$`), "https://gitlab.com/$1/$2.git"}, + {regexp.MustCompile(`^gitlab\.com/([\w-]+(?:/[\w-]+)*)/([\w\.-]+?)(?:\.git)?/?$`), "https://gitlab.com/$1/$2.git"}, + {regexp.MustCompile(`^git@gitlab\.com:([\w-]+(?:/[\w-]+)*)/([\w\.-]+?)(?:\.git)?$`), "https://gitlab.com/$1/$2.git"}, + {regexp.MustCompile(`^gl:([\w-]+(?:/[\w-]+)*)/([\w\.-]+?)$`), "https://gitlab.com/$1/$2.git"}, + + // Bitbucket patterns + {regexp.MustCompile(`^https?://bitbucket\.org/([\w-]+)/([\w\.-]+?)(?:\.git)?/?$`), "https://bitbucket.org/$1/$2.git"}, + {regexp.MustCompile(`^bitbucket\.org/([\w-]+)/([\w\.-]+?)(?:\.git)?/?$`), "https://bitbucket.org/$1/$2.git"}, + {regexp.MustCompile(`^git@bitbucket\.org:([\w-]+)/([\w\.-]+?)(?:\.git)?$`), "https://bitbucket.org/$1/$2.git"}, + {regexp.MustCompile(`^bb:([\w-]+)/([\w\.-]+?)$`), "https://bitbucket.org/$1/$2.git"}, } -// isGitHubURL checks if the text is a GitHub URL and returns normalized clone URL -func isGitHubURL(text string) (bool, string) { +// Generic git URL patterns (for any host) +var genericGitPatterns = []struct { + regex *regexp.Regexp +}{ + // https://host.com/path/to/repo.git + {regexp.MustCompile(`^https?://[\w\.-]+/[\w\.-/]+\.git$`)}, + // git@host.com:path/to/repo.git + {regexp.MustCompile(`^git@[\w\.-]+:[\w\.-/]+\.git$`)}, + // ssh://git@host.com/path/to/repo.git + {regexp.MustCompile(`^ssh://git@[\w\.-]+/[\w\.-/]+\.git$`)}, +} + +// isGitURL checks if the text is a git URL and returns normalized clone URL +// Supports GitHub, GitLab, Bitbucket, and generic git URLs +func isGitURL(text string) (bool, string) { text = strings.TrimSpace(text) - for _, p := range githubPatterns { + // Check known hosting services first (for normalization) + for _, p := range gitURLPatterns { if matches := p.regex.FindStringSubmatch(text); matches != nil { - user := matches[1] - repo := matches[2] - return true, fmt.Sprintf("https://github.com/%s/%s.git", user, repo) + // Use the format string to build normalized URL + result := p.format + for i := 1; i < len(matches); i++ { + result = strings.Replace(result, fmt.Sprintf("$%d", i), matches[i], 1) + } + return true, result + } + } + + // Check generic git URL patterns (return as-is, already valid git URLs) + for _, p := range genericGitPatterns { + if p.regex.MatchString(text) { + return true, text } } return false, "" } -// extractRepoName extracts the repository name from a GitHub URL +// isGitHubURL is kept for backwards compatibility in tests +// Deprecated: use isGitURL instead +func isGitHubURL(text string) (bool, string) { + return isGitURL(text) +} + +// extractRepoName extracts the repository name from a git URL func extractRepoName(url string) string { // Remove .git suffix url = strings.TrimSuffix(url, ".git") @@ -713,33 +896,26 @@ func cloneRepository(url, targetPath string) error { // Create the target directory if err := os.MkdirAll(targetPath, 0755); err != nil { - return fmt.Errorf("failed to create directory: %v", err) + return fmt.Errorf("failed to create directory: %w", err) } - // Clone the repository with timeout - cmd := exec.Command("git", "clone", "--depth", "1", url, targetPath) + // Clone the repository with context-based timeout + ctx, cancel := context.WithTimeout(context.Background(), cloneTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", url, targetPath) cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout - // Set a 2-minute timeout for clone operation - done := make(chan error, 1) - go func() { - done <- cmd.Run() - }() - - select { - case err := <-done: - if err != nil { - // If clone failed, remove the directory - os.RemoveAll(targetPath) - return fmt.Errorf("failed to clone repository: %v", err) - } - return nil - case <-time.After(2 * time.Minute): - cmd.Process.Kill() + if err := cmd.Run(); err != nil { + // Clean up on failure os.RemoveAll(targetPath) - return fmt.Errorf("clone operation timed out after 2 minutes") + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("clone operation timed out after %v", cloneTimeout) + } + return fmt.Errorf("failed to clone repository: %w", err) } + return nil } // performClone handles the common clone operation logic @@ -772,6 +948,409 @@ func performClone(cloneURL, basePath string) (string, error) { return fullPath, nil } +// Git worktree support functions + +// isGitRepository checks if the given path is inside a git repository +func isGitRepository(path string) (string, bool) { + // Walk up the directory tree looking for .git + absPath, err := filepath.Abs(path) + if err != nil { + return "", false + } + + for { + gitPath := filepath.Join(absPath, ".git") + if info, err := os.Stat(gitPath); err == nil { + // .git can be a directory (normal repo) or a file (worktree) + if info.IsDir() || info.Mode().IsRegular() { + return absPath, true + } + } + + parent := filepath.Dir(absPath) + if parent == absPath { + // Reached root + return "", false + } + absPath = parent + } +} + +// branchSanitizeRegex matches characters that are not safe for filesystem/shell usage +var branchSanitizeRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +// sanitizeBranchName converts branch name to filesystem-safe format +// Replaces unsafe characters (spaces, /, \, :, etc.) with dashes +func sanitizeBranchName(branch string) string { + // Replace path separators and double dots first + sanitized := strings.ReplaceAll(branch, "/", "-") + sanitized = strings.ReplaceAll(sanitized, "\\", "-") + sanitized = strings.ReplaceAll(sanitized, "..", "-") + // Replace any remaining unsafe characters (spaces, colons, etc.) + sanitized = branchSanitizeRegex.ReplaceAllString(sanitized, "-") + // Collapse multiple consecutive dashes into one + for strings.Contains(sanitized, "--") { + sanitized = strings.ReplaceAll(sanitized, "--", "-") + } + // Trim leading/trailing dashes + sanitized = strings.Trim(sanitized, "-") + return sanitized +} + +// Timeout constants for external operations +const ( + worktreeTimeout = 2 * time.Minute + cloneTimeout = 2 * time.Minute +) + +// createWorktree creates a git worktree at the target path +func createWorktree(repoPath, targetPath, branch string) error { + // Check if git is available + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is not installed") + } + + // Create worktree with detached HEAD (same as GT) using context for timeout + ctx, cancel := context.WithTimeout(context.Background(), worktreeTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "worktree", "add", "--detach", targetPath, branch) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("worktree operation timed out after %v", worktreeTimeout) + } + return fmt.Errorf("failed to create worktree: %w", err) + } + return nil +} + +// ensureGitignore adds .worktrees/ to .gitignore if not already present +func ensureGitignore(repoPath string) error { + gitignorePath := filepath.Join(repoPath, ".gitignore") + entry := ".worktrees/" + + // Read existing .gitignore + content, err := os.ReadFile(gitignorePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read .gitignore: %w", err) + } + + // Check if entry already exists + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.TrimSpace(line) == entry { + return nil // Already present + } + } + + // Append entry + var newContent string + if len(content) > 0 && !strings.HasSuffix(string(content), "\n") { + newContent = string(content) + "\n" + entry + "\n" + } else { + newContent = string(content) + entry + "\n" + } + + if err := os.WriteFile(gitignorePath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write .gitignore: %w", err) + } + + fmt.Printf("πŸ“ Added %s to .gitignore\n", entry) + return nil +} + +// performWorktree handles the worktree creation logic +// If inRepo is true, creates in .worktrees/ inside the repo (GT-style) +// Otherwise, creates in basePath with date prefix (Ruby try style) +func performWorktree(repoPath, branch, basePath string, inRepo bool) (string, error) { + sanitizedBranch := sanitizeBranchName(branch) + + var targetPath string + var dirName string + + if inRepo { + // GT-style: .worktrees/branch-name inside the repo + worktreesDir := filepath.Join(repoPath, ".worktrees") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + return "", fmt.Errorf("failed to create .worktrees directory: %w", err) + } + + // Add to .gitignore + if err := ensureGitignore(repoPath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + + dirName = sanitizedBranch + targetPath = filepath.Join(worktreesDir, sanitizedBranch) + } else { + // Ruby try style: date-prefix in TRY_PATH + datePrefix := time.Now().Format("2006-01-02") + dirName = fmt.Sprintf("%s-%s", datePrefix, sanitizedBranch) + targetPath = filepath.Join(basePath, dirName) + } + + // Check if target already exists + if _, err := os.Stat(targetPath); err == nil { + if inRepo { + return "", fmt.Errorf("worktree already exists: %s", targetPath) + } + // Add a number suffix for non-inRepo mode + for i := 2; ; i++ { + testPath := fmt.Sprintf("%s-%d", targetPath, i) + if _, err := os.Stat(testPath); os.IsNotExist(err) { + targetPath = testPath + dirName = fmt.Sprintf("%s-%d", dirName, i) + break + } + } + } + + // Create the worktree + fmt.Printf("🌳 Creating worktree for %s in %s...\n", branch, dirName) + if err := createWorktree(repoPath, targetPath, branch); err != nil { + return "", err + } + + return targetPath, nil +} + +// handleDirectWorktree handles the CLI worktree command +func handleDirectWorktree(repoArg, branch string, inRepo bool, config *Config) { + var repoPath string + var basePath string + + // Determine the repository path + if repoArg == "." || repoArg == "" { + // Use current directory + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: couldn't get current directory: %v\n", err) + os.Exit(1) + } + foundRepo, isRepo := isGitRepository(cwd) + if !isRepo { + fmt.Fprintf(os.Stderr, "Error: not inside a git repository\n") + os.Exit(1) + } + repoPath = foundRepo + } else { + // Use specified path + absPath, err := filepath.Abs(repoArg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid path: %v\n", err) + os.Exit(1) + } + foundRepo, isRepo := isGitRepository(absPath) + if !isRepo { + fmt.Fprintf(os.Stderr, "Error: %s is not a git repository\n", repoArg) + os.Exit(1) + } + repoPath = foundRepo + } + + // Get base path for non-inRepo mode + if !inRepo { + basePath = getDefaultPath(config) + if basePath == "" { + basePath = promptForPath() + var err error + config, err = getResolvedConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to reload config: %v\n", err) + os.Exit(1) + } + } + } + + // Perform the worktree creation + targetPath, err := performWorktree(repoPath, branch, basePath, inRepo) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Change to the worktree directory + if err := os.Chdir(targetPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: couldn't change directory: %v\n", err) + os.Exit(1) + } + + // Launch a new shell + shell := getShell(config) + + fmt.Printf("\n🌳 Worktree created and entering %s\n\n", filepath.Base(targetPath)) + + cmd := exec.Command(shell) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = targetPath + + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error launching shell: %v\n", err) + os.Exit(1) + } +} + +// Shell init functions + +// detectUserShell determines the user's shell for wrapper generation +// Priority: TRY_SHELL env > config file > $SHELL > default +func detectUserShell(config *Config) string { + // Check TRY_SHELL environment variable + if tryShell := os.Getenv("TRY_SHELL"); tryShell != "" { + return filepath.Base(tryShell) + } + + // Check config file + if config != nil && config.Shell != "" { + return filepath.Base(config.Shell) + } + + // Check $SHELL environment variable + if shell := os.Getenv("SHELL"); shell != "" { + return filepath.Base(shell) + } + + // Default to bash + return "bash" +} + +// validateRenameName checks if a rename target is valid and returns an error message +func validateRenameName(name, originalName, basePath string) string { + if name == "" { + return "Name cannot be empty" + } + if name == originalName { + return "" // No change is valid (will just exit rename mode) + } + // Block null bytes (could truncate path in C-based syscalls) + if strings.Contains(name, "\x00") { + return "Invalid name" + } + // Block absolute paths + if filepath.IsAbs(name) { + return "Name cannot be an absolute path" + } + // Block path separators + if strings.Contains(name, "/") || strings.Contains(name, "\\") { + return "Name cannot contain / or \\" + } + // Block special directory names + if name == "." || name == ".." { + return "Invalid name" + } + // Check if cleaned path would escape (handles edge cases) + cleaned := filepath.Clean(name) + if cleaned != name || strings.HasPrefix(cleaned, "..") { + return "Invalid name" + } + // Check if target already exists + newPath := filepath.Join(basePath, name) + if _, err := os.Stat(newPath); err == nil { + return "A directory with this name already exists" + } else if !os.IsNotExist(err) { + // Permission error or other issue - report it + return fmt.Sprintf("Cannot validate name: %v", err) + } + return "" +} + +// shellEscape escapes a string for safe use in shell scripts using single quotes. +// Single quotes prevent all shell expansion ($, `, etc.) - only embedded single +// quotes need escaping via the '\'' pattern (end quote, literal quote, start quote). +func shellEscape(s string) string { + // Single-quote the entire string, escaping any embedded single quotes + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// generateBashZshWrapper generates a shell wrapper for bash or zsh +func generateBashZshWrapper(tryPath string) string { + escapedPath := shellEscape(tryPath) + return fmt.Sprintf(`# try shell wrapper - add this to your .bashrc or .zshrc +try() { + local dir + dir=$(%s --select-only "$@") + if [[ $? -eq 0 && -n "$dir" ]]; then + cd "$dir" || return 1 + fi +} +`, escapedPath) +} + +// generateFishWrapper generates a shell wrapper for fish +func generateFishWrapper(tryPath string) string { + escapedPath := shellEscape(tryPath) + return fmt.Sprintf(`# try shell wrapper - add this to your config.fish +function try + set -l dir (%s --select-only $argv) + if test $status -eq 0 -a -n "$dir" + cd $dir + end +end +`, escapedPath) +} + +// handleInitCommand handles the 'try init' command +func handleInitCommand(customPath string, config *Config) { + // Determine the path to the try binary + var tryPath string + if customPath != "" { + absPath, err := filepath.Abs(customPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid path: %v\n", err) + os.Exit(1) + } + tryPath = absPath + } else { + // Try to find the current executable + execPath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: couldn't determine executable path: %v\n", err) + fmt.Fprintln(os.Stderr, "Please specify the path: try init /path/to/try") + os.Exit(1) + } + tryPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + tryPath = execPath + } + } + + // Detect shell + shell := detectUserShell(config) + + fmt.Printf("🐚 Detected shell: %s\n\n", shell) + + var wrapper string + var configFile string + + switch shell { + case "fish": + wrapper = generateFishWrapper(tryPath) + home, _ := os.UserHomeDir() + configFile = filepath.Join(home, ".config", "fish", "config.fish") + case "zsh": + wrapper = generateBashZshWrapper(tryPath) + home, _ := os.UserHomeDir() + configFile = filepath.Join(home, ".zshrc") + default: // bash and others + wrapper = generateBashZshWrapper(tryPath) + home, _ := os.UserHomeDir() + configFile = filepath.Join(home, ".bashrc") + } + + fmt.Println("Add the following to your shell configuration:") + fmt.Println() + fmt.Println(wrapper) + fmt.Printf("Suggested config file: %s\n\n", dimStyle.Render(configFile)) + fmt.Println("After adding the wrapper, restart your shell or run:") + fmt.Printf(" source %s\n\n", configFile) + fmt.Println("Then you can use 'try' to change directories instead of spawning a subshell.") +} + func (m model) Init() tea.Cmd { return tea.EnterAltScreen } @@ -798,6 +1377,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c", "esc": m.inputMode = false m.newName = "" + m.cursorPos = 0 case "enter": if m.newName != "" { @@ -813,46 +1393,171 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "backspace": - if len(m.newName) > 0 { - m.newName = m.newName[:len(m.newName)-1] + m.newName, m.cursorPos = deleteCharBackward(m.newName, m.cursorPos) + + // Text editing shortcuts + case "ctrl+a": + m.cursorPos = 0 + case "ctrl+e": + m.cursorPos = runeLen(m.newName) + case "ctrl+b", "left": + if m.cursorPos > 0 { + m.cursorPos-- + } + case "ctrl+f", "right": + if m.cursorPos < runeLen(m.newName) { + m.cursorPos++ + } + case "ctrl+k": + m.newName = deleteToEnd(m.newName, m.cursorPos) + case "ctrl+w": + m.newName, m.cursorPos = deleteWordBackward(m.newName, m.cursorPos) + + default: + // Handle character input (including paste) + switch msg.Type { + case tea.KeyRunes: + for _, r := range msg.Runes { + m.newName, m.cursorPos = insertCharAt(m.newName, m.cursorPos, r) + } + } + } + return m, nil + } + + // Handle delete confirmation mode - requires typing "YES" for batch delete + if m.confirmDelete && len(m.markedForDeletion) > 0 { + switch msg.String() { + case "ctrl+c", "esc": + // Cancel confirmation but stay in delete mode with marks + m.confirmDelete = false + m.deleteConfirmBuffer = "" + m.deleteError = "" + + case "backspace": + if len(m.deleteConfirmBuffer) > 0 { + m.deleteConfirmBuffer = m.deleteConfirmBuffer[:len(m.deleteConfirmBuffer)-1] } + m.deleteError = "" // Clear error on backspace (user is retrying) default: // Handle character input - if len(msg.String()) == 1 { - m.newName += msg.String() + if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { + char := string(msg.Runes[0]) + expectedChars := "YES" + bufLen := len(m.deleteConfirmBuffer) + + // Check if the character matches the expected next character + if bufLen < 3 && char == string(expectedChars[bufLen]) { + m.deleteConfirmBuffer += char + m.deleteError = "" // Clear any previous error + + // Check if confirmation is complete + if m.deleteConfirmBuffer == "YES" { + // Perform batch deletion + var errors []string + for _, path := range m.markedForDeletion { + if err := os.RemoveAll(path); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", filepath.Base(path), err)) + } + } + + if len(errors) > 0 { + m.deleteError = fmt.Sprintf("Failed to delete: %s", strings.Join(errors, "; ")) + m.deleteConfirmBuffer = "" + return m, nil + } + + // Reload directories and reset state + m.loadTries() + m.filterTries() + m.clearDeleteMode() + // Adjust cursor if it's out of bounds + if m.cursor >= len(m.filteredTries) { + m.cursor = len(m.filteredTries) - 1 + if m.cursor < 0 { + m.cursor = 0 + } + } + } + } + // Wrong character - just ignore it, stay in confirmation dialog } + // Non-character key (other than esc/backspace) - just ignore it } return m, nil } - // Handle delete confirmation mode - if m.confirmDelete && m.deleteTarget != nil { + // Handle rename mode + if m.renameMode && m.renameTarget != nil { switch msg.String() { - case "y", "Y": - // Perform deletion - if err := os.RemoveAll(m.deleteTarget.Path); err != nil { - // Could add error handling here, but for now just reset - m.confirmDelete = false - m.deleteTarget = nil - return m, nil - } - // Reload directories and reset state - m.loadTries() - m.filterTries() - m.confirmDelete = false - m.deleteTarget = nil - // Adjust cursor if it's out of bounds - if m.cursor >= len(m.filteredTries) { - m.cursor = len(m.filteredTries) - 1 - if m.cursor < 0 { - m.cursor = 0 + case "ctrl+c", "esc": + m.renameMode = false + m.renameTarget = nil + m.renameName = "" + m.renameError = "" + m.cursorPos = 0 + + case "enter": + // Validate and perform rename + m.renameError = validateRenameName(m.renameName, m.renameTarget.Basename, m.basePath) + if m.renameError == "" && m.renameName != "" && m.renameName != m.renameTarget.Basename { + // Proceed with rename + newPath := filepath.Join(m.basePath, m.renameName) + m.selected = &selection{ + Type: "rename", + Path: newPath, + OldPath: m.renameTarget.Path, } + m.quitting = true + return m, tea.Quit } + // If same name or empty, just exit rename mode + if m.renameName == "" || m.renameName == m.renameTarget.Basename { + m.renameMode = false + m.renameTarget = nil + m.renameName = "" + m.renameError = "" + m.cursorPos = 0 + } + // Otherwise stay in rename mode with error + + case "backspace": + m.renameName, m.cursorPos = deleteCharBackward(m.renameName, m.cursorPos) + m.renameError = validateRenameName(m.renameName, m.renameTarget.Basename, m.basePath) + + // Text editing shortcuts + case "ctrl+a": + m.cursorPos = 0 + case "ctrl+e": + m.cursorPos = runeLen(m.renameName) + case "ctrl+b", "left": + if m.cursorPos > 0 { + m.cursorPos-- + } + case "ctrl+f", "right": + if m.cursorPos < runeLen(m.renameName) { + m.cursorPos++ + } + case "ctrl+k": + m.renameName = deleteToEnd(m.renameName, m.cursorPos) + m.renameError = validateRenameName(m.renameName, m.renameTarget.Basename, m.basePath) + case "ctrl+w": + m.renameName, m.cursorPos = deleteWordBackward(m.renameName, m.cursorPos) + m.renameError = validateRenameName(m.renameName, m.renameTarget.Basename, m.basePath) + default: - // Cancel deletion on any other key - m.confirmDelete = false - m.deleteTarget = nil + // Handle character input + switch msg.Type { + case tea.KeyRunes: + for _, r := range msg.Runes { + // Don't allow / or \ in names + if r != '/' && r != '\\' { + m.renameName, m.cursorPos = insertCharAt(m.renameName, m.cursorPos, r) + } + } + m.renameError = validateRenameName(m.renameName, m.renameTarget.Basename, m.basePath) + } } return m, nil } @@ -860,15 +1565,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Normal mode switch msg.String() { case "ctrl+c", "esc": + // If in delete mode, clear marks and exit delete mode + if m.deleteMode { + m.clearDeleteMode() + return m, nil + } m.quitting = true return m, tea.Quit case "ctrl+n": // Quick create new experiment or clone if m.searchTerm != "" { - // Check if it's a GitHub URL - isGH, cloneURL := isGitHubURL(m.searchTerm) - if isGH { + // Check if it's a git URL (GitHub, GitLab, Bitbucket, etc.) + isGit, cloneURL := isGitURL(m.searchTerm) + if isGit { // Clone repository repoName := extractRepoName(cloneURL) datePrefix := time.Now().Format("2006-01-02") @@ -897,17 +1607,69 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Enter input mode for new name m.inputMode = true m.newName = "" + m.cursorPos = 0 + } + + case "ctrl+t": + // Quick create - same as Ctrl+N but more memorable shortcut + if m.searchTerm != "" { + // Check if it's a git URL (GitHub, GitLab, Bitbucket, etc.) + isGit, cloneURL := isGitURL(m.searchTerm) + if isGit { + repoName := extractRepoName(cloneURL) + datePrefix := time.Now().Format("2006-01-02") + finalName := fmt.Sprintf("%s-%s", datePrefix, repoName) + fullPath := filepath.Join(m.basePath, finalName) + m.selected = &selection{ + Type: "clone", + Path: fullPath, + CloneURL: cloneURL, + } + m.quitting = true + return m, tea.Quit + } + // Regular create + datePrefix := time.Now().Format("2006-01-02") + finalName := fmt.Sprintf("%s-%s", datePrefix, strings.ReplaceAll(m.searchTerm, " ", "-")) + fullPath := filepath.Join(m.basePath, finalName) + m.selected = &selection{ + Type: "mkdir", + Path: fullPath, + } + m.quitting = true + return m, tea.Quit } + // Enter input mode for new name + m.inputMode = true + m.newName = "" + m.cursorPos = 0 case "ctrl+d", "delete": - // Delete directory with confirmation + // Toggle mark for deletion (batch delete mode) if m.cursor < len(m.filteredTries) { - m.confirmDelete = true entry := m.filteredTries[m.cursor] - m.deleteTarget = &entry + m.toggleMarkForDeletion(entry.Path) + } + + case "ctrl+r": + // Rename directory + if m.cursor < len(m.filteredTries) { + entry := m.filteredTries[m.cursor] + m.renameMode = true + m.renameTarget = &entry + m.renameName = entry.Basename + m.renameError = "" // Initial name is valid (no change) + m.cursorPos = runeLen(entry.Basename) // Cursor at end } case "enter": + // If in delete mode with marked items, trigger confirmation + if m.deleteMode && len(m.markedForDeletion) > 0 { + m.confirmDelete = true + m.deleteConfirmBuffer = "" + return m, nil + } + if m.cursor < len(m.filteredTries) { // Select existing directory m.selected = &selection{ @@ -919,9 +1681,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.cursor == len(m.filteredTries) { // Create new directory or clone repository if m.searchTerm != "" { - // Check if it's a GitHub URL - isGH, cloneURL := isGitHubURL(m.searchTerm) - if isGH { + // Check if it's a git URL (GitHub, GitLab, Bitbucket, etc.) + isGit, cloneURL := isGitURL(m.searchTerm) + if isGit { // Clone repository repoName := extractRepoName(cloneURL) datePrefix := time.Now().Format("2006-01-02") @@ -950,6 +1712,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Enter input mode for new name m.inputMode = true m.newName = "" + m.cursorPos = 0 } } @@ -968,7 +1731,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "backspace": if len(m.searchTerm) > 0 { - m.searchTerm = m.searchTerm[:len(m.searchTerm)-1] + m.searchTerm, m.cursorPos = deleteCharBackward(m.searchTerm, m.cursorPos) m.filterTries() m.cursor = 0 m.scrollOffset = 0 @@ -977,6 +1740,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+u": // Clear search m.searchTerm = "" + m.cursorPos = 0 + m.filterTries() + m.cursor = 0 + m.scrollOffset = 0 + + // Search term text editing shortcuts + case "ctrl+a": + m.cursorPos = 0 + case "ctrl+e": + m.cursorPos = runeLen(m.searchTerm) + case "ctrl+b", "left": + if m.cursorPos > 0 { + m.cursorPos-- + } + case "ctrl+f", "right": + if m.cursorPos < runeLen(m.searchTerm) { + m.cursorPos++ + } + case "ctrl+w": + m.searchTerm, m.cursorPos = deleteWordBackward(m.searchTerm, m.cursorPos) m.filterTries() m.cursor = 0 m.scrollOffset = 0 @@ -988,7 +1771,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // This handles both single chars and pasted content input := string(msg.Runes) if isValidSearchInput(input) { - m.searchTerm += input + m.searchTerm, m.cursorPos = insertStringAt(m.searchTerm, m.cursorPos, input) m.filterTries() m.cursor = 0 m.scrollOffset = 0 @@ -1024,19 +1807,72 @@ func (m model) View() string { b.WriteString(titleStyle.Render("πŸ“ Try - Quick Experiment Directories")) b.WriteString("\n") - // Handle delete confirmation mode - if m.confirmDelete && m.deleteTarget != nil { + // Handle delete confirmation mode (batch delete) + if m.confirmDelete && len(m.markedForDeletion) > 0 { b.WriteString("\n") - b.WriteString(dangerStyle.Render("⚠️ Delete Directory")) + if len(m.markedForDeletion) == 1 { + b.WriteString(dangerStyle.Render("⚠️ Delete Directory")) + } else { + b.WriteString(dangerStyle.Render(fmt.Sprintf("⚠️ Delete %d Directories", len(m.markedForDeletion)))) + } b.WriteString("\n\n") - b.WriteString("Are you sure you want to delete this directory?\n\n") - b.WriteString(warningStyle.Render(" " + m.deleteTarget.Name)) + if len(m.markedForDeletion) == 1 { + b.WriteString("Are you sure you want to delete this directory?\n\n") + } else { + b.WriteString("Are you sure you want to delete these directories?\n\n") + } + // List all marked items + for _, path := range m.markedForDeletion { + b.WriteString(warningStyle.Render(" πŸ—‘οΈ " + filepath.Base(path))) + b.WriteString("\n") + } b.WriteString("\n") - b.WriteString(dimStyle.Render(" " + m.deleteTarget.Path)) - b.WriteString("\n\n") b.WriteString(dangerStyle.Render("This action cannot be undone!")) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("Press 'y' to confirm, any other key to cancel")) + // Show error if delete failed + if m.deleteError != "" { + b.WriteString(dangerStyle.Render("❌ " + m.deleteError)) + b.WriteString("\n\n") + } + // Show YES confirmation with typed progress + b.WriteString("Type ") + b.WriteString(dangerStyle.Render("YES")) + b.WriteString(" to confirm: ") + for i, char := range "YES" { + if i < len(m.deleteConfirmBuffer) { + b.WriteString(createNewStyle.Render(string(char))) + } else if i == len(m.deleteConfirmBuffer) { + b.WriteString(cursorStyle.Render(string(char))) + } else { + b.WriteString(dimStyle.Render(string(char))) + } + } + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("ESC: Cancel")) + return b.String() + } + + // Handle rename mode + if m.renameMode && m.renameTarget != nil { + b.WriteString("\n") + b.WriteString(promptStyle.Render("Rename directory:")) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render("From: ")) + b.WriteString(warningStyle.Render(m.renameTarget.Basename)) + b.WriteString("\n") + b.WriteString(dimStyle.Render("To: ")) + b.WriteString(renderTextWithCursor(m.renameName, m.cursorPos, searchInputStyle, cursorStyle)) + b.WriteString("\n") + // Show validation messages (using cached renameError from Update) + if m.renameName == m.renameTarget.Basename { + b.WriteString("\n") + b.WriteString(dimStyle.Render("(no change)")) + } else if m.renameError != "" { + b.WriteString("\n") + b.WriteString(dangerStyle.Render(m.renameError)) + } + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("Enter: Rename Ctrl-A/E: Start/End Ctrl-K: Delete to end ESC: Cancel")) return b.String() } @@ -1047,20 +1883,22 @@ func (m model) View() string { b.WriteString("\n") datePrefix := time.Now().Format("2006-01-02") b.WriteString(dimStyle.Render(datePrefix + "-")) - b.WriteString(searchInputStyle.Render(m.newName)) + b.WriteString(renderTextWithCursor(m.newName, m.cursorPos, searchInputStyle, cursorStyle)) b.WriteString("\n\n") - b.WriteString(helpStyle.Render("Enter: Create ESC: Cancel")) + b.WriteString(helpStyle.Render("Enter: Create Ctrl-A/E: Start/End Ctrl-K: Delete to end ESC: Cancel")) return b.String() } b.WriteString(m.separatorLine()) b.WriteString("\n") - // Search input + // Search input with cursor b.WriteString(searchStyle.Render("Search: ")) - b.WriteString(searchInputStyle.Render(m.searchTerm)) if m.searchTerm == "" { - b.WriteString(dimStyle.Render(" (type to filter)")) + b.WriteString(cursorStyle.Render(" ")) + b.WriteString(dimStyle.Render("(type to filter)")) + } else { + b.WriteString(renderTextWithCursor(m.searchTerm, m.cursorPos, searchInputStyle, cursorStyle)) } b.WriteString("\n") b.WriteString(m.separatorLine()) @@ -1096,7 +1934,8 @@ func (m model) View() string { // Display entry if idx < len(m.filteredTries) { entry := m.filteredTries[idx] - line := m.formatEntry(entry, isSelected) + isMarked := m.isMarkedForDeletion(entry.Path) + line := m.formatEntry(entry, isSelected, isMarked) b.WriteString(line) } else { // Create new option @@ -1116,11 +1955,23 @@ func (m model) View() string { b.WriteString(m.separatorLine()) b.WriteString("\n") - // Navigation hints - b.WriteString(helpStyle.Render("↑↓/Ctrl+j,k: Navigate Enter: Select Ctrl+N: Quick new Ctrl+D: Delete")) - b.WriteString("\n") - // Action hints - b.WriteString(helpStyle.Render("ESC/q: Quit")) + + // Show different footer based on mode + if m.deleteMode { + // Delete mode footer + b.WriteString(deleteModeStyle.Render(" DELETE MODE ")) + b.WriteString(" ") + b.WriteString(dangerStyle.Render(fmt.Sprintf("%d marked", len(m.markedForDeletion)))) + b.WriteString(" | ") + b.WriteString(helpStyle.Render("Ctrl+D: Toggle Enter: Confirm Esc: Cancel")) + } else { + // Normal footer + // Navigation hints + b.WriteString(helpStyle.Render("↑↓: Navigate Enter: Select Ctrl+N/T: New Ctrl+R: Rename Ctrl+D: Delete")) + b.WriteString("\n") + // Action hints + b.WriteString(helpStyle.Render("Ctrl+A/E/B/F/W: Edit text Ctrl+U: Clear ESC: Quit")) + } return b.String() } @@ -1133,11 +1984,52 @@ func (m model) separatorLine() string { return separatorStyle.Render(strings.Repeat("─", width)) } -func (m model) formatEntry(entry tryEntry, isSelected bool) string { +// isMarkedForDeletion checks if a path is marked for deletion +func (m model) isMarkedForDeletion(path string) bool { + for _, p := range m.markedForDeletion { + if p == path { + return true + } + } + return false +} + +// toggleMarkForDeletion toggles the deletion mark on a path +func (m *model) toggleMarkForDeletion(path string) { + for i, p := range m.markedForDeletion { + if p == path { + // Remove from slice + m.markedForDeletion = append(m.markedForDeletion[:i], m.markedForDeletion[i+1:]...) + // Exit delete mode if no more marks + if len(m.markedForDeletion) == 0 { + m.deleteMode = false + } + return + } + } + // Add to slice + m.markedForDeletion = append(m.markedForDeletion, path) + m.deleteMode = true +} + +// clearDeleteMode clears all marks and exits delete mode +func (m *model) clearDeleteMode() { + m.markedForDeletion = nil + m.deleteMode = false + m.confirmDelete = false + m.deleteConfirmBuffer = "" + m.deleteError = "" +} + +func (m model) formatEntry(entry tryEntry, isSelected bool, isMarked bool) string { var result strings.Builder - // Icon - result.WriteString("πŸ“ ") + // Icon - use different icon for marked items + if isMarked { + result.WriteString("πŸ—‘οΈ ") + } else { + result.WriteString("πŸ“ ") + } // Parse and format the name name := entry.Basename @@ -1149,7 +2041,10 @@ func (m model) formatEntry(entry tryEntry, isSelected bool) string { datePart := strings.Join(parts[:3], "-") namePart := strings.Join(parts[3:], "-") - if isSelected { + if isMarked { + // Marked items get danger style + displayName = markedStyle.Render(datePart + "-" + namePart) + } else if isSelected { displayName = selectedStyle.Render( dateStyle.Render(datePart) + dimStyle.Render("-") + @@ -1161,7 +2056,9 @@ func (m model) formatEntry(entry tryEntry, isSelected bool) string { } } else { // Regular name - if isSelected { + if isMarked { + displayName = markedStyle.Render(name) + } else if isSelected { displayName = selectedStyle.Render(m.highlightMatches(name)) } else { displayName = m.highlightMatches(name) @@ -1193,10 +2090,10 @@ func (m model) formatCreateNew(isSelected bool) string { var displayText string var iconLen int - // Check if search term is a GitHub URL - isGH, cloneURL := isGitHubURL(m.searchTerm) + // Check if search term is a git URL + isGit, cloneURL := isGitURL(m.searchTerm) - if isGH { + if isGit { result.WriteString("πŸ“¦ ") iconLen = 2 repoName := extractRepoName(cloneURL) @@ -1273,10 +2170,11 @@ func (m model) formatRelativeTime(t time.Time) string { } func handleDirectClone(url string, config *Config) { - // Validate it's a GitHub URL - isGH, cloneURL := isGitHubURL(url) - if !isGH { - fmt.Fprintf(os.Stderr, "Error: Not a valid GitHub URL: %s\n", url) + // Validate it's a git URL + isGit, cloneURL := isGitURL(url) + if !isGit { + fmt.Fprintf(os.Stderr, "Error: Not a valid git URL: %s\n", url) + fmt.Fprintf(os.Stderr, "Supported formats: https://host/user/repo.git, git@host:user/repo.git\n") os.Exit(1) } @@ -1330,29 +2228,82 @@ func main() { showVersion := false cloneURL := "" selectOnly := false + // Worktree mode + worktreeMode := false + worktreeRepo := "" + worktreeBranch := "" + worktreeInRepo := false + // Init command + initMode := false + initPath := "" args := os.Args[1:] - for i := 0; i < len(args); i++ { - arg := args[i] - switch arg { - case "--help", "-h", "help": - showHelp = true - case "--version", "-v": - showVersion = true - case "--select-only", "-s": - selectOnly = true - case "--clone", "-c": - // Get the next argument as the URL - if i+1 < len(args) { - cloneURL = args[i+1] - i++ // Skip the URL argument + + // Check for worktree patterns first + // try worktree + // try worktree --in-repo + // try . + // try . --in-repo + // try ./path + if len(args) >= 2 { + firstArg := args[0] + if firstArg == "worktree" || firstArg == "." || strings.HasPrefix(firstArg, "./") || strings.HasPrefix(firstArg, "/") { + worktreeMode = true + if firstArg == "worktree" { + worktreeRepo = "." } else { - fmt.Fprintln(os.Stderr, "Error: --clone requires a URL argument") + worktreeRepo = firstArg + } + + // Parse remaining args + for i := 1; i < len(args); i++ { + arg := args[i] + if arg == "--in-repo" { + worktreeInRepo = true + } else if !strings.HasPrefix(arg, "-") && worktreeBranch == "" { + worktreeBranch = arg + } + } + + if worktreeBranch == "" { + fmt.Fprintln(os.Stderr, "Error: worktree requires a branch name") os.Exit(1) } - default: - if !strings.HasPrefix(arg, "-") { - searchTerm += arg + " " + } + } + + // Check for init command + if len(args) >= 1 && args[0] == "init" { + initMode = true + if len(args) >= 2 && !strings.HasPrefix(args[1], "-") { + initPath = args[1] + } + } + + // Standard argument parsing (if not worktree or init mode) + if !worktreeMode && !initMode { + for i := 0; i < len(args); i++ { + arg := args[i] + switch arg { + case "--help", "-h", "help": + showHelp = true + case "--version", "-v": + showVersion = true + case "--select-only", "-s": + selectOnly = true + case "--clone", "-c": + // Get the next argument as the URL + if i+1 < len(args) { + cloneURL = args[i+1] + i++ // Skip the URL argument + } else { + fmt.Fprintln(os.Stderr, "Error: --clone requires a URL argument") + os.Exit(1) + } + default: + if !strings.HasPrefix(arg, "-") { + searchTerm += arg + " " + } } } } @@ -1375,6 +2326,18 @@ func main() { return } + // Handle init command + if initMode { + handleInitCommand(initPath, config) + return + } + + // Handle worktree operation + if worktreeMode { + handleDirectWorktree(worktreeRepo, worktreeBranch, worktreeInRepo, config) + return + } + // Handle direct clone operation if cloneURL != "" { handleDirectClone(cloneURL, config) @@ -1505,7 +2468,7 @@ func main() { } case "clone": - // Clone GitHub repository + // Clone git repository cloneURL := m.selected.CloneURL // Perform the clone @@ -1538,6 +2501,47 @@ func main() { cmd.Stderr = os.Stderr cmd.Dir = targetPath + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error launching shell: %v\n", err) + os.Exit(1) + } + + case "rename": + // Rename directory + if err := os.Rename(m.selected.OldPath, m.selected.Path); err != nil { + fmt.Fprintf(os.Stderr, "Error renaming directory: %v\n", err) + os.Exit(1) + } + + // Touch the renamed directory + if err := os.Chtimes(m.selected.Path, time.Now(), time.Now()); err != nil { + if !selectOnly { + fmt.Fprintf(os.Stderr, "Warning: couldn't update access time: %v\n", err) + } + } + + if selectOnly { + fmt.Println(m.selected.Path) + os.Exit(0) + } + + // Change to the renamed directory + if err := os.Chdir(m.selected.Path); err != nil { + fmt.Fprintf(os.Stderr, "Error: couldn't change directory: %v\n", err) + os.Exit(1) + } + + // Launch a new shell + shell := getShell(m.config) + + fmt.Printf("\nπŸ“ Renamed to %s\n\n", filepath.Base(m.selected.Path)) + + cmd := exec.Command(shell) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = m.selected.Path + if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error launching shell: %v\n", err) os.Exit(1) @@ -1564,25 +2568,46 @@ Perfect for people with ADHD who need quick, organized workspaces. USAGE: try [search_term] Launch selector with optional search try --select-only, -s Output selected path instead of launching shell - try --clone Clone a GitHub repository + try --clone Clone a git repository try --version, -v Show version information try --help Show this help +GIT WORKTREE: + try worktree Create worktree in TRY_PATH (date-prefixed) + try . Shorthand for worktree (current repo) + try ./path Create worktree from specific repo + try worktree --in-repo Create in .worktrees/ inside repo (GT-style) + try . --in-repo Shorthand with GT-style location + +SHELL INTEGRATION: + try init Generate shell wrapper for cd integration + try init /path/to/try Generate wrapper with custom binary path + FEATURES: β€’ Fuzzy search with smart scoring β€’ Automatic date prefixing (YYYY-MM-DD) β€’ Time-based sorting (recent = higher) - β€’ GitHub repository cloning + β€’ Git repository cloning (GitHub, GitLab, Bitbucket, etc.) + β€’ Git worktree support (GT-compatible) + β€’ Directory renaming NAVIGATION: ↑/↓ Navigate entries Ctrl+j/k Navigate entries (vim-style) Enter Select directory or create new - Ctrl+N Create new experiment (quick) - Ctrl+D Delete selected directory + Ctrl+N/T Create new experiment (quick) + Ctrl+D Toggle mark for deletion (batch delete mode) + Ctrl+R Rename selected directory Backspace Delete search character Ctrl+U Clear search - ESC or q Cancel and exit + ESC Cancel and exit + +TEXT EDITING (in search/input modes): + Ctrl+A Move cursor to beginning + Ctrl+E Move cursor to end + Ctrl+B/← Move cursor backward + Ctrl+F/β†’ Move cursor forward + Ctrl+W Delete word backward CONFIGURATION: Environment variables (override config file): @@ -1597,9 +2622,14 @@ EXAMPLES: try neural # Launch with search for "neural" try new project # Search for "new project" try github.com/user/repo # Shows clone option in TUI + try gitlab.com/user/repo # GitLab clone option try --clone https://github.com/user/repo # Clone directly + try --clone git@gitlab.com:user/repo.git # Clone via SSH try -s # Select and output path cd $(try -s) # Use with cd in current shell + try . feature/my-branch # Create worktree for branch + try . --in-repo main # GT-style worktree in .worktrees/ + try init # Generate shell wrapper First launch automatically creates the base directory. Selected directories open in a new shell session. diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..2a59353 --- /dev/null +++ b/main_test.go @@ -0,0 +1,672 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// ============================================================================ +// Text Editing Helper Tests +// ============================================================================ + +func TestInsertCharAt(t *testing.T) { + tests := []struct { + name string + text string + pos int + char rune + wantText string + wantPos int + }{ + {"insert at start", "hello", 0, 'X', "Xhello", 1}, + {"insert at end", "hello", 5, 'X', "helloX", 6}, + {"insert in middle", "hello", 2, 'X', "heXllo", 3}, + {"insert into empty", "", 0, 'X', "X", 1}, + {"insert unicode", "hello", 2, 'δΈ–', "heδΈ–llo", 3}, + {"negative pos clamps to 0", "hello", -5, 'X', "Xhello", 1}, + {"pos beyond length clamps", "hello", 100, 'X', "helloX", 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotText, gotPos := insertCharAt(tt.text, tt.pos, tt.char) + if gotText != tt.wantText { + t.Errorf("insertCharAt() text = %q, want %q", gotText, tt.wantText) + } + if gotPos != tt.wantPos { + t.Errorf("insertCharAt() pos = %d, want %d", gotPos, tt.wantPos) + } + }) + } +} + +func TestInsertStringAt(t *testing.T) { + tests := []struct { + name string + text string + pos int + insert string + wantText string + wantPos int + }{ + {"insert at start", "world", 0, "hello ", "hello world", 6}, + {"insert at end", "hello", 5, " world", "hello world", 11}, + {"insert in middle", "helo", 2, "l", "hello", 3}, + {"insert empty string", "hello", 2, "", "hello", 2}, + {"insert into empty", "", 0, "hello", "hello", 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotText, gotPos := insertStringAt(tt.text, tt.pos, tt.insert) + if gotText != tt.wantText { + t.Errorf("insertStringAt() text = %q, want %q", gotText, tt.wantText) + } + if gotPos != tt.wantPos { + t.Errorf("insertStringAt() pos = %d, want %d", gotPos, tt.wantPos) + } + }) + } +} + +func TestDeleteCharBackward(t *testing.T) { + tests := []struct { + name string + text string + pos int + wantText string + wantPos int + }{ + {"delete from middle", "hello", 3, "helo", 2}, + {"delete from end", "hello", 5, "hell", 4}, + {"delete at start (no-op)", "hello", 0, "hello", 0}, + {"delete single char", "X", 1, "", 0}, + {"delete unicode", "heδΈ–llo", 3, "hello", 2}, + {"negative pos (no-op)", "hello", -1, "hello", -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotText, gotPos := deleteCharBackward(tt.text, tt.pos) + if gotText != tt.wantText { + t.Errorf("deleteCharBackward() text = %q, want %q", gotText, tt.wantText) + } + if gotPos != tt.wantPos { + t.Errorf("deleteCharBackward() pos = %d, want %d", gotPos, tt.wantPos) + } + }) + } +} + +func TestDeleteToEnd(t *testing.T) { + tests := []struct { + name string + text string + pos int + wantText string + }{ + {"delete from middle", "hello world", 5, "hello"}, + {"delete from start", "hello", 0, ""}, + {"delete at end (no-op)", "hello", 5, "hello"}, + {"delete beyond end (no-op)", "hello", 100, "hello"}, + {"negative pos clamps", "hello", -1, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := deleteToEnd(tt.text, tt.pos) + if got != tt.wantText { + t.Errorf("deleteToEnd() = %q, want %q", got, tt.wantText) + } + }) + } +} + +func TestDeleteWordBackward(t *testing.T) { + tests := []struct { + name string + text string + pos int + wantText string + wantPos int + }{ + {"delete word from end", "hello world", 11, "hello ", 6}, + {"delete word with trailing spaces", "hello ", 7, "", 0}, + {"delete from middle of word", "hello world", 8, "hello rld", 6}, + {"delete at start (no-op)", "hello", 0, "hello", 0}, + {"delete single word", "hello", 5, "", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotText, gotPos := deleteWordBackward(tt.text, tt.pos) + if gotText != tt.wantText { + t.Errorf("deleteWordBackward() text = %q, want %q", gotText, tt.wantText) + } + if gotPos != tt.wantPos { + t.Errorf("deleteWordBackward() pos = %d, want %d", gotPos, tt.wantPos) + } + }) + } +} + +func TestRuneLen(t *testing.T) { + tests := []struct { + name string + text string + want int + }{ + {"empty", "", 0}, + {"ascii", "hello", 5}, + {"unicode", "helloδΈ–η•Œ", 7}, + {"emoji", "helloπŸ‘‹", 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := runeLen(tt.text); got != tt.want { + t.Errorf("runeLen() = %d, want %d", got, tt.want) + } + }) + } +} + +// ============================================================================ +// Git URL Parsing Tests +// ============================================================================ + +func TestIsGitURL(t *testing.T) { + tests := []struct { + name string + input string + wantIsGit bool + wantURL string + }{ + // GitHub patterns + {"github https url", "https://github.com/user/repo", true, "https://github.com/user/repo.git"}, + {"github https url with .git", "https://github.com/user/repo.git", true, "https://github.com/user/repo.git"}, + {"github http url", "http://github.com/user/repo", true, "https://github.com/user/repo.git"}, + {"github.com without protocol", "github.com/user/repo", true, "https://github.com/user/repo.git"}, + {"github git@ ssh url", "git@github.com:user/repo.git", true, "https://github.com/user/repo.git"}, + {"gh: shorthand", "gh:user/repo", true, "https://github.com/user/repo.git"}, + {"github trailing slash", "https://github.com/user/repo/", true, "https://github.com/user/repo.git"}, + + // GitLab patterns + {"gitlab https url", "https://gitlab.com/user/repo", true, "https://gitlab.com/user/repo.git"}, + {"gitlab.com without protocol", "gitlab.com/user/repo", true, "https://gitlab.com/user/repo.git"}, + {"gitlab git@ ssh url", "git@gitlab.com:user/repo.git", true, "https://gitlab.com/user/repo.git"}, + {"gl: shorthand", "gl:user/repo", true, "https://gitlab.com/user/repo.git"}, + {"gitlab nested group", "https://gitlab.com/group/subgroup/repo", true, "https://gitlab.com/group/subgroup/repo.git"}, + + // Bitbucket patterns + {"bitbucket https url", "https://bitbucket.org/user/repo", true, "https://bitbucket.org/user/repo.git"}, + {"bitbucket.org without protocol", "bitbucket.org/user/repo", true, "https://bitbucket.org/user/repo.git"}, + {"bitbucket git@ ssh url", "git@bitbucket.org:user/repo.git", true, "https://bitbucket.org/user/repo.git"}, + {"bb: shorthand", "bb:user/repo", true, "https://bitbucket.org/user/repo.git"}, + + // Generic git URLs (returned as-is) + {"generic https .git", "https://git.example.com/user/repo.git", true, "https://git.example.com/user/repo.git"}, + {"generic git@ ssh", "git@git.example.com:user/repo.git", true, "git@git.example.com:user/repo.git"}, + {"generic ssh://", "ssh://git@git.example.com/user/repo.git", true, "ssh://git@git.example.com/user/repo.git"}, + + // Non-git URLs + {"random text", "hello world", false, ""}, + {"empty", "", false, ""}, + {"partial url", "github.com", false, ""}, + {"no repo", "github.com/user", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIsGit, gotURL := isGitURL(tt.input) + if gotIsGit != tt.wantIsGit { + t.Errorf("isGitURL() isGit = %v, want %v", gotIsGit, tt.wantIsGit) + } + if gotURL != tt.wantURL { + t.Errorf("isGitURL() url = %q, want %q", gotURL, tt.wantURL) + } + }) + } +} + +// TestIsGitHubURL tests the backwards compatibility wrapper +func TestIsGitHubURL(t *testing.T) { + // Just verify it calls isGitURL correctly + isGit, url := isGitHubURL("https://github.com/user/repo") + if !isGit || url != "https://github.com/user/repo.git" { + t.Errorf("isGitHubURL() backwards compat failed") + } +} + +func TestExtractRepoName(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {"https url", "https://github.com/user/my-repo.git", "my-repo"}, + {"without .git", "https://github.com/user/my-repo", "my-repo"}, + {"complex name", "https://github.com/user/my.complex-repo_name.git", "my.complex-repo_name"}, + {"short url", "repo.git", "repo"}, + {"just name", "repo", "repo"}, + {"empty returns default", "", "repo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractRepoName(tt.url); got != tt.want { + t.Errorf("extractRepoName() = %q, want %q", got, tt.want) + } + }) + } +} + +// ============================================================================ +// Path and Branch Sanitization Tests +// ============================================================================ + +func TestSanitizeBranchName(t *testing.T) { + tests := []struct { + name string + branch string + want string + }{ + {"simple", "main", "main"}, + {"with slash", "feature/my-feature", "feature-my-feature"}, + {"multiple slashes", "user/feature/sub", "user-feature-sub"}, + {"with backslash", "feature\\test", "feature-test"}, + {"double dots", "feature..test", "feature-test"}, + {"already clean", "my-feature-branch", "my-feature-branch"}, + {"with spaces", "feature my branch", "feature-my-branch"}, + {"with colons", "feature:test:branch", "feature-test-branch"}, + {"mixed special chars", "feat/my branch:v2", "feat-my-branch-v2"}, + {"preserves dots and underscores", "feat_1.0", "feat_1.0"}, + {"leading slash", "/feature", "feature"}, + {"trailing slash", "feature/", "feature"}, + {"multiple consecutive dashes", "feature---branch", "feature-branch"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeBranchName(tt.branch); got != tt.want { + t.Errorf("sanitizeBranchName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSanitizePath(t *testing.T) { + home, _ := os.UserHomeDir() + + tests := []struct { + name string + path string + want string + wantErr bool + }{ + {"empty path", "", "", false}, + {"absolute path", "/tmp/test", "/tmp/test", false}, + {"tilde expansion", "~/test", filepath.Join(home, "test"), false}, + {"just tilde", "~", home, false}, + {"relative path", "test/dir", "", false}, // Will be made absolute, can't predict + {"clean dots", "/tmp/../tmp/test", "/tmp/test", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sanitizePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("sanitizePath() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.want != "" && got != tt.want { + t.Errorf("sanitizePath() = %q, want %q", got, tt.want) + } + }) + } +} + +// ============================================================================ +// Git Repository Detection Tests +// ============================================================================ + +func TestIsGitRepository(t *testing.T) { + // Create a temp directory structure for testing + tempDir := t.TempDir() + + // Create a fake git repo + gitRepoDir := filepath.Join(tempDir, "git-repo") + os.MkdirAll(filepath.Join(gitRepoDir, ".git"), 0755) + + // Create a nested directory inside the git repo + nestedDir := filepath.Join(gitRepoDir, "nested", "deep") + os.MkdirAll(nestedDir, 0755) + + // Create a non-git directory + nonGitDir := filepath.Join(tempDir, "non-git") + os.MkdirAll(nonGitDir, 0755) + + tests := []struct { + name string + path string + wantRepo string + wantIs bool + }{ + {"git repo root", gitRepoDir, gitRepoDir, true}, + {"nested in git repo", nestedDir, gitRepoDir, true}, + {"non-git directory", nonGitDir, "", false}, + {"non-existent", filepath.Join(tempDir, "nonexistent"), "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRepo, gotIs := isGitRepository(tt.path) + if gotIs != tt.wantIs { + t.Errorf("isGitRepository() isRepo = %v, want %v", gotIs, tt.wantIs) + } + if tt.wantIs && gotRepo != tt.wantRepo { + t.Errorf("isGitRepository() repo = %q, want %q", gotRepo, tt.wantRepo) + } + }) + } +} + +// ============================================================================ +// Search Input Validation Tests +// ============================================================================ + +func TestIsValidSearchInput(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"letters", "hello", true}, + {"numbers", "123", true}, + {"mixed", "hello123", true}, + {"with dash", "hello-world", true}, + {"with underscore", "hello_world", true}, + {"with dot", "hello.world", true}, + {"with space", "hello world", true}, + {"with colon", "user:repo", true}, + {"with slash", "user/repo", true}, + {"with at", "user@host", true}, + {"empty", "", false}, + {"special chars", "hello!", false}, + {"newline", "hello\nworld", false}, + {"tab", "hello\tworld", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidSearchInput(tt.input); got != tt.want { + t.Errorf("isValidSearchInput(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// ============================================================================ +// Fuzzy Matching/Scoring Tests +// ============================================================================ + +func TestCalculateScore(t *testing.T) { + // Create a model with empty search term first + m := model{searchTerm: ""} + + // Test entries + now := time.Now() + recentEntry := tryEntry{ + Basename: "2024-01-15-project-alpha", + CTime: now.Add(-24 * time.Hour), // 1 day old + MTime: now.Add(-1 * time.Hour), // accessed 1 hour ago + } + oldEntry := tryEntry{ + Basename: "2024-01-01-old-project", + CTime: now.Add(-30 * 24 * time.Hour), // 30 days old + MTime: now.Add(-7 * 24 * time.Hour), // accessed 7 days ago + } + + t.Run("empty query uses time-based scoring", func(t *testing.T) { + recentScore := m.calculateScore(recentEntry) + oldScore := m.calculateScore(oldEntry) + + if recentScore <= oldScore { + t.Errorf("recent entry should score higher: recent=%f, old=%f", recentScore, oldScore) + } + }) + + t.Run("query filters non-matching", func(t *testing.T) { + m.searchTerm = "xyz" + score := m.calculateScore(recentEntry) + if score != 0 { + t.Errorf("non-matching entry should score 0, got %f", score) + } + }) + + t.Run("query matches substring", func(t *testing.T) { + m.searchTerm = "proj" + score := m.calculateScore(recentEntry) + if score == 0 { + t.Error("matching entry should score > 0") + } + }) + + t.Run("case insensitive matching", func(t *testing.T) { + m.searchTerm = "PROJ" + score := m.calculateScore(recentEntry) + if score == 0 { + t.Error("case-insensitive matching should work") + } + }) + + t.Run("date-prefixed entries get bonus", func(t *testing.T) { + m.searchTerm = "" + dated := tryEntry{Basename: "2024-01-15-project", CTime: now, MTime: now} + undated := tryEntry{Basename: "project", CTime: now, MTime: now} + + datedScore := m.calculateScore(dated) + undatedScore := m.calculateScore(undated) + + if datedScore <= undatedScore { + t.Errorf("dated entry should score higher: dated=%f, undated=%f", datedScore, undatedScore) + } + }) + + t.Run("consecutive chars bonus", func(t *testing.T) { + m.searchTerm = "proj" + consecutive := tryEntry{Basename: "project", CTime: now, MTime: now} + spread := tryEntry{Basename: "p-r-o-j-e-c-t", CTime: now, MTime: now} + + consScore := m.calculateScore(consecutive) + spreadScore := m.calculateScore(spread) + + if consScore <= spreadScore { + t.Errorf("consecutive should score higher: consecutive=%f, spread=%f", consScore, spreadScore) + } + }) + + t.Run("shorter strings preferred", func(t *testing.T) { + m.searchTerm = "proj" + short := tryEntry{Basename: "project", CTime: now, MTime: now} + long := tryEntry{Basename: "project-with-long-suffix", CTime: now, MTime: now} + + shortScore := m.calculateScore(short) + longScore := m.calculateScore(long) + + if shortScore <= longScore { + t.Errorf("shorter should score higher: short=%f, long=%f", shortScore, longScore) + } + }) +} + +// ============================================================================ +// Shell Detection Tests +// ============================================================================ + +func TestDetectUserShell(t *testing.T) { + // Save original env + origTryShell := os.Getenv("TRY_SHELL") + origShell := os.Getenv("SHELL") + defer func() { + os.Setenv("TRY_SHELL", origTryShell) + os.Setenv("SHELL", origShell) + }() + + t.Run("TRY_SHELL takes priority", func(t *testing.T) { + os.Setenv("TRY_SHELL", "/usr/bin/fish") + os.Setenv("SHELL", "/bin/bash") + got := detectUserShell(nil) + if got != "fish" { + t.Errorf("detectUserShell() = %q, want %q", got, "fish") + } + }) + + t.Run("config shell as fallback", func(t *testing.T) { + os.Unsetenv("TRY_SHELL") + os.Setenv("SHELL", "/bin/bash") + config := &Config{Shell: "/usr/bin/zsh"} + got := detectUserShell(config) + if got != "zsh" { + t.Errorf("detectUserShell() = %q, want %q", got, "zsh") + } + }) + + t.Run("SHELL env fallback", func(t *testing.T) { + os.Unsetenv("TRY_SHELL") + os.Setenv("SHELL", "/bin/bash") + got := detectUserShell(nil) + if got != "bash" { + t.Errorf("detectUserShell() = %q, want %q", got, "bash") + } + }) + + t.Run("default to bash", func(t *testing.T) { + os.Unsetenv("TRY_SHELL") + os.Unsetenv("SHELL") + got := detectUserShell(nil) + if got != "bash" { + t.Errorf("detectUserShell() = %q, want %q", got, "bash") + } + }) +} + +// ============================================================================ +// Wrapper Generation Tests +// ============================================================================ + +func TestGenerateBashZshWrapper(t *testing.T) { + wrapper := generateBashZshWrapper("/usr/local/bin/try") + + // Check essential parts + if !strings.Contains(wrapper, "/usr/local/bin/try") { + t.Error("wrapper should contain the binary path") + } + if !strings.Contains(wrapper, "--select-only") { + t.Error("wrapper should use --select-only flag") + } + if !strings.Contains(wrapper, "cd \"$dir\"") { + t.Error("wrapper should cd to the selected directory") + } +} + +func TestGenerateFishWrapper(t *testing.T) { + wrapper := generateFishWrapper("/usr/local/bin/try") + + // Check essential parts + if !strings.Contains(wrapper, "/usr/local/bin/try") { + t.Error("wrapper should contain the binary path") + } + if !strings.Contains(wrapper, "--select-only") { + t.Error("wrapper should use --select-only flag") + } + if !strings.Contains(wrapper, "function try") { + t.Error("wrapper should define a fish function") + } + if !strings.Contains(wrapper, "cd $dir") { + t.Error("wrapper should cd to the selected directory") + } +} + +// ============================================================================ +// Shell Escape Tests +// ============================================================================ + +func TestShellEscape(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"simple path", "/usr/local/bin/try", "'/usr/local/bin/try'"}, + {"path with spaces", "/Users/me/My Programs/try", "'/Users/me/My Programs/try'"}, + {"path with double quotes", `/path/with"quotes/try`, `'/path/with"quotes/try'`}, + {"path with backslash", `/path\with\backslash`, `'/path\with\backslash'`}, + {"path with single quote", "/path/with'quote/try", `'/path/with'\''quote/try'`}, + {"command injection attempt $", "/tmp/try$(rm -rf ~)", "'/tmp/try$(rm -rf ~)'"}, + {"command injection attempt backtick", "/tmp/try`rm -rf ~`", "'/tmp/try`rm -rf ~`'"}, + {"empty path", "", "''"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shellEscape(tt.input); got != tt.want { + t.Errorf("shellEscape() = %q, want %q", got, tt.want) + } + }) + } +} + +// ============================================================================ +// Rename Validation Tests +// ============================================================================ + +func TestValidateRenameName(t *testing.T) { + tempDir := t.TempDir() + + // Create an existing directory for collision testing + if err := os.MkdirAll(filepath.Join(tempDir, "existing-dir"), 0755); err != nil { + t.Fatalf("failed to create existing-dir: %v", err) + } + + tests := []struct { + name string + renameName string + originalName string + basePath string + wantError bool + errorContains string + }{ + {"valid rename", "new-name", "old-name", tempDir, false, ""}, + {"same name is ok", "same-name", "same-name", tempDir, false, ""}, + {"empty name", "", "old-name", tempDir, true, "cannot be empty"}, + {"contains slash", "new/name", "old-name", tempDir, true, "cannot contain"}, + {"contains backslash", "new\\name", "old-name", tempDir, true, "cannot contain"}, + {"double dots in name is ok", "v1..v2", "old-name", tempDir, false, ""}, // Not traversal since / is blocked + {"exact dot-dot blocked", "..", "old-name", tempDir, true, "Invalid name"}, + {"exact single dot blocked", ".", "old-name", tempDir, true, "Invalid name"}, + {"path traversal attempt", "../escape", "old-name", tempDir, true, "cannot contain"}, + {"collision with existing", "existing-dir", "old-name", tempDir, true, "already exists"}, + {"null byte blocked", "bad\x00name", "old-name", tempDir, true, "Invalid name"}, + {"absolute path blocked", "/etc/passwd", "old-name", tempDir, true, "absolute path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRenameName(tt.renameName, tt.originalName, tt.basePath) + if tt.wantError && err == "" { + t.Errorf("validateRenameName() expected error containing %q, got none", tt.errorContains) + } + if !tt.wantError && err != "" { + t.Errorf("validateRenameName() unexpected error: %q", err) + } + if tt.wantError && tt.errorContains != "" && !strings.Contains(err, tt.errorContains) { + t.Errorf("validateRenameName() error = %q, want containing %q", err, tt.errorContains) + } + }) + } +} + diff --git a/spec/tests/runner.sh b/spec/tests/runner.sh new file mode 100755 index 0000000..9143326 --- /dev/null +++ b/spec/tests/runner.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# Spec compliance test runner for try (Go version) +# Usage: ./runner.sh /path/to/try + +set +e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SPEC_DIR="$(dirname "$SCRIPT_DIR")" + +# Check arguments +if [ $# -lt 1 ]; then + echo "Usage: $0 /path/to/try" + echo "" + echo "Run spec compliance tests against the try binary." + exit 1 +fi + +TRY_CMD="$1" + +# Convert to absolute path if relative +if [[ "$TRY_CMD" != /* ]]; then + TRY_CMD="$(cd "$(dirname "$TRY_CMD")" && pwd)/$(basename "$TRY_CMD")" +fi + +# Verify binary exists and is executable +if [ ! -x "$TRY_CMD" ]; then + echo -e "${RED}Error: '$TRY_CMD' is not executable or does not exist${NC}" + exit 1 +fi + +# Export for test scripts +export TRY_CMD +export SPEC_DIR + +# Helper function to run try +try_run() { + $TRY_CMD "$@" 2>&1 +} + +# Create test environment +export TEST_ROOT=$(mktemp -d) +export TEST_TRIES="$TEST_ROOT/tries" +mkdir -p "$TEST_TRIES" + +# Create test directories with different mtimes +mkdir -p "$TEST_TRIES/2025-11-01-alpha" +mkdir -p "$TEST_TRIES/2025-11-15-beta" +mkdir -p "$TEST_TRIES/2025-11-20-gamma" +mkdir -p "$TEST_TRIES/2025-11-25-project-with-long-name" +mkdir -p "$TEST_TRIES/no-date-prefix" + +# Set mtimes (oldest first) +touch -t 202511010000 "$TEST_TRIES/2025-11-01-alpha" +touch -t 202511150000 "$TEST_TRIES/2025-11-15-beta" +touch -t 202511200000 "$TEST_TRIES/2025-11-20-gamma" +touch -t 202511250000 "$TEST_TRIES/2025-11-25-project-with-long-name" +touch "$TEST_TRIES/no-date-prefix" + +# Counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Test utilities +pass() { + echo -en "${GREEN}.${NC}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + TESTS_RUN=$((TESTS_RUN + 1)) +} + +fail() { + echo -e "\n${RED}FAIL${NC}: $1" + if [ -n "$2" ]; then + echo " Expected: $2" + fi + if [ -n "$3" ]; then + echo -e "\n Command output:\n\n$3\n" + fi + if [ -n "$4" ]; then + echo -e " ${YELLOW}Spec: $4${NC}" + fi + TESTS_FAILED=$((TESTS_FAILED + 1)) + TESTS_RUN=$((TESTS_RUN + 1)) +} + +section() { + echo -en "\n${YELLOW}$1${NC} " +} + +export -f pass fail section try_run + +# Cleanup on exit +cleanup() { + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT + +# Header +echo "Testing: $TRY_CMD" +echo "Test env: $TEST_TRIES" +echo + +# Run all test_*.sh files in order +for test_file in "$SCRIPT_DIR"/test_*.sh; do + if [ -f "$test_file" ]; then + set +e + source "$test_file" + fi +done + +# Summary +echo +echo +echo "═══════════════════════════════════" +echo "Results: $TESTS_PASSED/$TESTS_RUN passed" + +EXIT_CODE=0 + +if [ $TESTS_FAILED -gt 0 ]; then + echo -e "${RED}$TESTS_FAILED tests failed${NC}" + EXIT_CODE=1 +fi + +if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}All tests passed${NC}" +fi + +exit $EXIT_CODE diff --git a/spec/tests/test_01_basic.sh b/spec/tests/test_01_basic.sh new file mode 100644 index 0000000..3b7b13d --- /dev/null +++ b/spec/tests/test_01_basic.sh @@ -0,0 +1,64 @@ +# Basic compliance tests: --help, --version + +section "basic" + +# Test --help +output=$(try_run --help 2>&1) +if echo "$output" | grep -q "Quick Experiment Directories"; then + pass +else + fail "--help missing expected text" "contains 'Quick Experiment Directories'" "$output" +fi + +# Test -h +output=$(try_run -h 2>&1) +if echo "$output" | grep -q "Quick Experiment Directories"; then + pass +else + fail "-h missing expected text" "contains 'Quick Experiment Directories'" "$output" +fi + +# Test --version +output=$(try_run --version 2>&1) +if echo "$output" | grep -qE "^try version [0-9]+\.[0-9]+"; then + pass +else + fail "--version format incorrect" "try version X.Y.Z" "$output" +fi + +# Test -v +output=$(try_run -v 2>&1) +if echo "$output" | grep -qE "^try version [0-9]+\.[0-9]+"; then + pass +else + fail "-v format incorrect" "try version X.Y.Z" "$output" +fi + +# Test help shows worktree command +output=$(try_run --help 2>&1) +if echo "$output" | grep -q "worktree"; then + pass +else + fail "--help should mention worktree" "worktree in help" "$output" +fi + +# Test help shows init command +if echo "$output" | grep -q "try init"; then + pass +else + fail "--help should mention init" "try init in help" "$output" +fi + +# Test help shows rename shortcut +if echo "$output" | grep -q "Ctrl+R"; then + pass +else + fail "--help should mention Ctrl+R rename" "Ctrl+R in help" "$output" +fi + +# Test help shows text editing shortcuts +if echo "$output" | grep -q "Ctrl+A"; then + pass +else + fail "--help should mention text editing shortcuts" "Ctrl+A in help" "$output" +fi diff --git a/spec/tests/test_02_init.sh b/spec/tests/test_02_init.sh new file mode 100644 index 0000000..cab2357 --- /dev/null +++ b/spec/tests/test_02_init.sh @@ -0,0 +1,40 @@ +# Init command tests + +section "init" + +# Test: init generates shell wrapper +output=$(try_run init 2>&1) +if echo "$output" | grep -q "Detected shell"; then + pass +else + fail "init should detect shell" "Detected shell" "$output" +fi + +# Test: init generates function definition (bash: "try()" or fish: "function try") +if echo "$output" | grep -qE "try\(\)|function try"; then + pass +else + fail "init should generate try function" "try() or function try" "$output" +fi + +# Test: init uses --select-only +if echo "$output" | grep -q -- "--select-only"; then + pass +else + fail "init wrapper should use --select-only" "--select-only" "$output" +fi + +# Test: init shows config file suggestion +if echo "$output" | grep -q "Suggested config file"; then + pass +else + fail "init should suggest config file" "Suggested config file" "$output" +fi + +# Test: init with custom path +output=$(try_run init /custom/path/to/try 2>&1) +if echo "$output" | grep -q "/custom/path/to/try"; then + pass +else + fail "init should use custom path" "/custom/path/to/try" "$output" +fi diff --git a/spec/tests/test_03_worktree.sh b/spec/tests/test_03_worktree.sh new file mode 100644 index 0000000..380a392 --- /dev/null +++ b/spec/tests/test_03_worktree.sh @@ -0,0 +1,52 @@ +# Worktree command tests + +section "worktree" + +# Create a fake git repo for worktree tests +FAKE_REPO=$(mktemp -d) +mkdir -p "$FAKE_REPO/.git" +# Initialize as a real git repo for worktree tests +git -C "$FAKE_REPO" init -q 2>/dev/null +git -C "$FAKE_REPO" config user.email "test@test.com" 2>/dev/null +git -C "$FAKE_REPO" config user.name "Test" 2>/dev/null +touch "$FAKE_REPO/test.txt" +git -C "$FAKE_REPO" add . 2>/dev/null +git -C "$FAKE_REPO" commit -m "initial" -q 2>/dev/null + +# Test: worktree from non-git dir shows error +PLAIN_DIR=$(mktemp -d) +output=$(cd "$PLAIN_DIR" && TRY_PATH="$TEST_TRIES" try_run worktree testbranch 2>&1) +if echo "$output" | grep -qi "not.*git\|error"; then + pass +else + fail "worktree from non-git dir should error" "error message" "$output" +fi + +# Test: try . without branch shows error +output=$(cd "$FAKE_REPO" && TRY_PATH="$TEST_TRIES" try_run . 2>&1) +if echo "$output" | grep -qi "branch\|error\|requires"; then + pass +else + fail "try . without branch should show error" "error about branch" "$output" +fi + +# Test: dot shorthand is recognized as worktree command +# We just test that it's parsed, not that the worktree succeeds +output=$(cd "$FAKE_REPO" && TRY_PATH="$TEST_TRIES" try_run . main 2>&1) +if echo "$output" | grep -qi "worktree\|Creating\|error"; then + pass +else + fail "try . should be recognized" "worktree output" "$output" +fi + +# Test: --in-repo flag is recognized +output=$(cd "$FAKE_REPO" && TRY_PATH="$TEST_TRIES" try_run . --in-repo main 2>&1) +if echo "$output" | grep -qi "worktree\|Creating\|\.worktrees\|error"; then + pass +else + fail "try . --in-repo should be recognized" "worktree output" "$output" +fi + +# Cleanup worktrees if created +git -C "$FAKE_REPO" worktree prune 2>/dev/null || true +rm -rf "$FAKE_REPO" "$PLAIN_DIR" diff --git a/spec/tests/test_04_clone.sh b/spec/tests/test_04_clone.sh new file mode 100644 index 0000000..f92df16 --- /dev/null +++ b/spec/tests/test_04_clone.sh @@ -0,0 +1,22 @@ +# Clone command tests + +section "clone" + +# Test: --clone requires URL argument +output=$(try_run --clone 2>&1) +if echo "$output" | grep -qi "requires\|error\|url"; then + pass +else + fail "--clone without URL should error" "error message" "$output" +fi + +# Test: -c requires URL argument +output=$(try_run -c 2>&1) +if echo "$output" | grep -qi "requires\|error\|url"; then + pass +else + fail "-c without URL should error" "error message" "$output" +fi + +# Note: We don't test actual cloning as it requires network access +# The unit tests cover URL parsing diff --git a/spec/tests/test_05_select_only.sh b/spec/tests/test_05_select_only.sh new file mode 100644 index 0000000..52924de --- /dev/null +++ b/spec/tests/test_05_select_only.sh @@ -0,0 +1,21 @@ +# Select-only mode tests + +section "select-only" + +# Note: Select-only mode requires a TTY for the TUI +# We can only test that the flag is recognized + +# Test: --select-only is documented +output=$(try_run --help 2>&1) +if echo "$output" | grep -qE -- "--select-only|-s"; then + pass +else + fail "--select-only should be in help" "--select-only or -s" "$output" +fi + +# Test: -s is documented +if echo "$output" | grep -q -- "-s"; then + pass +else + fail "-s should be in help" "-s flag" "$output" +fi