From a8e497da14d27a12f91e9e5f203ff4b81b5886b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Esp=C3=ADrito=20Santo?= <16672623+gabssanto@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:50:37 -0300 Subject: [PATCH] Add bulk tagging support with dry-run mode Closes #1 New command: - scope bulk [--dry-run] Features: - Read paths from a file (one per line) - Support comments (lines starting with #) - Skip empty lines - Validate paths exist and are directories - Optional --dry-run mode to preview changes - Summary of tagged/skipped/errors at the end Also: - Updated shell completions for bulk command - Updated README with documentation --- README.md | 20 ++++++ cmd/scope/main.go | 102 +++++++++++++++++++++++++--- internal/completions/completions.go | 25 ++++++- internal/update/update.go | 18 ++--- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 807b9fb..4bfe63c 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,26 @@ scope tag . work scope tag ~/my-project work,urgent,backend ``` +#### `scope bulk [--dry-run]` + +Bulk tag multiple paths from a file. The file should contain one path per line. +Empty lines and lines starting with `#` are ignored. + +```bash +scope bulk paths.txt work # Tag all paths with 'work' +scope bulk paths.txt work --dry-run # Preview what would be tagged +``` + +Example paths file: +``` +# Services +/home/user/project/services/api +/home/user/project/services/frontend + +# Infrastructure +/home/user/project/infra/db +``` + #### `scope untag ` Remove a tag from a folder. diff --git a/cmd/scope/main.go b/cmd/scope/main.go index f1b065c..a78ca6f 100644 --- a/cmd/scope/main.go +++ b/cmd/scope/main.go @@ -31,6 +31,7 @@ const usage = `Scope - Fast folder navigation with tags Usage: scope tag Tag a folder (use . for current directory) + scope bulk Bulk tag paths from file (--dry-run to preview) scope untag Remove a tag from a folder scope tags Show all tags for a folder scope list [tag] List all tags or folders with specific tag @@ -78,6 +79,8 @@ Examples: scope each work "git status" Run git status in each 'work' folder scope each work -p "go test" Run tests in parallel across folders scope untag . work Remove 'work' tag from current directory + scope bulk paths.txt work Bulk tag paths from file + scope bulk paths.txt work --dry-run Preview bulk tagging scope rename old new Rename 'old' tag to 'new' scope remove-tag old Delete 'old' tag entirely scope prune --dry-run Preview folders to be removed @@ -133,6 +136,8 @@ func run() error { switch command { case "tag": return handleTag() + case "bulk": + return handleBulk() case "untag": return handleUntag() case "tags": @@ -209,6 +214,87 @@ func handleTag() error { return nil } +func handleBulk() error { + if len(os.Args) < 4 { + return fmt.Errorf("usage: scope bulk [--dry-run]") + } + + filePath := os.Args[2] + tagName := os.Args[3] + dryRun := len(os.Args) >= 5 && (os.Args[4] == "--dry-run" || os.Args[4] == "-n") + + // Read file + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file '%s': %w", filePath, err) + } + + lines := strings.Split(string(content), "\n") + + successCount := 0 + skipCount := 0 + errorCount := 0 + + for lineNum, line := range lines { + // Trim whitespace + line = strings.TrimSpace(line) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Resolve path + absPath, err := resolvePath(line) + if err != nil { + fmt.Fprintf(os.Stderr, "Line %d: failed to resolve path '%s': %v\n", lineNum+1, line, err) + errorCount++ + continue + } + + // Check if directory exists + info, err := os.Stat(absPath) + if os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Line %d: path does not exist: %s\n", lineNum+1, absPath) + skipCount++ + continue + } + if err != nil { + fmt.Fprintf(os.Stderr, "Line %d: failed to access path '%s': %v\n", lineNum+1, absPath, err) + errorCount++ + continue + } + if !info.IsDir() { + fmt.Fprintf(os.Stderr, "Line %d: not a directory: %s\n", lineNum+1, absPath) + skipCount++ + continue + } + + if dryRun { + fmt.Printf("[DRY-RUN] Would tag '%s' with '%s'\n", absPath, tagName) + successCount++ + } else { + if err := tag.AddTag(absPath, tagName); err != nil { + fmt.Fprintf(os.Stderr, "Line %d: failed to tag '%s': %v\n", lineNum+1, absPath, err) + errorCount++ + continue + } + fmt.Printf("Tagged '%s' with '%s'\n", absPath, tagName) + successCount++ + } + } + + // Summary + fmt.Println() + if dryRun { + fmt.Printf("Dry-run complete: %d would be tagged, %d skipped, %d errors\n", successCount, skipCount, errorCount) + } else { + fmt.Printf("Bulk tagging complete: %d tagged, %d skipped, %d errors\n", successCount, skipCount, errorCount) + } + + return nil +} + func handleUntag() error { if len(os.Args) < 4 { return fmt.Errorf("usage: scope untag ") @@ -411,10 +497,7 @@ func handleRename() error { } func handlePrune() error { - dryRun := false - if len(os.Args) >= 3 && (os.Args[2] == "--dry-run" || os.Args[2] == "-n") { - dryRun = true - } + dryRun := len(os.Args) >= 3 && (os.Args[2] == "--dry-run" || os.Args[2] == "-n") result, err := tag.Prune(dryRun) if err != nil { @@ -530,7 +613,10 @@ func handleImport() error { } func handleDebug() error { - homeDir, _ := os.UserHomeDir() + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } dbPath := filepath.Join(homeDir, ".config", "scope", "scope.db") fmt.Println("Scope Debug Information") @@ -992,11 +1078,7 @@ func handleCompletions() error { } func handleUpdate() error { - // Check for --check flag - checkOnly := false - if len(os.Args) >= 3 && (os.Args[2] == "--check" || os.Args[2] == "-c") { - checkOnly = true - } + checkOnly := len(os.Args) >= 3 && (os.Args[2] == "--check" || os.Args[2] == "-c") if checkOnly { info, err := update.CheckForUpdate(Version) diff --git a/internal/completions/completions.go b/internal/completions/completions.go index 184070d..5ec746f 100644 --- a/internal/completions/completions.go +++ b/internal/completions/completions.go @@ -16,7 +16,7 @@ _scope_completions() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="tag untag tags list start scan go pick open edit each status pull rename remove-tag prune export import update debug help version completions" + commands="tag bulk untag tags list start scan go pick open edit each status pull rename remove-tag prune export import update debug help version completions" # Get tags dynamically if command -v scope &> /dev/null; then @@ -48,6 +48,17 @@ _scope_completions() { COMPREPLY=( $(compgen -f -X '!*.yml' -- "${cur}") $(compgen -f -X '!*.yaml' -- "${cur}") ) return 0 ;; + bulk) + # Complete with files, then tags + if [[ ${COMP_CWORD} -eq 2 ]]; then + COMPREPLY=( $(compgen -f -- "${cur}") ) + elif [[ ${COMP_CWORD} -eq 3 ]]; then + COMPREPLY=( $(compgen -W "${tags}" -- "${cur}") ) + elif [[ ${COMP_CWORD} -eq 4 ]]; then + COMPREPLY=( $(compgen -W "--dry-run" -- "${cur}") ) + fi + return 0 + ;; completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") ) return 0 @@ -92,6 +103,7 @@ _scope() { commands=( 'tag:Tag a folder' + 'bulk:Bulk tag paths from file' 'untag:Remove a tag from a folder' 'tags:Show all tags for a folder' 'list:List all tags or folders with a tag' @@ -150,6 +162,15 @@ _scope() { import) _files -g '*.y(a|)ml' ;; + bulk) + if [[ $CURRENT -eq 3 ]]; then + _files + elif [[ $CURRENT -eq 4 ]]; then + _describe -t tags 'tags' tags + elif [[ $CURRENT -eq 5 ]]; then + _values 'flags' '--dry-run[preview changes]' + fi + ;; completions) _values 'shells' 'bash' 'zsh' 'fish' ;; @@ -178,6 +199,7 @@ complete -c scope -f # Commands complete -c scope -n "__fish_use_subcommand" -a "tag" -d "Tag a folder" +complete -c scope -n "__fish_use_subcommand" -a "bulk" -d "Bulk tag paths from file" complete -c scope -n "__fish_use_subcommand" -a "untag" -d "Remove a tag from a folder" complete -c scope -n "__fish_use_subcommand" -a "tags" -d "Show all tags for a folder" complete -c scope -n "__fish_use_subcommand" -a "list" -d "List all tags or folders" @@ -216,6 +238,7 @@ complete -c scope -n "__fish_seen_subcommand_from tag untag tags" -a "(__fish_co # Flags complete -c scope -n "__fish_seen_subcommand_from prune" -l dry-run -d "Preview changes" +complete -c scope -n "__fish_seen_subcommand_from bulk" -l dry-run -d "Preview changes" complete -c scope -n "__fish_seen_subcommand_from update" -l check -d "Check only" complete -c scope -n "__fish_seen_subcommand_from each" -s p -l parallel -d "Run in parallel" diff --git a/internal/update/update.go b/internal/update/update.go index 02b6b5a..34ea3d4 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -30,11 +30,11 @@ type Release struct { // UpdateInfo contains information about available updates type UpdateInfo struct { - CurrentVersion string - LatestVersion string - UpdateAvailable bool - ReleaseURL string - ReleaseNotes string + CurrentVersion string + LatestVersion string + UpdateAvailable bool + ReleaseURL string + ReleaseNotes string } // getConfigDir returns the scope config directory @@ -80,7 +80,7 @@ func fetchLatestRelease() (*Release, error) { if err != nil { return nil, fmt.Errorf("failed to fetch release: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) @@ -246,7 +246,7 @@ func PerformUpdate(currentVersion string) error { if err != nil { return fmt.Errorf("failed to download update: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed with status %d (asset may not exist for your platform)", resp.StatusCode) @@ -270,11 +270,11 @@ func PerformUpdate(currentVersion string) error { return fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) + defer func() { _ = os.Remove(tmpPath) }() // Download to temp file _, err = io.Copy(tmpFile, resp.Body) - tmpFile.Close() + _ = tmpFile.Close() if err != nil { return fmt.Errorf("failed to download: %w", err) }