diff --git a/CLAUDE.md b/CLAUDE.md index e1c289e..a9978e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ go mod download ## 아키텍처 ### CLI 구조 (Cobra 기반) -- `cmd/root.go` - 루트 명령어 및 전역 플래그 (`--config`, `--verbose`, `--quiet`) +- `cmd/root.go` - 루트 명령어 및 전역 플래그 (`--config`, `--verbose`) - `cmd/*.go` - 각 서브커맨드 (init, sync, unsync, list, add, version) ### 핵심 패키지 (`internal/gitvolume`) diff --git a/README.md b/README.md index a91361e..ce61b61 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | ---------------------------------------------- | | `--dry-run` | `sync`, `unsync` | Show what would be done without making changes | | `--relative` | `sync` | Create relative symlinks instead of absolute | -| `--verbose`, `-v` | All | Verbose output | -| `--quiet`, `-q` | All | Suppress non-error output | +| `--verbose`, `-v` | All | Verbosity level: 0=errors only, 1=normal (default), 2=detailed | | `--config`, `-c` | All | Custom config file path | ## 🛡️ Safety Features diff --git a/cmd/global.go b/cmd/global.go index fd8305b..cb65f1e 100644 --- a/cmd/global.go +++ b/cmd/global.go @@ -26,7 +26,7 @@ var globalListCmd = &cobra.Command{ Long: `Displays a tree of all files currently stored in the global git-volume directory.`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{Quiet: quiet}) + gv, err := gitvolume.New(commandOptions(false)) if err != nil { return err } diff --git a/cmd/global_add.go b/cmd/global_add.go index 15bfd1b..b00c52b 100644 --- a/cmd/global_add.go +++ b/cmd/global_add.go @@ -34,7 +34,7 @@ Examples: Args: cobra.MinimumNArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{Quiet: quiet}) + gv, err := gitvolume.New(commandOptions(false)) if err != nil { return err } diff --git a/cmd/global_edit.go b/cmd/global_edit.go index fe491a0..1fa96a8 100644 --- a/cmd/global_edit.go +++ b/cmd/global_edit.go @@ -21,7 +21,7 @@ Examples: Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{Quiet: quiet}) + gv, err := gitvolume.New(commandOptions(false)) if err != nil { return err } diff --git a/cmd/global_remove.go b/cmd/global_remove.go index 8215f09..4c2e2be 100644 --- a/cmd/global_remove.go +++ b/cmd/global_remove.go @@ -21,7 +21,7 @@ Examples: SilenceUsage: true, Aliases: []string{"rm"}, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{Quiet: quiet}) + gv, err := gitvolume.New(commandOptions(false)) if err != nil { return err } diff --git a/cmd/init.go b/cmd/init.go index 292f4ea..bc5ef8b 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -16,7 +16,7 @@ var initCmd = &cobra.Command{ git-volume.yaml configuration file in the current directory.`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{Quiet: quiet}) + gv, err := gitvolume.New(commandOptions(false)) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index e645b7c..ca52ef6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,14 +4,16 @@ Copyright © 2026 laggu package cmd import ( + "fmt" + + "github.com/laggu/git-volume/internal/gitvolume" "github.com/spf13/cobra" ) // Global flags var ( - cfgFile string - verbose bool - quiet bool + cfgFile string + verbosity int ) // rootCmd represents the base command when called without any subcommands @@ -22,6 +24,13 @@ var rootCmd = &cobra.Command{ by dynamically mounting them using a git-volume.yaml manifest. "Keep code in Git, mount environments as volumes."`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if verbosity < gitvolume.VerbosityQuiet || verbosity > gitvolume.VerbosityDetailed { + return fmt.Errorf("invalid --verbose level %d (allowed: 0, 1, 2)", verbosity) + } + + return nil + }, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -29,9 +38,18 @@ func Execute() error { return rootCmd.Execute() } +func commandOptions(useConfig bool) gitvolume.Options { + opts := gitvolume.Options{Verbosity: verbosity} + if useConfig { + opts.ConfigPath = cfgFile + } + return opts +} + func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (default: auto-detected)") - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") - rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress non-error output") - rootCmd.MarkFlagsMutuallyExclusive("verbose", "quiet") + rootCmd.PersistentFlags().IntVarP(&verbosity, "verbose", "v", gitvolume.VerbosityNormal, "verbosity level (0=errors only, 1=normal, 2=detailed)") + if flag := rootCmd.PersistentFlags().Lookup("verbose"); flag != nil { + flag.NoOptDefVal = "2" + } } diff --git a/cmd/status.go b/cmd/status.go index eda2c4d..1d4159b 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -14,10 +14,7 @@ var statusCmd = &cobra.Command{ Short: "Show the status of all volumes", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{ - ConfigPath: cfgFile, - Quiet: quiet, - }) + gv, err := gitvolume.New(commandOptions(true)) if err != nil { return err } diff --git a/cmd/sync.go b/cmd/sync.go index d55ef3d..4e63154 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -24,11 +24,7 @@ It checks the current directory for git-volume.yaml first. If not found, it looks for it in the main Git worktree (inheritance).`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{ - ConfigPath: cfgFile, - Verbose: verbose, - Quiet: quiet, - }) + gv, err := gitvolume.New(commandOptions(true)) if err != nil { return err } diff --git a/cmd/unsync.go b/cmd/unsync.go index 8a17cd9..a1a952a 100644 --- a/cmd/unsync.go +++ b/cmd/unsync.go @@ -22,11 +22,7 @@ For copied files, it verifies that the file content has not changed compared to the source before deleting. If changed, it skips deletion to prevent data loss.`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - gv, err := gitvolume.New(gitvolume.Options{ - ConfigPath: cfgFile, - Verbose: verbose, - Quiet: quiet, - }) + gv, err := gitvolume.New(commandOptions(true)) if err != nil { return err } diff --git a/docs/specs/spec.md b/docs/specs/spec.md index 210e8b0..a10b3d1 100644 --- a/docs/specs/spec.md +++ b/docs/specs/spec.md @@ -16,15 +16,15 @@ git volume init [flags] ### Flags - `-h, --help`: help for init -- `-q, --quiet`: suppress all output except errors +- `-v, --verbose int`: verbosity level (0=errors only, 1=normal default, 2=detailed) ### Example ```bash # Basic initialization git volume init -# Run quietly -git volume init -q +# Errors only output +git volume init --verbose 0 ``` --- @@ -45,7 +45,7 @@ git volume sync [flags] ### Flags - `--dry-run`: show what would be done without making actual changes - `--relative`: create relative symbolic links instead of absolute ones -- `-v, --verbose`: verbose output +- `-v, --verbose int`: verbosity level (0=errors only, 1=normal default, 2=detailed) - `-c, --config string`: manually specify config file path (default: auto-detected) ### Example @@ -72,7 +72,7 @@ git volume unsync [flags] ### Flags - `--dry-run`: show what would be removed without actually deleting -- `-v, --verbose`: verbose output +- `-v, --verbose int`: verbosity level (0=errors only, 1=normal default, 2=detailed) ### Example ```bash @@ -99,7 +99,7 @@ git volume status [flags] ### Flags - `-c, --config string`: specify configuration file path -- (Note: The `status` command displays detailed information by default, which can be suppressed with the `-q` flag. The `-v` flag has no separate effect.) +- `-v, --verbose int`: verbosity level (0=errors only, 1=normal default, 2=detailed) ### Example ```bash @@ -181,5 +181,4 @@ git volume global edit dev.env Flags available for all commands. - `-c, --config`: specify config file path -- `-v, --verbose`: verbose output mode -- `-q, --quiet`: suppress output except errors +- `-v, --verbose int`: verbosity level (`0=errors only`, `1=normal`, `2=detailed`) diff --git a/docs/translations/README_de.md b/docs/translations/README_de.md index 3d70793..28d49dc 100644 --- a/docs/translations/README_de.md +++ b/docs/translations/README_de.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | --------------------------------------------------- | | `--dry-run` | `sync`, `unsync` | Zeigen was getan würde, ohne Änderungen vorzunehmen | | `--relative` | `sync` | Relative statt absolute symbolische Links erstellen | -| `--verbose`, `-v` | Alle | Ausführliche Ausgabe | -| `--quiet`, `-q` | Alle | Nicht-Fehler-Ausgaben unterdrücken | +| `--verbose`, `-v` | Alle | Ausgabestufe: 0=nur Fehler, 1=normal (Standard), 2=detailliert | | `--config`, `-c` | Alle | Benutzerdefinierter Konfigurationsdateipfad | ## 🛡️ Sicherheitsfunktionen diff --git a/docs/translations/README_es.md b/docs/translations/README_es.md index 85bc620..0ab5a46 100644 --- a/docs/translations/README_es.md +++ b/docs/translations/README_es.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | ------------------------------------------------------ | | `--dry-run` | `sync`, `unsync` | Mostrar qué se haría sin realizar cambios | | `--relative` | `sync` | Crear enlaces simbólicos relativos en vez de absolutos | -| `--verbose`, `-v` | Todos | Salida detallada | -| `--quiet`, `-q` | Todos | Ocultar salida no relacionada con errores | +| `--verbose`, `-v` | Todos | Nivel de salida: 0=solo errores, 1=normal (por defecto), 2=detallado | | `--config`, `-c` | Todos | Ruta personalizada del archivo de configuración | ## 🛡️ Características de seguridad diff --git a/docs/translations/README_fr.md b/docs/translations/README_fr.md index 35918f1..e51b8e3 100644 --- a/docs/translations/README_fr.md +++ b/docs/translations/README_fr.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | --------------------------------------------------------- | | `--dry-run` | `sync`, `unsync` | Afficher ce qui serait fait sans effectuer de changements | | `--relative` | `sync` | Créer des liens symboliques relatifs au lieu d'absolus | -| `--verbose`, `-v` | Toutes | Sortie détaillée | -| `--quiet`, `-q` | Toutes | Masquer les sorties non liées aux erreurs | +| `--verbose`, `-v` | Toutes | Niveau de sortie : 0=erreurs seulement, 1=normal (par défaut), 2=détaillé | | `--config`, `-c` | Toutes | Chemin personnalisé du fichier de configuration | ## 🛡️ Fonctionnalités de sécurité diff --git a/docs/translations/README_it.md b/docs/translations/README_it.md index 4144a1a..8c2ab12 100644 --- a/docs/translations/README_it.md +++ b/docs/translations/README_it.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | ------------------------------------------------------ | | `--dry-run` | `sync`, `unsync` | Mostrare cosa verrebbe fatto senza apportare modifiche | | `--relative` | `sync` | Creare link simbolici relativi invece di assoluti | -| `--verbose`, `-v` | Tutti | Output dettagliato | -| `--quiet`, `-q` | Tutti | Nascondere l'output non correlato agli errori | +| `--verbose`, `-v` | Tutti | Livello output: 0=solo errori, 1=normale (predefinito), 2=dettagliato | | `--config`, `-c` | Tutti | Percorso personalizzato del file di configurazione | ## 🛡️ Funzionalità di sicurezza diff --git a/docs/translations/README_ja.md b/docs/translations/README_ja.md index b0e0d51..6cfeedf 100644 --- a/docs/translations/README_ja.md +++ b/docs/translations/README_ja.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | ---------------------------------------- | | `--dry-run` | `sync`, `unsync` | 実際の変更なしに実行内容を表示 | | `--relative` | `sync` | 絶対パスの代わりに相対パスのリンクを作成 | -| `--verbose`, `-v` | 全て | 詳細出力 | -| `--quiet`, `-q` | 全て | エラー以外の出力を非表示 | +| `--verbose`, `-v` | 全て | 出力レベル: 0=エラーのみ、1=通常(既定)、2=詳細 | | `--config`, `-c` | 全て | 設定ファイルパスの指定 | ## 🛡️ 安全機能 diff --git a/docs/translations/README_ko.md b/docs/translations/README_ko.md index 37ec73d..40514db 100644 --- a/docs/translations/README_ko.md +++ b/docs/translations/README_ko.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | ----------------------------------------- | | `--dry-run` | `sync`, `unsync` | 실제 변경 없이 수행할 작업만 표시 | | `--relative` | `sync` | 절대 경로 대신 상대 경로 심볼릭 링크 생성 | -| `--verbose`, `-v` | 전체 | 상세 출력 | -| `--quiet`, `-q` | 전체 | 에러 외 출력 숨김 | +| `--verbose`, `-v` | 전체 | 출력 레벨: 0=에러만, 1=기본(기본값), 2=상세 | | `--config`, `-c` | 전체 | 설정 파일 경로 지정 | ## 🛡️ 안전 장치 diff --git a/docs/translations/README_pt.md b/docs/translations/README_pt.md index fe0eca2..02634e6 100644 --- a/docs/translations/README_pt.md +++ b/docs/translations/README_pt.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | ---------------------------------------------------- | | `--dry-run` | `sync`, `unsync` | Mostrar o que seria feito sem realizar alterações | | `--relative` | `sync` | Criar links simbólicos relativos em vez de absolutos | -| `--verbose`, `-v` | Todos | Saída detalhada | -| `--quiet`, `-q` | Todos | Ocultar saída não relacionada a erros | +| `--verbose`, `-v` | Todos | Nível de saída: 0=somente erros, 1=normal (padrão), 2=detalhado | | `--config`, `-c` | Todos | Caminho personalizado do arquivo de configuração | ## 🛡️ Recursos de segurança diff --git a/docs/translations/README_zh.md b/docs/translations/README_zh.md index eb87b54..6cf14ac 100644 --- a/docs/translations/README_zh.md +++ b/docs/translations/README_zh.md @@ -144,8 +144,7 @@ volumes: | ----------------- | ---------------- | -------------------------------- | | `--dry-run` | `sync`, `unsync` | 不做实际更改,仅显示将执行的操作 | | `--relative` | `sync` | 创建相对路径符号链接而非绝对路径 | -| `--verbose`, `-v` | 全部 | 详细输出 | -| `--quiet`, `-q` | 全部 | 隐藏非错误输出 | +| `--verbose`, `-v` | 全部 | 输出级别:0=仅错误,1=普通(默认),2=详细 | | `--config`, `-c` | 全部 | 指定配置文件路径 | ## 🛡️ 安全功能 diff --git a/internal/gitvolume/add.go b/internal/gitvolume/add.go index dd74d36..96dd42d 100644 --- a/internal/gitvolume/add.go +++ b/internal/gitvolume/add.go @@ -67,8 +67,8 @@ func (g *GitVolume) beforeAllAdd(files []string, opts AddOptions) error { } func (g *GitVolume) afterAllAdd(errs []error) error { - if len(errs) > 0 && !g.quiet { - fmt.Printf("❌ Global add completed with %d error(s)\n", len(errs)) + if len(errs) > 0 && g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Global add completed with %d error(s)\n", len(errs)) } return errors.Join(errs...) } @@ -151,14 +151,14 @@ func (g *GitVolume) add(file string, prepared addPrepared, opts AddOptions) erro func (g *GitVolume) afterAdd(file, dstPath string, opts AddOptions, err error, errs *[]error) { if err != nil { - if !g.quiet { - fmt.Printf("❌ Failed to add %s: %v\n", file, err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Failed to add %s: %v\n", file, err) } *errs = append(*errs, err) return } - if !g.quiet { + if g.isDetailed() { relPath, err := filepath.Rel(g.ctx.GlobalDir, dstPath) if err != nil { // This should not happen due to prior validation, but as a fallback: diff --git a/internal/gitvolume/add_test.go b/internal/gitvolume/add_test.go index 231eb50..7c65be7 100644 --- a/internal/gitvolume/add_test.go +++ b/internal/gitvolume/add_test.go @@ -159,7 +159,7 @@ func TestGlobalAdd(t *testing.T) { ctx: &Context{ GlobalDir: globalDir, }, - quiet: true, + verbosity: VerbosityQuiet, } // Create test files diff --git a/internal/gitvolume/context.go b/internal/gitvolume/context.go index 634577b..7f0a33f 100644 --- a/internal/gitvolume/context.go +++ b/internal/gitvolume/context.go @@ -338,8 +338,7 @@ func NewContext() (*Context, error) { // Load loads configuration from config file and populates the Context. // configPath: custom config file path (empty string for auto-detection) -// quiet: suppress warning messages -func (c *Context) Load(configPath string, quiet bool) error { +func (c *Context) Load(configPath string, verbosity int) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current working directory: %w", err) @@ -358,7 +357,7 @@ func (c *Context) Load(configPath string, quiet bool) error { } // 3. Load and apply config - cfg, err := loadConfig(absConfigPath, quiet) + cfg, err := loadConfig(absConfigPath, verbosity) if err != nil { return err } @@ -433,7 +432,7 @@ type rawConfig struct { // loadConfig reads and parses the configuration file // Returns rawConfig and error -func loadConfig(path string, quiet bool) (*rawConfig, error) { +func loadConfig(path string, verbosity int) (*rawConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) @@ -445,7 +444,7 @@ func loadConfig(path string, quiet bool) (*rawConfig, error) { } // Warn if no volumes defined - if len(cfg.Volumes) == 0 && !quiet { + if len(cfg.Volumes) == 0 && verbosity >= VerbosityNormal { fmt.Fprintf(os.Stderr, "⚠️ Warning: no volumes defined in %s\n", path) } diff --git a/internal/gitvolume/context_test.go b/internal/gitvolume/context_test.go index eab4dd6..10a4f1e 100644 --- a/internal/gitvolume/context_test.go +++ b/internal/gitvolume/context_test.go @@ -170,7 +170,7 @@ volumes: _ = tmpFile.Close() // Run loadConfig - cfg, err := loadConfig(tmpFile.Name(), true) + cfg, err := loadConfig(tmpFile.Name(), VerbosityQuiet) if tt.wantErr { assert.Error(t, err) return @@ -268,7 +268,7 @@ func TestNewWorkspace_LocalConfig(t *testing.T) { // Create context and load config ctx, err := NewContext() require.NoError(t, err) - require.NoError(t, ctx.Load("", true)) + require.NoError(t, ctx.Load("", VerbosityQuiet)) assert.Equal(t, repoDir, resolvePath(ctx.SourceDir)) assert.Equal(t, repoDir, resolvePath(ctx.TargetDir)) @@ -299,7 +299,7 @@ func TestNewWorkspace_CustomPath(t *testing.T) { // Create context with custom path ctx, err := NewContext() require.NoError(t, err) - require.NoError(t, ctx.Load(customConfigPath, true)) + require.NoError(t, ctx.Load(customConfigPath, VerbosityQuiet)) assert.Equal(t, customDir, ctx.SourceDir) assert.Equal(t, 1, len(ctx.Volumes)) @@ -318,7 +318,7 @@ func TestNewWorkspace_NoConfig(t *testing.T) { // Create context and load should fail ctx, err := NewContext() require.NoError(t, err) - assert.Error(t, ctx.Load("", true)) + assert.Error(t, ctx.Load("", VerbosityQuiet)) } func TestNewWorkspace_EmptyConfig(t *testing.T) { @@ -337,7 +337,7 @@ func TestNewWorkspace_EmptyConfig(t *testing.T) { ctx, err := NewContext() require.NoError(t, err) // Should not error, just empty volumes - require.NoError(t, ctx.Load("", true)) + require.NoError(t, ctx.Load("", VerbosityQuiet)) assert.Equal(t, 0, len(ctx.Volumes)) } @@ -363,7 +363,7 @@ func TestNewWorkspace_RelativeCustomPath(t *testing.T) { // Create context with relative path ctx, err := NewContext() require.NoError(t, err) - require.NoError(t, ctx.Load("my-config.yaml", true)) + require.NoError(t, ctx.Load("my-config.yaml", VerbosityQuiet)) assert.Equal(t, repoDir, resolvePath(ctx.SourceDir)) } diff --git a/internal/gitvolume/edit.go b/internal/gitvolume/edit.go index fe660b0..1778573 100644 --- a/internal/gitvolume/edit.go +++ b/internal/gitvolume/edit.go @@ -74,13 +74,13 @@ func (g *GitVolume) edit(targetPath, editor string) error { func (g *GitVolume) afterEdit(targetPath string, err error) error { if err != nil { - if !g.quiet { - fmt.Printf("❌ Failed to edit %s: %v\n", targetPath, err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Failed to edit %s: %v\n", targetPath, err) } return err } - if !g.quiet { + if g.isDetailed() { fmt.Printf("✓ Edited %s\n", targetPath) } return nil diff --git a/internal/gitvolume/edit_test.go b/internal/gitvolume/edit_test.go index b25c74b..0eed5ab 100644 --- a/internal/gitvolume/edit_test.go +++ b/internal/gitvolume/edit_test.go @@ -21,7 +21,7 @@ func TestGitVolume_GlobalEdit(t *testing.T) { ctx: &Context{ GlobalDir: globalDir, }, - quiet: true, + verbosity: VerbosityQuiet, } t.Run("Edit existing file", func(t *testing.T) { @@ -63,7 +63,7 @@ func TestGitVolume_GlobalEdit(t *testing.T) { ctx: &Context{ GlobalDir: filepath.Join(tmpDir, "missing-global"), }, - quiet: true, + verbosity: VerbosityQuiet, } err := gvMissing.GlobalEdit("anything.txt") assert.Error(t, err) diff --git a/internal/gitvolume/gitvolume.go b/internal/gitvolume/gitvolume.go index 1b1de73..e293a49 100644 --- a/internal/gitvolume/gitvolume.go +++ b/internal/gitvolume/gitvolume.go @@ -3,16 +3,30 @@ package gitvolume // GitVolume is the main entry point for git-volume operations type GitVolume struct { ctx *Context - verbose bool - quiet bool + verbosity int configPath string // stored for Load() } // Options configures GitVolume creation type Options struct { ConfigPath string // Custom config file path (optional) - Verbose bool // Verbose output - Quiet bool // Suppress non-error output + Verbosity int +} + +const ( + VerbosityQuiet = 0 + VerbosityNormal = 1 + VerbosityDetailed = 2 +) + +func normalizeVerbosity(level int) int { + if level < VerbosityQuiet { + return VerbosityQuiet + } + if level > VerbosityDetailed { + return VerbosityDetailed + } + return level } // New creates a new GitVolume instance without loading config @@ -23,8 +37,7 @@ func New(opts Options) (*GitVolume, error) { } return &GitVolume{ ctx: ctx, - verbose: opts.Verbose, - quiet: opts.Quiet, + verbosity: normalizeVerbosity(opts.Verbosity), configPath: opts.ConfigPath, }, nil } @@ -32,9 +45,12 @@ func New(opts Options) (*GitVolume, error) { // Load loads configuration from the config file. // Must be called before Sync, Unsync, or Status operations. func (g *GitVolume) Load() error { - return g.ctx.Load(g.configPath, g.quiet) + return g.ctx.Load(g.configPath, g.verbosity) } +func (g *GitVolume) isNormalOrHigher() bool { return g.verbosity >= VerbosityNormal } +func (g *GitVolume) isDetailed() bool { return g.verbosity >= VerbosityDetailed } + // Context returns the underlying context func (g *GitVolume) Context() *Context { return g.ctx } diff --git a/internal/gitvolume/gitvolume_test.go b/internal/gitvolume/gitvolume_test.go index c612cb2..8cd86cc 100644 --- a/internal/gitvolume/gitvolume_test.go +++ b/internal/gitvolume/gitvolume_test.go @@ -63,9 +63,8 @@ func createTestGitVolume(sourceDir, targetDir, globalDir string, volumes []Volum ctx.ResolveVolumePaths() return &GitVolume{ - ctx: ctx, - verbose: false, - quiet: true, + ctx: ctx, + verbosity: VerbosityQuiet, } } diff --git a/internal/gitvolume/init.go b/internal/gitvolume/init.go index 3ddc593..1ad005b 100644 --- a/internal/gitvolume/init.go +++ b/internal/gitvolume/init.go @@ -56,13 +56,13 @@ func (g *GitVolume) init(state *initState) error { func (g *GitVolume) afterInit(state *initState, err error) error { if err != nil { - if !g.quiet { - fmt.Printf("❌ Init failed: %v\n", err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Init failed: %v\n", err) } return err } - if g.quiet { + if !g.isNormalOrHigher() { return nil } diff --git a/internal/gitvolume/init_test.go b/internal/gitvolume/init_test.go index c669eb1..1593ca5 100644 --- a/internal/gitvolume/init_test.go +++ b/internal/gitvolume/init_test.go @@ -27,7 +27,7 @@ func TestInit(t *testing.T) { // Setup GitVolume (GlobalDir will be in tmp) globalDir := filepath.Join(tmpDir, "global") gv := createTestGitVolume(repoDir, repoDir, globalDir, nil) - gv.quiet = true + gv.verbosity = VerbosityQuiet // Test 1: Init success err = gv.Init() @@ -64,7 +64,7 @@ func TestInit_OutsideGit(t *testing.T) { require.NoError(t, os.Chdir(tmpDir)) gv := createTestGitVolume(tmpDir, tmpDir, filepath.Join(tmpDir, "global"), nil) - gv.quiet = true + gv.verbosity = VerbosityQuiet // Test: Init fails err = gv.Init() @@ -86,7 +86,7 @@ func TestInit_NonQuiet(t *testing.T) { globalDir := filepath.Join(tmpDir, "global") gv := createTestGitVolume(repoDir, repoDir, globalDir, nil) - gv.quiet = false // Non-quiet to cover afterInit output branches + gv.verbosity = VerbosityNormal // First init (configCreated branch) err = gv.Init() @@ -108,7 +108,7 @@ func TestInit_NonQuiet_Error(t *testing.T) { require.NoError(t, os.Chdir(tmpDir)) gv := createTestGitVolume(tmpDir, tmpDir, filepath.Join(tmpDir, "global"), nil) - gv.quiet = false // Non-quiet to cover error branch + gv.verbosity = VerbosityNormal err = gv.Init() assert.Error(t, err) diff --git a/internal/gitvolume/list.go b/internal/gitvolume/list.go index c683b45..d41cde8 100644 --- a/internal/gitvolume/list.go +++ b/internal/gitvolume/list.go @@ -31,7 +31,7 @@ func (g *GitVolume) GlobalList() error { } if len(state.root.children) == 0 { - if !g.quiet { + if g.isNormalOrHigher() { fmt.Println("Global storage is empty.") } return nil diff --git a/internal/gitvolume/list_test.go b/internal/gitvolume/list_test.go index 8096f3b..4b89556 100644 --- a/internal/gitvolume/list_test.go +++ b/internal/gitvolume/list_test.go @@ -24,7 +24,7 @@ func createTestGitVolumeWithGlobal(globalDir string) *GitVolume { ctx: &Context{ GlobalDir: globalDir, }, - quiet: true, // suppress stdout in tests + verbosity: VerbosityQuiet, } } diff --git a/internal/gitvolume/remove.go b/internal/gitvolume/remove.go index b5b4111..6b36217 100644 --- a/internal/gitvolume/remove.go +++ b/internal/gitvolume/remove.go @@ -33,8 +33,8 @@ func (g *GitVolume) beforeAllRemove() error { } func (g *GitVolume) afterAllRemove(errs []error) error { - if len(errs) > 0 && !g.quiet { - fmt.Printf("❌ Global remove completed with %d error(s)\n", len(errs)) + if len(errs) > 0 && g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Global remove completed with %d error(s)\n", len(errs)) } return errors.Join(errs...) } @@ -74,14 +74,14 @@ func (g *GitVolume) remove(file string) error { func (g *GitVolume) afterRemove(file string, err error, errs *[]error) { if err != nil { - if !g.quiet { - fmt.Printf("❌ Failed to remove %s: %v\n", file, err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Failed to remove %s: %v\n", file, err) } *errs = append(*errs, err) return } - if !g.quiet { + if g.isDetailed() { fmt.Printf("✓ Removed %s\n", file) } } diff --git a/internal/gitvolume/status.go b/internal/gitvolume/status.go index 8267ae9..9be09ac 100644 --- a/internal/gitvolume/status.go +++ b/internal/gitvolume/status.go @@ -21,7 +21,7 @@ func (g *GitVolume) beforeAllStatus() error { return fmt.Errorf("failed to load config: %w", err) } - if !g.quiet { + if g.isNormalOrHigher() { fmt.Printf("📂 Source Config: %s\n", filepath.Join(g.SourceDir(), ConfigFileName)) fmt.Printf("🎯 Target Root: %s\n", g.TargetDir()) if g.HasGlobalVolumes() { @@ -45,6 +45,9 @@ func (g *GitVolume) afterAllStatus(statuses []VolumeStatus, err error) error { if err != nil { return err } + if !g.isNormalOrHigher() { + return nil + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) _, _ = fmt.Fprintln(w, "SOURCE\tTARGET\tMODE\tSTATUS") diff --git a/internal/gitvolume/sync.go b/internal/gitvolume/sync.go index c41cad8..3eafd2b 100644 --- a/internal/gitvolume/sync.go +++ b/internal/gitvolume/sync.go @@ -39,7 +39,7 @@ func (g *GitVolume) beforeAllSync(opts SyncOptions) error { } } - if !g.quiet { + if g.isNormalOrHigher() { fmt.Printf("📂 Using config from: %s\n", g.SourceDir()) fmt.Printf("🎯 Target worktree: %s\n", g.TargetDir()) if g.HasGlobalVolumes() { @@ -51,10 +51,10 @@ func (g *GitVolume) beforeAllSync(opts SyncOptions) error { } func (g *GitVolume) afterAllSync(errs []error, opts SyncOptions) error { - if len(errs) > 0 && !g.quiet { - fmt.Printf("❌ Sync completed with %d error(s)\n", len(errs)) + if len(errs) > 0 && g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Sync completed with %d error(s)\n", len(errs)) } - if len(errs) == 0 && !g.quiet && !opts.DryRun { + if len(errs) == 0 && g.isNormalOrHigher() && !opts.DryRun { fmt.Println("✓ Volumes successfully synced") } return errors.Join(errs...) @@ -124,23 +124,25 @@ func (g *GitVolume) afterSync(vol Volume, opts SyncOptions, err error, errs *[]e displaySource := vol.DisplaySource() if err != nil { - if !g.quiet { - fmt.Printf("❌ Failed to sync %s: %v\n", vol.Target, err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Failed to sync %s: %v\n", vol.Target, err) } *errs = append(*errs, err) return } if opts.DryRun { - action := "link" - if vol.Mode == ModeCopy { - action = "copy" + if g.isNormalOrHigher() { + action := "link" + if vol.Mode == ModeCopy { + action = "copy" + } + fmt.Printf("[dry-run] Would %s %s -> %s\n", action, displaySource, vol.Target) } - fmt.Printf("[dry-run] Would %s %s -> %s\n", action, displaySource, vol.Target) return } - if g.verbose && !g.quiet { + if g.isDetailed() { if vol.Mode == ModeCopy { fmt.Printf("✓ Copied %s -> %s\n", displaySource, vol.Target) } else { @@ -163,7 +165,7 @@ func (g *GitVolume) syncCopy(src, dst string, srcInfo os.FileInfo) error { // syncLink handles link mode synchronization func (g *GitVolume) syncLink(src, dst string, relativeLink bool) error { - // Ensure parent directory exists + // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(dst), DefaultDirPerm); err != nil { return fmt.Errorf("failed to create parent directory: %w", err) } diff --git a/internal/gitvolume/sync_test.go b/internal/gitvolume/sync_test.go index b34a81b..b04c464 100644 --- a/internal/gitvolume/sync_test.go +++ b/internal/gitvolume/sync_test.go @@ -397,7 +397,7 @@ func TestGitVolume_Sync_Verbose(t *testing.T) { } ctx.ResolveVolumePaths() - gv := &GitVolume{ctx: ctx, verbose: true, quiet: false} + gv := &GitVolume{ctx: ctx, verbosity: VerbosityDetailed} // Sync with verbose output require.NoError(t, gv.Sync(SyncOptions{})) @@ -426,7 +426,7 @@ func TestGitVolume_Sync_NonQuiet_Error(t *testing.T) { } ctx.ResolveVolumePaths() - gv := &GitVolume{ctx: ctx, verbose: false, quiet: false} + gv := &GitVolume{ctx: ctx, verbosity: VerbosityNormal} // Should report error non-quietly err := gv.Sync(SyncOptions{}) diff --git a/internal/gitvolume/unsync.go b/internal/gitvolume/unsync.go index de40d27..eda23de 100644 --- a/internal/gitvolume/unsync.go +++ b/internal/gitvolume/unsync.go @@ -38,7 +38,7 @@ func (g *GitVolume) beforeAllUnsync(opts UnsyncOptions) error { } } - if !g.quiet { + if g.isNormalOrHigher() { fmt.Printf("📂 Using config from: %s\n", g.SourceDir()) } @@ -46,10 +46,10 @@ func (g *GitVolume) beforeAllUnsync(opts UnsyncOptions) error { } func (g *GitVolume) afterAllUnsync(errs []error, opts UnsyncOptions) error { - if len(errs) > 0 && !g.quiet { - fmt.Printf("❌ Unsync completed with %d error(s)\n", len(errs)) + if len(errs) > 0 && g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Unsync completed with %d error(s)\n", len(errs)) } - if len(errs) == 0 && !g.quiet && !opts.DryRun { + if len(errs) == 0 && g.isNormalOrHigher() && !opts.DryRun { fmt.Println("✓ Unsync complete") } return errors.Join(errs...) @@ -70,21 +70,23 @@ func (g *GitVolume) beforeUnsync(vol Volume) error { func (g *GitVolume) unsync(vol Volume, opts UnsyncOptions) error { removable, err := g.checkRemovable(vol) if err != nil { - if !g.quiet { - fmt.Printf("⚠️ Skipping %s: %v\n", vol.Target, err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "⚠️ Skipping %s: %v\n", vol.Target, err) } return nil } if !removable { - if !g.quiet { - fmt.Printf("⚠️ Skipping %s: modified or not managed by us\n", vol.Target) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "⚠️ Skipping %s: modified or not managed by us\n", vol.Target) } return nil } if opts.DryRun { - fmt.Printf("[dry-run] Would remove %s\n", vol.Target) + if g.isNormalOrHigher() { + fmt.Printf("[dry-run] Would remove %s\n", vol.Target) + } return nil } @@ -93,8 +95,8 @@ func (g *GitVolume) unsync(vol Volume, opts UnsyncOptions) error { func (g *GitVolume) afterUnsync(vol Volume, opts UnsyncOptions, err error, errs *[]error) { if err != nil { - if !g.quiet { - fmt.Printf("❌ Failed to unsync %s: %v\n", vol.Target, err) + if g.isNormalOrHigher() { + fmt.Fprintf(os.Stderr, "❌ Failed to unsync %s: %v\n", vol.Target, err) } *errs = append(*errs, err) } @@ -166,7 +168,7 @@ func (g *GitVolume) removeVolume(vol Volume) error { } } - if !g.quiet { + if g.isDetailed() { fmt.Printf("✓ Removed %s\n", vol.Target) } diff --git a/test/integration.sh b/test/integration.sh index d5bf224..ea22f10 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -51,7 +51,7 @@ git init -q git config user.name "test" git config user.email "test@test.com" -"$GV_BIN" init -q +"$GV_BIN" init --verbose 0 if [[ -d "$TEST_DIR/.git-volume" && -f "git-volume.yaml" ]]; then pass "init created necessary files" else @@ -340,7 +340,7 @@ volumes: EOF # Sync -"$GV_BIN" sync -q +"$GV_BIN" sync --verbose 0 # Verify directory was copied if [[ -d "copied_dir" && -f "copied_dir/a.txt" && -f "copied_dir/nested/b.txt" ]]; then @@ -350,7 +350,7 @@ else fi # Unsync (unmodified directory should be removed) -"$GV_BIN" unsync -q +"$GV_BIN" unsync --verbose 0 if [[ ! -d "copied_dir" ]]; then pass "unsync removed copy directory" @@ -362,13 +362,13 @@ fi log "TEST" "Testing 'unsync' preserves modified copy directory..." # Re-sync -"$GV_BIN" sync -q +"$GV_BIN" sync --verbose 0 # Modify a file inside the copied directory echo "MODIFIED" > copied_dir/a.txt # Unsync -"$GV_BIN" unsync -q +"$GV_BIN" unsync --verbose 0 if [[ -d "copied_dir" ]]; then CONTENT=$(cat copied_dir/a.txt)