diff --git a/README.md b/README.md index a3eff3c..4a3a5ac 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Manual logging is supported for non-code work (research, analysis, meetings) via - [Commands](#commands) - [Time Tracking](#time-tracking) — init, log, edit, remove, sync, report, history, status - [Project Management](#project-management) — project add/assign/edit/list/remove - - [Schedule Configuration](#schedule-configuration) — config get/set/reset/report - - [Default Schedule](#default-schedule) — defaults get/set/reset/report + - [Schedule Configuration](#schedule-configuration) — project schedule get/set/reset/report + - [Default Schedule](#default-schedule) — defaults schedule get/set/reset/report - [Shell Completions](#shell-completions) — completion install/generate - [Other](#other) — version, watch - [Precise Mode](#precise-mode) @@ -324,14 +324,15 @@ hourgit project add [--mode ] #### `hourgit project assign` -Assign the current repository to a project. +Assign the current repository to a project. The project name is optional — if omitted, the project is auto-detected from the current repository. ```bash -hourgit project assign [--force] [--yes] +hourgit project assign [PROJECT] [--project ] [--force] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| +| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-f`, `--force` | `false` | Reassign repository to a different project | | `-y`, `--yes` | `false` | Skip confirmation prompt | @@ -375,52 +376,53 @@ No flags. #### `hourgit project remove` -Remove a project and clean up its repository assignments. +Remove a project and clean up its repository assignments. The project name is optional — if omitted, the project is auto-detected from the current repository. ```bash -hourgit project remove [--yes] +hourgit project remove [PROJECT] [--project ] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| +| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-y`, `--yes` | `false` | Skip confirmation prompt | ### Schedule Configuration Manage per-project schedule configuration. If `--project` is omitted, the project is auto-detected from the current repository. -Commands: `config get` · `config set` · `config reset` · `config report` +Commands: `project schedule get` · `project schedule set` · `project schedule reset` · `project schedule report` -#### `hourgit config get` +#### `hourgit project schedule get` Show the schedule configuration for a project. ```bash -hourgit config get [--project ] +hourgit project schedule get [--project ] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Project name or ID | -#### `hourgit config set` +#### `hourgit project schedule set` Interactively edit a project's schedule using a guided schedule builder. ```bash -hourgit config set [--project ] +hourgit project schedule set [--project ] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Project name or ID | -#### `hourgit config reset` +#### `hourgit project schedule reset` Reset a project's schedule to the defaults. ```bash -hourgit config reset [--project ] [--yes] +hourgit project schedule reset [--project ] [--yes] ``` | Flag | Default | Description | @@ -428,12 +430,12 @@ hourgit config reset [--project ] [--yes] | `-p`, `--project` | auto-detect | Project name or ID | | `-y`, `--yes` | `false` | Skip confirmation prompt | -#### `hourgit config report` +#### `hourgit project schedule report` Show expanded working hours for a given month (resolves schedule rules into concrete days and time ranges). ```bash -hourgit config report [--project ] [--month <1-12>] [--year ] +hourgit project schedule report [--project ] [--month <1-12>] [--year ] ``` | Flag | Default | Description | @@ -446,46 +448,46 @@ hourgit config report [--project ] [--month <1-12>] [--year ] Manage the default schedule applied to new projects. -Commands: `defaults get` · `defaults set` · `defaults reset` · `defaults report` +Commands: `defaults schedule get` · `defaults schedule set` · `defaults schedule reset` · `defaults schedule report` -#### `hourgit defaults get` +#### `hourgit defaults schedule get` Show the default schedule for new projects. ```bash -hourgit defaults get +hourgit defaults schedule get ``` No flags. -#### `hourgit defaults set` +#### `hourgit defaults schedule set` Interactively edit the default schedule for new projects. ```bash -hourgit defaults set +hourgit defaults schedule set ``` No flags. -#### `hourgit defaults reset` +#### `hourgit defaults schedule reset` Reset the default schedule to factory settings (Mon-Fri, 9 AM - 5 PM). ```bash -hourgit defaults reset [--yes] +hourgit defaults schedule reset [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-y`, `--yes` | `false` | Skip confirmation prompt | -#### `hourgit defaults report` +#### `hourgit defaults schedule report` Show expanded default working hours for a given month. ```bash -hourgit defaults report [--month <1-12>] [--year ] +hourgit defaults schedule report [--month <1-12>] [--year ] ``` | Flag | Default | Description | @@ -612,7 +614,7 @@ Hourgit uses a schedule system to define working hours. The factory default is * ### Schedule types -The interactive schedule editor (`config set` / `defaults set`) supports three schedule types: +The interactive schedule editor (`project schedule set` / `defaults schedule set`) supports three schedule types: - **Recurring** — repeats on a regular pattern (e.g., every weekday, every Monday/Wednesday/Friday) - **One-off** — applies to a single specific date (e.g., a holiday or overtime day) @@ -622,7 +624,7 @@ Each schedule entry defines one or more time ranges for the days it covers. Mult ### Per-project overrides -Every project starts with a copy of the defaults. You can then customize a project's schedule independently using `hourgit config set --project NAME`. To revert a project back to the current defaults, use `hourgit config reset --project NAME`. +Every project starts with a copy of the defaults. You can then customize a project's schedule independently using `hourgit project schedule set --project NAME`. To revert a project back to the current defaults, use `hourgit project schedule reset --project NAME`. ## Data Storage diff --git a/internal/cli/config.go b/internal/cli/config.go deleted file mode 100644 index a33fde7..0000000 --- a/internal/cli/config.go +++ /dev/null @@ -1,14 +0,0 @@ -package cli - -import "github.com/spf13/cobra" - -var configCmd = GroupCommand{ - Use: "config", - Short: "Manage project configuration", - Subcommands: []*cobra.Command{ - configGetCmd, - configSetCmd, - configResetCmd, - configReportCmd, - }, -}.Build() diff --git a/internal/cli/defaults.go b/internal/cli/defaults.go index abb6cfa..69fb4ba 100644 --- a/internal/cli/defaults.go +++ b/internal/cli/defaults.go @@ -4,11 +4,8 @@ import "github.com/spf13/cobra" var defaultsCmd = GroupCommand{ Use: "defaults", - Short: "Manage default schedule for new projects", + Short: "Manage defaults for new projects", Subcommands: []*cobra.Command{ - defaultsGetCmd, - defaultsSetCmd, - defaultsResetCmd, - defaultsReportCmd, + defaultsScheduleCmd, }, }.Build() diff --git a/internal/cli/defaults_schedule.go b/internal/cli/defaults_schedule.go new file mode 100644 index 0000000..88c560b --- /dev/null +++ b/internal/cli/defaults_schedule.go @@ -0,0 +1,14 @@ +package cli + +import "github.com/spf13/cobra" + +var defaultsScheduleCmd = GroupCommand{ + Use: "schedule", + Short: "Manage default schedule for new projects", + Subcommands: []*cobra.Command{ + defaultsScheduleGetCmd, + defaultsScheduleSetCmd, + defaultsScheduleResetCmd, + defaultsScheduleReportCmd, + }, +}.Build() diff --git a/internal/cli/defaults_get.go b/internal/cli/defaults_schedule_get.go similarity index 78% rename from internal/cli/defaults_get.go rename to internal/cli/defaults_schedule_get.go index 014b6cf..21a174e 100644 --- a/internal/cli/defaults_get.go +++ b/internal/cli/defaults_schedule_get.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -var defaultsGetCmd = LeafCommand{ +var defaultsScheduleGetCmd = LeafCommand{ Use: "get", Short: "Show the default schedule for new projects", RunE: func(cmd *cobra.Command, args []string) error { @@ -16,11 +16,11 @@ var defaultsGetCmd = LeafCommand{ if err != nil { return err } - return runDefaultsGet(cmd, homeDir) + return runDefaultsScheduleGet(cmd, homeDir) }, }.Build() -func runDefaultsGet(cmd *cobra.Command, homeDir string) error { +func runDefaultsScheduleGet(cmd *cobra.Command, homeDir string) error { cfg, err := project.ReadConfig(homeDir) if err != nil { return err diff --git a/internal/cli/defaults_get_test.go b/internal/cli/defaults_schedule_get_test.go similarity index 59% rename from internal/cli/defaults_get_test.go rename to internal/cli/defaults_schedule_get_test.go index 02d925c..0c1d0a4 100644 --- a/internal/cli/defaults_get_test.go +++ b/internal/cli/defaults_schedule_get_test.go @@ -10,18 +10,18 @@ import ( "github.com/stretchr/testify/require" ) -func execDefaultsGet(homeDir string) (string, error) { +func execDefaultsScheduleGet(homeDir string) (string, error) { stdout := new(bytes.Buffer) - cmd := defaultsGetCmd + cmd := defaultsScheduleGetCmd cmd.SetOut(stdout) - err := runDefaultsGet(cmd, homeDir) + err := runDefaultsScheduleGet(cmd, homeDir) return stdout.String(), err } -func TestDefaultsGetFactoryDefaults(t *testing.T) { +func TestDefaultsScheduleGetFactoryDefaults(t *testing.T) { homeDir := t.TempDir() - stdout, err := execDefaultsGet(homeDir) + stdout, err := execDefaultsScheduleGet(homeDir) assert.NoError(t, err) assert.Contains(t, stdout, "Default schedule for new projects") @@ -29,7 +29,7 @@ func TestDefaultsGetFactoryDefaults(t *testing.T) { assert.Contains(t, stdout, "every weekday") } -func TestDefaultsGetCustomDefaults(t *testing.T) { +func TestDefaultsScheduleGetCustomDefaults(t *testing.T) { homeDir := t.TempDir() custom := []schedule.ScheduleEntry{ @@ -37,17 +37,26 @@ func TestDefaultsGetCustomDefaults(t *testing.T) { } require.NoError(t, project.SetDefaults(homeDir, custom)) - stdout, err := execDefaultsGet(homeDir) + stdout, err := execDefaultsScheduleGet(homeDir) assert.NoError(t, err) assert.Contains(t, stdout, "8:00 AM - 12:00 PM") } -func TestDefaultsGetRegisteredAsSubcommand(t *testing.T) { - commands := defaultsCmd.Commands() +func TestDefaultsScheduleGetRegisteredAsSubcommand(t *testing.T) { + commands := defaultsScheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() } assert.Contains(t, names, "get") } + +func TestDefaultsScheduleRegisteredUnderDefaults(t *testing.T) { + commands := defaultsCmd.Commands() + names := make([]string, len(commands)) + for i, cmd := range commands { + names[i] = cmd.Name() + } + assert.Contains(t, names, "schedule") +} diff --git a/internal/cli/defaults_report.go b/internal/cli/defaults_schedule_report.go similarity index 77% rename from internal/cli/defaults_report.go rename to internal/cli/defaults_schedule_report.go index 9036892..89f1ea7 100644 --- a/internal/cli/defaults_report.go +++ b/internal/cli/defaults_schedule_report.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -var defaultsReportCmd = LeafCommand{ +var defaultsScheduleReportCmd = LeafCommand{ Use: "report", Short: "Show expanded default working hours for a given month", StrFlags: []StringFlag{ @@ -24,11 +24,11 @@ var defaultsReportCmd = LeafCommand{ monthFlag, _ := cmd.Flags().GetString("month") yearFlag, _ := cmd.Flags().GetString("year") - return runDefaultsReport(cmd, homeDir, monthFlag, yearFlag, time.Now()) + return runDefaultsScheduleReport(cmd, homeDir, monthFlag, yearFlag, time.Now()) }, }.Build() -func runDefaultsReport(cmd *cobra.Command, homeDir, monthFlag, yearFlag string, now time.Time) error { +func runDefaultsScheduleReport(cmd *cobra.Command, homeDir, monthFlag, yearFlag string, now time.Time) error { cfg, err := project.ReadConfig(homeDir) if err != nil { return err diff --git a/internal/cli/defaults_report_test.go b/internal/cli/defaults_schedule_report_test.go similarity index 68% rename from internal/cli/defaults_report_test.go rename to internal/cli/defaults_schedule_report_test.go index 700d4e7..d116d13 100644 --- a/internal/cli/defaults_report_test.go +++ b/internal/cli/defaults_schedule_report_test.go @@ -11,19 +11,19 @@ import ( "github.com/stretchr/testify/require" ) -func execDefaultsReport(homeDir, monthFlag, yearFlag string, now time.Time) (string, error) { +func execDefaultsScheduleReport(homeDir, monthFlag, yearFlag string, now time.Time) (string, error) { stdout := new(bytes.Buffer) - cmd := defaultsReportCmd + cmd := defaultsScheduleReportCmd cmd.SetOut(stdout) - err := runDefaultsReport(cmd, homeDir, monthFlag, yearFlag, now) + err := runDefaultsScheduleReport(cmd, homeDir, monthFlag, yearFlag, now) return stdout.String(), err } -func TestDefaultsReportFactoryDefaults(t *testing.T) { +func TestDefaultsScheduleReportFactoryDefaults(t *testing.T) { homeDir := t.TempDir() now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - stdout, err := execDefaultsReport(homeDir, "", "", now) + stdout, err := execDefaultsScheduleReport(homeDir, "", "", now) assert.NoError(t, err) assert.Contains(t, stdout, "Default working hours") @@ -36,7 +36,7 @@ func TestDefaultsReportFactoryDefaults(t *testing.T) { assert.NotContains(t, stdout, "Sun ") } -func TestDefaultsReportCustomDefaults(t *testing.T) { +func TestDefaultsScheduleReportCustomDefaults(t *testing.T) { homeDir := t.TempDir() now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) @@ -45,7 +45,7 @@ func TestDefaultsReportCustomDefaults(t *testing.T) { } require.NoError(t, project.SetDefaults(homeDir, custom)) - stdout, err := execDefaultsReport(homeDir, "", "", now) + stdout, err := execDefaultsScheduleReport(homeDir, "", "", now) assert.NoError(t, err) assert.Contains(t, stdout, "10:00 AM - 2:00 PM") @@ -54,19 +54,19 @@ func TestDefaultsReportCustomDefaults(t *testing.T) { assert.Contains(t, stdout, "Sun ") } -func TestDefaultsReportWithMonthAndYearFlags(t *testing.T) { +func TestDefaultsScheduleReportWithMonthAndYearFlags(t *testing.T) { homeDir := t.TempDir() now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - stdout, err := execDefaultsReport(homeDir, "3", "2025", now) + stdout, err := execDefaultsScheduleReport(homeDir, "3", "2025", now) assert.NoError(t, err) assert.Contains(t, stdout, "March 2025") assert.Contains(t, stdout, "9:00 AM - 5:00 PM") } -func TestDefaultsReportRegisteredAsSubcommand(t *testing.T) { - commands := defaultsCmd.Commands() +func TestDefaultsScheduleReportRegisteredAsSubcommand(t *testing.T) { + commands := defaultsScheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() diff --git a/internal/cli/defaults_reset.go b/internal/cli/defaults_schedule_reset.go similarity index 82% rename from internal/cli/defaults_reset.go rename to internal/cli/defaults_schedule_reset.go index dd82115..09a8a02 100644 --- a/internal/cli/defaults_reset.go +++ b/internal/cli/defaults_schedule_reset.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -var defaultsResetCmd = LeafCommand{ +var defaultsScheduleResetCmd = LeafCommand{ Use: "reset", Short: "Reset the default schedule to factory settings (Mon-Fri 9am-5pm)", BoolFlags: []BoolFlag{ @@ -23,11 +23,11 @@ var defaultsResetCmd = LeafCommand{ yes, _ := cmd.Flags().GetBool("yes") confirm := ResolveConfirmFunc(yes) - return runDefaultsReset(cmd, homeDir, confirm) + return runDefaultsScheduleReset(cmd, homeDir, confirm) }, }.Build() -func runDefaultsReset(cmd *cobra.Command, homeDir string, confirm ConfirmFunc) error { +func runDefaultsScheduleReset(cmd *cobra.Command, homeDir string, confirm ConfirmFunc) error { confirmed, err := confirm("Reset defaults to factory settings (Mon-Fri 9am-5pm)?") if err != nil { return err diff --git a/internal/cli/defaults_reset_test.go b/internal/cli/defaults_schedule_reset_test.go similarity index 73% rename from internal/cli/defaults_reset_test.go rename to internal/cli/defaults_schedule_reset_test.go index 690db07..1d430cd 100644 --- a/internal/cli/defaults_reset_test.go +++ b/internal/cli/defaults_schedule_reset_test.go @@ -10,15 +10,15 @@ import ( "github.com/stretchr/testify/require" ) -func execDefaultsReset(homeDir string, confirm ConfirmFunc) (string, error) { +func execDefaultsScheduleReset(homeDir string, confirm ConfirmFunc) (string, error) { stdout := new(bytes.Buffer) - cmd := defaultsResetCmd + cmd := defaultsScheduleResetCmd cmd.SetOut(stdout) - err := runDefaultsReset(cmd, homeDir, confirm) + err := runDefaultsScheduleReset(cmd, homeDir, confirm) return stdout.String(), err } -func TestDefaultsResetHappyPath(t *testing.T) { +func TestDefaultsScheduleResetHappyPath(t *testing.T) { homeDir := t.TempDir() // Set custom defaults first @@ -27,7 +27,7 @@ func TestDefaultsResetHappyPath(t *testing.T) { } require.NoError(t, project.SetDefaults(homeDir, custom)) - stdout, err := execDefaultsReset(homeDir, AlwaysYes()) + stdout, err := execDefaultsScheduleReset(homeDir, AlwaysYes()) assert.NoError(t, err) assert.Contains(t, stdout, "defaults reset to factory settings") @@ -38,7 +38,7 @@ func TestDefaultsResetHappyPath(t *testing.T) { assert.Equal(t, schedule.DefaultSchedules(), project.GetDefaults(cfg)) } -func TestDefaultsResetDeclined(t *testing.T) { +func TestDefaultsScheduleResetDeclined(t *testing.T) { homeDir := t.TempDir() custom := []schedule.ScheduleEntry{ @@ -47,7 +47,7 @@ func TestDefaultsResetDeclined(t *testing.T) { require.NoError(t, project.SetDefaults(homeDir, custom)) decline := func(_ string) (bool, error) { return false, nil } - stdout, err := execDefaultsReset(homeDir, decline) + stdout, err := execDefaultsScheduleReset(homeDir, decline) assert.NoError(t, err) assert.Contains(t, stdout, "cancelled") @@ -58,8 +58,8 @@ func TestDefaultsResetDeclined(t *testing.T) { assert.Equal(t, custom, project.GetDefaults(cfg)) } -func TestDefaultsResetRegisteredAsSubcommand(t *testing.T) { - commands := defaultsCmd.Commands() +func TestDefaultsScheduleResetRegisteredAsSubcommand(t *testing.T) { + commands := defaultsScheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() diff --git a/internal/cli/defaults_set.go b/internal/cli/defaults_schedule_set.go similarity index 78% rename from internal/cli/defaults_set.go rename to internal/cli/defaults_schedule_set.go index dd9794f..718f06b 100644 --- a/internal/cli/defaults_set.go +++ b/internal/cli/defaults_schedule_set.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -var defaultsSetCmd = LeafCommand{ +var defaultsScheduleSetCmd = LeafCommand{ Use: "set", Short: "Interactively edit the default schedule for new projects", RunE: func(cmd *cobra.Command, args []string) error { @@ -17,11 +17,11 @@ var defaultsSetCmd = LeafCommand{ return err } kit := NewPromptKit() - return runDefaultsSet(cmd, homeDir, kit) + return runDefaultsScheduleSet(cmd, homeDir, kit) }, }.Build() -func runDefaultsSet(cmd *cobra.Command, homeDir string, kit PromptKit) error { +func runDefaultsScheduleSet(cmd *cobra.Command, homeDir string, kit PromptKit) error { cfg, err := project.ReadConfig(homeDir) if err != nil { return err diff --git a/internal/cli/defaults_set_test.go b/internal/cli/defaults_schedule_set_test.go similarity index 69% rename from internal/cli/defaults_set_test.go rename to internal/cli/defaults_schedule_set_test.go index e644c51..aa6acf4 100644 --- a/internal/cli/defaults_set_test.go +++ b/internal/cli/defaults_schedule_set_test.go @@ -9,15 +9,15 @@ import ( "github.com/stretchr/testify/require" ) -func execDefaultsSet(homeDir string, kit PromptKit) (string, error) { +func execDefaultsScheduleSet(homeDir string, kit PromptKit) (string, error) { stdout := new(bytes.Buffer) - cmd := defaultsSetCmd + cmd := defaultsScheduleSetCmd cmd.SetOut(stdout) - err := runDefaultsSet(cmd, homeDir, kit) + err := runDefaultsScheduleSet(cmd, homeDir, kit) return stdout.String(), err } -func TestDefaultsSetQuitImmediately(t *testing.T) { +func TestDefaultsScheduleSetQuitImmediately(t *testing.T) { homeDir := t.TempDir() kit := testKit( @@ -26,13 +26,13 @@ func TestDefaultsSetQuitImmediately(t *testing.T) { mockConfirm(false), mockMultiSelect(nil), ) - stdout, err := execDefaultsSet(homeDir, kit) + stdout, err := execDefaultsScheduleSet(homeDir, kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") } -func TestDefaultsSetAddAndQuit(t *testing.T) { +func TestDefaultsScheduleSetAddAndQuit(t *testing.T) { homeDir := t.TempDir() // Add(0): recurring(0) > every weekend(1) > 10am-2pm > no more ranges @@ -43,7 +43,7 @@ func TestDefaultsSetAddAndQuit(t *testing.T) { mockConfirmSequence(false, false), // no more ranges, no overlap mockMultiSelect(nil), ) - stdout, err := execDefaultsSet(homeDir, kit) + stdout, err := execDefaultsScheduleSet(homeDir, kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -55,8 +55,8 @@ func TestDefaultsSetAddAndQuit(t *testing.T) { assert.Len(t, defaults, 2) // original default + new } -func TestDefaultsSetRegisteredAsSubcommand(t *testing.T) { - commands := defaultsCmd.Commands() +func TestDefaultsScheduleSetRegisteredAsSubcommand(t *testing.T) { + commands := defaultsScheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() diff --git a/internal/cli/project.go b/internal/cli/project.go index 3ac9088..0e6b0ab 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -11,5 +11,6 @@ var projectCmd = GroupCommand{ projectEditCmd, projectListCmd, projectRemoveCmd, + scheduleCmd, }, }.Build() diff --git a/internal/cli/project_assign.go b/internal/cli/project_assign.go index 127df94..548b49e 100644 --- a/internal/cli/project_assign.go +++ b/internal/cli/project_assign.go @@ -11,13 +11,16 @@ import ( ) var projectAssignCmd = LeafCommand{ - Use: "assign PROJECT", + Use: "assign [PROJECT]", Short: "Assign repository to a project", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), BoolFlags: []BoolFlag{ {Name: "force", Shorthand: "f", Usage: "reassign repository to a different project"}, {Name: "yes", Shorthand: "y", Usage: "skip confirmation prompt"}, }, + StrFlags: []StringFlag{ + {Name: "project", Shorthand: "p", Usage: "project name or ID"}, + }, RunE: func(cmd *cobra.Command, args []string) error { dir, err := os.Getwd() if err != nil { @@ -31,10 +34,25 @@ var projectAssignCmd = LeafCommand{ force, _ := cmd.Flags().GetBool("force") yes, _ := cmd.Flags().GetBool("yes") + projectFlag, _ := cmd.Flags().GetString("project") confirm := ResolveConfirmFunc(yes) - return runProjectAssign(cmd, dir, homeDir, args[0], force, confirm) + // Resolve project name: positional arg > --project flag > repo config + var projectName string + if len(args) > 0 { + projectName = args[0] + } else if projectFlag != "" { + projectName = projectFlag + } else { + entry, err := ResolveProjectContext(homeDir, dir, "") + if err != nil { + return err + } + projectName = entry.Name + } + + return runProjectAssign(cmd, dir, homeDir, projectName, force, confirm) }, }.Build() diff --git a/internal/cli/project_assign_test.go b/internal/cli/project_assign_test.go index 3ed7442..8bcc7ad 100644 --- a/internal/cli/project_assign_test.go +++ b/internal/cli/project_assign_test.go @@ -237,6 +237,20 @@ func TestProjectAssignDifferentProjectWithForce(t *testing.T) { assert.Equal(t, "Project B", repoCfg.Project) } +func TestProjectAssignAutoDetectFromRepo(t *testing.T) { + dir := setupProjectTest(t) + home := os.Getenv("HOME") + + // First assign the project + _, _, err := execProjectAssign(dir, AlwaysYes(), "Auto Project") + require.NoError(t, err) + + // Verify auto-detection works via ResolveProjectContext (same as RunE fallback) + resolved, err := ResolveProjectContext(home, dir, "") + require.NoError(t, err) + assert.Equal(t, "Auto Project", resolved.Name) +} + func TestProjectAssignRegisteredAsSubcommand(t *testing.T) { commands := projectCmd.Commands() names := make([]string, len(commands)) diff --git a/internal/cli/project_remove.go b/internal/cli/project_remove.go index fa948b2..c774cfa 100644 --- a/internal/cli/project_remove.go +++ b/internal/cli/project_remove.go @@ -10,22 +10,43 @@ import ( ) var projectRemoveCmd = LeafCommand{ - Use: "remove PROJECT", + Use: "remove [PROJECT]", Short: "Remove a project and clean up its repository assignments", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), BoolFlags: []BoolFlag{ {Name: "yes", Shorthand: "y", Usage: "skip confirmation prompt"}, }, + StrFlags: []StringFlag{ + {Name: "project", Shorthand: "p", Usage: "project name or ID"}, + }, RunE: func(cmd *cobra.Command, args []string) error { homeDir, err := os.UserHomeDir() if err != nil { return err } + repoDir, _ := os.Getwd() + projectFlag, _ := cmd.Flags().GetString("project") yes, _ := cmd.Flags().GetBool("yes") confirm := ResolveConfirmFunc(yes) - return runProjectRemove(cmd, homeDir, args[0], confirm) + // Resolve project identifier: positional arg > --project flag > repo config + var identifier string + if len(args) > 0 { + identifier = args[0] + } else if projectFlag != "" { + identifier = projectFlag + } + + if identifier == "" { + entry, err := ResolveProjectContext(homeDir, repoDir, "") + if err != nil { + return err + } + identifier = entry.Name + } + + return runProjectRemove(cmd, homeDir, identifier, confirm) }, }.Build() diff --git a/internal/cli/project_remove_test.go b/internal/cli/project_remove_test.go index f0038f0..b46c988 100644 --- a/internal/cli/project_remove_test.go +++ b/internal/cli/project_remove_test.go @@ -152,6 +152,49 @@ func TestProjectRemoveDeletesEntryDirectory(t *testing.T) { assert.True(t, os.IsNotExist(err), "entry directory should be deleted") } +func TestProjectRemoveAutoDetectFromRepo(t *testing.T) { + home := t.TempDir() + repo := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0755)) + + entry, err := project.CreateProject(home, "Auto Project") + require.NoError(t, err) + require.NoError(t, project.AssignProject(home, repo, entry)) + + // Use ResolveProjectContext to simulate auto-detection (same as RunE fallback) + resolved, err := ResolveProjectContext(home, repo, "") + require.NoError(t, err) + assert.Equal(t, "Auto Project", resolved.Name) + + // Now actually remove using the resolved identifier + stdout, err := execProjectRemove(home, resolved.Name, AlwaysYes()) + + assert.NoError(t, err) + assert.Contains(t, stdout, "project 'Auto Project' removed") +} + +func TestProjectRemoveAutoDetectNoRepo(t *testing.T) { + home := t.TempDir() + + _, err := ResolveProjectContext(home, "", "") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no project found") +} + +func TestProjectRemoveAutoDetectNoAssignment(t *testing.T) { + home := t.TempDir() + repo := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0755)) + + _, err := ResolveProjectContext(home, repo, "") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no project found") +} + func TestProjectRemoveRegisteredAsSubcommand(t *testing.T) { commands := projectCmd.Commands() names := make([]string, len(commands)) diff --git a/internal/cli/root.go b/internal/cli/root.go index 2c0068e..a9ed2a1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,7 +23,6 @@ func newRootCmd() *cobra.Command { statusCmd, versionCmd, projectCmd, - configCmd, defaultsCmd, completionCmd, updateCmd, diff --git a/internal/cli/schedule_cmd.go b/internal/cli/schedule_cmd.go new file mode 100644 index 0000000..fbd9a5e --- /dev/null +++ b/internal/cli/schedule_cmd.go @@ -0,0 +1,14 @@ +package cli + +import "github.com/spf13/cobra" + +var scheduleCmd = GroupCommand{ + Use: "schedule", + Short: "Manage project schedule", + Subcommands: []*cobra.Command{ + scheduleGetCmd, + scheduleSetCmd, + scheduleResetCmd, + scheduleReportCmd, + }, +}.Build() diff --git a/internal/cli/config_get.go b/internal/cli/schedule_get.go similarity index 83% rename from internal/cli/config_get.go rename to internal/cli/schedule_get.go index 79f723e..17f91b9 100644 --- a/internal/cli/config_get.go +++ b/internal/cli/schedule_get.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -var configGetCmd = LeafCommand{ +var scheduleGetCmd = LeafCommand{ Use: "get", Short: "Show the schedule configuration for a project", StrFlags: []StringFlag{ @@ -21,11 +21,11 @@ var configGetCmd = LeafCommand{ projectFlag, _ := cmd.Flags().GetString("project") - return runConfigGet(cmd, homeDir, repoDir, projectFlag) + return runScheduleGet(cmd, homeDir, repoDir, projectFlag) }, }.Build() -func runConfigGet(cmd *cobra.Command, homeDir, repoDir, projectFlag string) error { +func runScheduleGet(cmd *cobra.Command, homeDir, repoDir, projectFlag string) error { entry, err := ResolveProjectContext(homeDir, repoDir, projectFlag) if err != nil { return err diff --git a/internal/cli/config_get_test.go b/internal/cli/schedule_get_test.go similarity index 62% rename from internal/cli/config_get_test.go rename to internal/cli/schedule_get_test.go index 7fa5b09..ff543a5 100644 --- a/internal/cli/config_get_test.go +++ b/internal/cli/schedule_get_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func setupConfigTest(t *testing.T) (homeDir string, repoDir string, entry *project.ProjectEntry) { +func setupScheduleTest(t *testing.T) (homeDir string, repoDir string, entry *project.ProjectEntry) { t.Helper() homeDir = t.TempDir() repoDir = t.TempDir() @@ -31,18 +31,18 @@ func setupConfigTest(t *testing.T) (homeDir string, repoDir string, entry *proje return homeDir, repoDir, entry } -func execConfigGet(homeDir, repoDir, projectFlag string) (string, error) { +func execScheduleGet(homeDir, repoDir, projectFlag string) (string, error) { stdout := new(bytes.Buffer) - cmd := configGetCmd + cmd := scheduleGetCmd cmd.SetOut(stdout) - err := runConfigGet(cmd, homeDir, repoDir, projectFlag) + err := runScheduleGet(cmd, homeDir, repoDir, projectFlag) return stdout.String(), err } -func TestConfigGetDefaultSchedule(t *testing.T) { - homeDir, repoDir, _ := setupConfigTest(t) +func TestScheduleGetDefaultSchedule(t *testing.T) { + homeDir, repoDir, _ := setupScheduleTest(t) - stdout, err := execConfigGet(homeDir, repoDir, "") + stdout, err := execScheduleGet(homeDir, repoDir, "") assert.NoError(t, err) assert.Contains(t, stdout, "Schedule for") @@ -51,18 +51,18 @@ func TestConfigGetDefaultSchedule(t *testing.T) { assert.Contains(t, stdout, "every weekday") } -func TestConfigGetByProjectFlag(t *testing.T) { - homeDir, _, entry := setupConfigTest(t) +func TestScheduleGetByProjectFlag(t *testing.T) { + homeDir, _, entry := setupScheduleTest(t) - stdout, err := execConfigGet(homeDir, "", entry.Name) + stdout, err := execScheduleGet(homeDir, "", entry.Name) assert.NoError(t, err) assert.Contains(t, stdout, "Test Project") assert.Contains(t, stdout, "9:00 AM - 5:00 PM") } -func TestConfigGetCustomSchedule(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleGetCustomSchedule(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) custom := []schedule.ScheduleEntry{ {Ranges: []schedule.TimeRange{{From: "08:00", To: "12:00"}}, RRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR"}, @@ -70,7 +70,7 @@ func TestConfigGetCustomSchedule(t *testing.T) { } require.NoError(t, project.SetSchedules(homeDir, entry.ID, custom)) - stdout, err := execConfigGet(homeDir, repoDir, "") + stdout, err := execScheduleGet(homeDir, repoDir, "") assert.NoError(t, err) assert.Contains(t, stdout, "8:00 AM - 12:00 PM") @@ -79,20 +79,29 @@ func TestConfigGetCustomSchedule(t *testing.T) { assert.Contains(t, stdout, "2.") } -func TestConfigGetNoProject(t *testing.T) { +func TestScheduleGetNoProject(t *testing.T) { homeDir := t.TempDir() - _, err := execConfigGet(homeDir, "", "") + _, err := execScheduleGet(homeDir, "", "") assert.Error(t, err) assert.Contains(t, err.Error(), "no project found") } -func TestConfigGetRegisteredAsSubcommand(t *testing.T) { - commands := configCmd.Commands() +func TestScheduleGetRegisteredAsSubcommand(t *testing.T) { + commands := scheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() } assert.Contains(t, names, "get") } + +func TestScheduleRegisteredUnderProject(t *testing.T) { + commands := projectCmd.Commands() + names := make([]string, len(commands)) + for i, cmd := range commands { + names[i] = cmd.Name() + } + assert.Contains(t, names, "schedule") +} diff --git a/internal/cli/config_report.go b/internal/cli/schedule_report_cmd.go similarity index 81% rename from internal/cli/config_report.go rename to internal/cli/schedule_report_cmd.go index 71edc8d..1e01ff2 100644 --- a/internal/cli/config_report.go +++ b/internal/cli/schedule_report_cmd.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -var configReportCmd = LeafCommand{ +var scheduleReportCmd = LeafCommand{ Use: "report", Short: "Show expanded working hours for a given month", StrFlags: []StringFlag{ @@ -26,11 +26,11 @@ var configReportCmd = LeafCommand{ monthFlag, _ := cmd.Flags().GetString("month") yearFlag, _ := cmd.Flags().GetString("year") - return runConfigReport(cmd, homeDir, repoDir, projectFlag, monthFlag, yearFlag, time.Now()) + return runScheduleReport(cmd, homeDir, repoDir, projectFlag, monthFlag, yearFlag, time.Now()) }, }.Build() -func runConfigReport(cmd *cobra.Command, homeDir, repoDir, projectFlag, monthFlag, yearFlag string, now time.Time) error { +func runScheduleReport(cmd *cobra.Command, homeDir, repoDir, projectFlag, monthFlag, yearFlag string, now time.Time) error { entry, err := ResolveProjectContext(homeDir, repoDir, projectFlag) if err != nil { return err diff --git a/internal/cli/config_report_test.go b/internal/cli/schedule_report_test.go similarity index 65% rename from internal/cli/config_report_test.go rename to internal/cli/schedule_report_test.go index 9b911bc..6190a65 100644 --- a/internal/cli/config_report_test.go +++ b/internal/cli/schedule_report_test.go @@ -11,19 +11,19 @@ import ( "github.com/stretchr/testify/require" ) -func execConfigReport(homeDir, repoDir, projectFlag, monthFlag, yearFlag string, now time.Time) (string, error) { +func execScheduleReport(homeDir, repoDir, projectFlag, monthFlag, yearFlag string, now time.Time) (string, error) { stdout := new(bytes.Buffer) - cmd := configReportCmd + cmd := scheduleReportCmd cmd.SetOut(stdout) - err := runConfigReport(cmd, homeDir, repoDir, projectFlag, monthFlag, yearFlag, now) + err := runScheduleReport(cmd, homeDir, repoDir, projectFlag, monthFlag, yearFlag, now) return stdout.String(), err } -func TestConfigReportDefaultSchedule(t *testing.T) { - homeDir, repoDir, _ := setupConfigTest(t) +func TestScheduleReportDefaultSchedule(t *testing.T) { + homeDir, repoDir, _ := setupScheduleTest(t) now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) // mid-February - stdout, err := execConfigReport(homeDir, repoDir, "", "", "", now) + stdout, err := execScheduleReport(homeDir, repoDir, "", "", "", now) assert.NoError(t, err) assert.Contains(t, stdout, "Working hours for") @@ -38,19 +38,19 @@ func TestConfigReportDefaultSchedule(t *testing.T) { assert.NotContains(t, stdout, "Sun ") } -func TestConfigReportByProjectFlag(t *testing.T) { - homeDir, _, entry := setupConfigTest(t) +func TestScheduleReportByProjectFlag(t *testing.T) { + homeDir, _, entry := setupScheduleTest(t) now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - stdout, err := execConfigReport(homeDir, "", entry.Name, "", "", now) + stdout, err := execScheduleReport(homeDir, "", entry.Name, "", "", now) assert.NoError(t, err) assert.Contains(t, stdout, "Test Project") assert.Contains(t, stdout, "9:00 AM - 5:00 PM") } -func TestConfigReportMultipleWindows(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleReportMultipleWindows(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) custom := []schedule.ScheduleEntry{ {Ranges: []schedule.TimeRange{{From: "09:00", To: "12:00"}}, RRule: "FREQ=WEEKLY;BYDAY=MO"}, @@ -59,25 +59,25 @@ func TestConfigReportMultipleWindows(t *testing.T) { require.NoError(t, project.SetSchedules(homeDir, entry.ID, custom)) now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - stdout, err := execConfigReport(homeDir, repoDir, "", "", "", now) + stdout, err := execScheduleReport(homeDir, repoDir, "", "", "", now) assert.NoError(t, err) // Default is accumulate: both windows appear comma-separated assert.Contains(t, stdout, "9:00 AM - 12:00 PM, 1:00 PM - 5:00 PM") } -func TestConfigReportNoProject(t *testing.T) { +func TestScheduleReportNoProject(t *testing.T) { homeDir := t.TempDir() now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - _, err := execConfigReport(homeDir, "", "", "", "", now) + _, err := execScheduleReport(homeDir, "", "", "", "", now) assert.Error(t, err) assert.Contains(t, err.Error(), "no project found") } -func TestConfigReportNoWorkingHours(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleReportNoWorkingHours(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Set schedule to a specific date outside the test month custom := []schedule.ScheduleEntry{ @@ -86,25 +86,25 @@ func TestConfigReportNoWorkingHours(t *testing.T) { require.NoError(t, project.SetSchedules(homeDir, entry.ID, custom)) now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - stdout, err := execConfigReport(homeDir, repoDir, "", "", "", now) + stdout, err := execScheduleReport(homeDir, repoDir, "", "", "", now) assert.NoError(t, err) assert.Contains(t, stdout, "No working hours scheduled this month") } -func TestConfigReportWithMonthAndYearFlags(t *testing.T) { - homeDir, repoDir, _ := setupConfigTest(t) +func TestScheduleReportWithMonthAndYearFlags(t *testing.T) { + homeDir, repoDir, _ := setupScheduleTest(t) now := time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC) - stdout, err := execConfigReport(homeDir, repoDir, "", "1", "2025", now) + stdout, err := execScheduleReport(homeDir, repoDir, "", "1", "2025", now) assert.NoError(t, err) assert.Contains(t, stdout, "January 2025") assert.Contains(t, stdout, "9:00 AM - 5:00 PM") } -func TestConfigReportRegisteredAsSubcommand(t *testing.T) { - commands := configCmd.Commands() +func TestScheduleReportRegisteredAsSubcommand(t *testing.T) { + commands := scheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() diff --git a/internal/cli/config_reset.go b/internal/cli/schedule_reset.go similarity index 85% rename from internal/cli/config_reset.go rename to internal/cli/schedule_reset.go index 4b92aa0..cbe3f63 100644 --- a/internal/cli/config_reset.go +++ b/internal/cli/schedule_reset.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" ) -var configResetCmd = LeafCommand{ +var scheduleResetCmd = LeafCommand{ Use: "reset", Short: "Reset a project's schedule to the defaults", StrFlags: []StringFlag{ @@ -27,11 +27,11 @@ var configResetCmd = LeafCommand{ yes, _ := cmd.Flags().GetBool("yes") confirm := ResolveConfirmFunc(yes) - return runConfigReset(cmd, homeDir, repoDir, projectFlag, confirm) + return runScheduleReset(cmd, homeDir, repoDir, projectFlag, confirm) }, }.Build() -func runConfigReset(cmd *cobra.Command, homeDir, repoDir, projectFlag string, confirm ConfirmFunc) error { +func runScheduleReset(cmd *cobra.Command, homeDir, repoDir, projectFlag string, confirm ConfirmFunc) error { entry, err := ResolveProjectContext(homeDir, repoDir, projectFlag) if err != nil { return err diff --git a/internal/cli/config_reset_test.go b/internal/cli/schedule_reset_test.go similarity index 64% rename from internal/cli/config_reset_test.go rename to internal/cli/schedule_reset_test.go index 0b3b260..244f069 100644 --- a/internal/cli/config_reset_test.go +++ b/internal/cli/schedule_reset_test.go @@ -10,16 +10,16 @@ import ( "github.com/stretchr/testify/require" ) -func execConfigReset(homeDir, repoDir, projectFlag string, confirm ConfirmFunc) (string, error) { +func execScheduleReset(homeDir, repoDir, projectFlag string, confirm ConfirmFunc) (string, error) { stdout := new(bytes.Buffer) - cmd := configResetCmd + cmd := scheduleResetCmd cmd.SetOut(stdout) - err := runConfigReset(cmd, homeDir, repoDir, projectFlag, confirm) + err := runScheduleReset(cmd, homeDir, repoDir, projectFlag, confirm) return stdout.String(), err } -func TestConfigResetHappyPath(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleResetHappyPath(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Set a custom schedule first custom := []schedule.ScheduleEntry{ @@ -27,7 +27,7 @@ func TestConfigResetHappyPath(t *testing.T) { } require.NoError(t, project.SetSchedules(homeDir, entry.ID, custom)) - stdout, err := execConfigReset(homeDir, repoDir, "", AlwaysYes()) + stdout, err := execScheduleReset(homeDir, repoDir, "", AlwaysYes()) assert.NoError(t, err) assert.Contains(t, stdout, "reset to default") @@ -39,8 +39,8 @@ func TestConfigResetHappyPath(t *testing.T) { assert.Equal(t, schedule.DefaultSchedules(), schedules) } -func TestConfigResetDeclined(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleResetDeclined(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) custom := []schedule.ScheduleEntry{ {Ranges: []schedule.TimeRange{{From: "06:00", To: "14:00"}}, RRule: "FREQ=DAILY"}, @@ -48,7 +48,7 @@ func TestConfigResetDeclined(t *testing.T) { require.NoError(t, project.SetSchedules(homeDir, entry.ID, custom)) decline := func(_ string) (bool, error) { return false, nil } - stdout, err := execConfigReset(homeDir, repoDir, "", decline) + stdout, err := execScheduleReset(homeDir, repoDir, "", decline) assert.NoError(t, err) assert.Contains(t, stdout, "cancelled") @@ -60,26 +60,26 @@ func TestConfigResetDeclined(t *testing.T) { assert.Equal(t, custom, schedules) } -func TestConfigResetByProjectFlag(t *testing.T) { - homeDir, _, entry := setupConfigTest(t) +func TestScheduleResetByProjectFlag(t *testing.T) { + homeDir, _, entry := setupScheduleTest(t) - stdout, err := execConfigReset(homeDir, "", entry.Name, AlwaysYes()) + stdout, err := execScheduleReset(homeDir, "", entry.Name, AlwaysYes()) assert.NoError(t, err) assert.Contains(t, stdout, "reset to default") } -func TestConfigResetNoProject(t *testing.T) { +func TestScheduleResetNoProject(t *testing.T) { homeDir := t.TempDir() - _, err := execConfigReset(homeDir, "", "", AlwaysYes()) + _, err := execScheduleReset(homeDir, "", "", AlwaysYes()) assert.Error(t, err) assert.Contains(t, err.Error(), "no project found") } -func TestConfigResetRegisteredAsSubcommand(t *testing.T) { - commands := configCmd.Commands() +func TestScheduleResetRegisteredAsSubcommand(t *testing.T) { + commands := scheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() diff --git a/internal/cli/config_set.go b/internal/cli/schedule_set.go similarity index 82% rename from internal/cli/config_set.go rename to internal/cli/schedule_set.go index 93d750f..2bb3259 100644 --- a/internal/cli/config_set.go +++ b/internal/cli/schedule_set.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -var configSetCmd = LeafCommand{ +var scheduleSetCmd = LeafCommand{ Use: "set", Short: "Interactively edit a project's schedule", StrFlags: []StringFlag{ @@ -21,11 +21,11 @@ var configSetCmd = LeafCommand{ projectFlag, _ := cmd.Flags().GetString("project") kit := NewPromptKit() - return runConfigSet(cmd, homeDir, repoDir, projectFlag, kit) + return runScheduleSet(cmd, homeDir, repoDir, projectFlag, kit) }, }.Build() -func runConfigSet(cmd *cobra.Command, homeDir, repoDir, projectFlag string, kit PromptKit) error { +func runScheduleSet(cmd *cobra.Command, homeDir, repoDir, projectFlag string, kit PromptKit) error { entry, err := ResolveProjectContext(homeDir, repoDir, projectFlag) if err != nil { return err diff --git a/internal/cli/config_set_test.go b/internal/cli/schedule_set_test.go similarity index 88% rename from internal/cli/config_set_test.go rename to internal/cli/schedule_set_test.go index 1068a82..e0306e8 100644 --- a/internal/cli/config_set_test.go +++ b/internal/cli/schedule_set_test.go @@ -80,16 +80,16 @@ func testKit(sel SelectFunc, prompt PromptFunc, confirm ConfirmFunc, multiSel Mu } } -func execConfigSet(homeDir, repoDir, projectFlag string, kit PromptKit) (string, error) { +func execScheduleSet(homeDir, repoDir, projectFlag string, kit PromptKit) (string, error) { stdout := new(bytes.Buffer) - cmd := configSetCmd + cmd := scheduleSetCmd cmd.SetOut(stdout) - err := runConfigSet(cmd, homeDir, repoDir, projectFlag, kit) + err := runScheduleSet(cmd, homeDir, repoDir, projectFlag, kit) return stdout.String(), err } -func TestConfigSetQuitImmediately(t *testing.T) { - homeDir, repoDir, _ := setupConfigTest(t) +func TestScheduleSetQuitImmediately(t *testing.T) { + homeDir, repoDir, _ := setupScheduleTest(t) kit := testKit( mockSelect(3), // Save & quit @@ -97,14 +97,14 @@ func TestConfigSetQuitImmediately(t *testing.T) { mockConfirm(false), mockMultiSelect(nil), ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") } -func TestConfigSetAddRecurringWeekendAndQuit(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetAddRecurringWeekendAndQuit(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Action: Add(0), then Save&quit(3) // Schedule type: Recurring(0) @@ -115,7 +115,7 @@ func TestConfigSetAddRecurringWeekendAndQuit(t *testing.T) { mockConfirmSequence(false, false), // no more ranges, no overlap confirm needed mockMultiSelect(nil), ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -130,8 +130,8 @@ func TestConfigSetAddRecurringWeekendAndQuit(t *testing.T) { assert.Contains(t, schedules[1].RRule, "BYDAY=SA,SU") } -func TestConfigSetAddSpecificDaysAndQuit(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetAddSpecificDaysAndQuit(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Action: Add(0), then Save&quit(3) // Schedule type: Recurring(0) @@ -143,7 +143,7 @@ func TestConfigSetAddSpecificDaysAndQuit(t *testing.T) { mockConfirmSequence(false, true), // no more ranges, overlap override yes mockMultiSelect([]int{0, 2, 4}), ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -158,8 +158,8 @@ func TestConfigSetAddSpecificDaysAndQuit(t *testing.T) { assert.Contains(t, schedules[1].RRule, "BYDAY=MO,WE,FR") } -func TestConfigSetEditAndQuit(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetEditAndQuit(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Action: Edit(1), select schedule 0, then Save&quit(3) // Schedule type: Recurring(0) @@ -170,7 +170,7 @@ func TestConfigSetEditAndQuit(t *testing.T) { mockConfirmSequence(false), // no more ranges mockMultiSelect(nil), ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -184,8 +184,8 @@ func TestConfigSetEditAndQuit(t *testing.T) { assert.Equal(t, "16:00", schedules[0].Ranges[0].To) } -func TestConfigSetDeleteAndQuit(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetDeleteAndQuit(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Action: Delete(2), select schedule 0, then Save&quit(3) kit := testKit( @@ -194,7 +194,7 @@ func TestConfigSetDeleteAndQuit(t *testing.T) { mockConfirm(false), mockMultiSelect(nil), ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -208,8 +208,8 @@ func TestConfigSetDeleteAndQuit(t *testing.T) { assert.Empty(t, regEntry.Schedules) } -func TestConfigSetByProjectFlag(t *testing.T) { - homeDir, _, entry := setupConfigTest(t) +func TestScheduleSetByProjectFlag(t *testing.T) { + homeDir, _, entry := setupScheduleTest(t) kit := testKit( mockSelect(3), // Save & quit @@ -217,13 +217,13 @@ func TestConfigSetByProjectFlag(t *testing.T) { mockConfirm(false), mockMultiSelect(nil), ) - stdout, err := execConfigSet(homeDir, "", entry.Name, kit) + stdout, err := execScheduleSet(homeDir, "", entry.Name, kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") } -func TestConfigSetNoProject(t *testing.T) { +func TestScheduleSetNoProject(t *testing.T) { homeDir := t.TempDir() kit := testKit( @@ -232,14 +232,14 @@ func TestConfigSetNoProject(t *testing.T) { mockConfirm(false), mockMultiSelect(nil), ) - _, err := execConfigSet(homeDir, "", "", kit) + _, err := execScheduleSet(homeDir, "", "", kit) assert.Error(t, err) assert.Contains(t, err.Error(), "no project found") } -func TestConfigSetRegisteredAsSubcommand(t *testing.T) { - commands := configCmd.Commands() +func TestScheduleSetRegisteredAsSubcommand(t *testing.T) { + commands := scheduleCmd.Commands() names := make([]string, len(commands)) for i, cmd := range commands { names[i] = cmd.Name() @@ -247,8 +247,8 @@ func TestConfigSetRegisteredAsSubcommand(t *testing.T) { assert.Contains(t, names, "set") } -func TestConfigSetAddOverlap(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetAddOverlap(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Add(0): recurring(0) > specific days(3) > Monday > 8am-4pm > no more ranges // Overlap detected → confirm yes @@ -259,7 +259,7 @@ func TestConfigSetAddOverlap(t *testing.T) { mockConfirmSequence(false, true), // no more ranges, override yes mockMultiSelect([]int{0}), // Monday ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -271,8 +271,8 @@ func TestConfigSetAddOverlap(t *testing.T) { assert.True(t, schedules[1].Override, "new entry should have override set") } -func TestConfigSetAddNoOverlap(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetAddNoOverlap(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) confirmCalled := false confirmTracker := func(_ string) (bool, error) { @@ -289,7 +289,7 @@ func TestConfigSetAddNoOverlap(t *testing.T) { confirmTracker, mockMultiSelect([]int{5}), // Saturday ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -304,8 +304,8 @@ func TestConfigSetAddNoOverlap(t *testing.T) { assert.False(t, schedules[1].Override) } -func TestConfigSetAddOverlapDeclined(t *testing.T) { - homeDir, repoDir, entry := setupConfigTest(t) +func TestScheduleSetAddOverlapDeclined(t *testing.T) { + homeDir, repoDir, entry := setupScheduleTest(t) // Add overlapping Monday schedule, decline override kit := testKit( @@ -314,7 +314,7 @@ func TestConfigSetAddOverlapDeclined(t *testing.T) { mockConfirmSequence(false, false), // no more ranges, override no mockMultiSelect([]int{0}), // Monday ) - stdout, err := execConfigSet(homeDir, repoDir, "", kit) + stdout, err := execScheduleSet(homeDir, repoDir, "", kit) assert.NoError(t, err) assert.Contains(t, stdout, "saved") @@ -349,7 +349,7 @@ func TestBuildScheduleEntryRecurringSpecificDays(t *testing.T) { kit := testKit( mockSelectSequence(0, 3), // recurring, specific days mockPrompt("9am", "5pm"), - mockConfirm(false), // no more ranges + mockConfirm(false), // no more ranges mockMultiSelect([]int{0, 2, 4}), // Mon, Wed, Fri ) @@ -587,4 +587,3 @@ func TestDeleteScheduleActionEmpty(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "no schedules to delete") } - diff --git a/web/docs/commands/defaults.md b/web/docs/commands/defaults.md index 68cf0ac..39c9a3a 100644 --- a/web/docs/commands/defaults.md +++ b/web/docs/commands/defaults.md @@ -2,40 +2,40 @@ Manage the default schedule applied to new projects. The factory default is **Monday-Friday, 9 AM - 5 PM**. -## `hourgit defaults get` +## `hourgit defaults schedule get` Show the default schedule for new projects. ```bash -hourgit defaults get +hourgit defaults schedule get ``` -## `hourgit defaults set` +## `hourgit defaults schedule set` Interactively edit the default schedule for new projects. ```bash -hourgit defaults set +hourgit defaults schedule set ``` -## `hourgit defaults reset` +## `hourgit defaults schedule reset` Reset the default schedule to factory settings (Mon-Fri, 9 AM - 5 PM). ```bash -hourgit defaults reset [--yes] +hourgit defaults schedule reset [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-y`, `--yes` | `false` | Skip confirmation prompt | -## `hourgit defaults report` +## `hourgit defaults schedule report` Show expanded default working hours for a given month. ```bash -hourgit defaults report [--month <1-12>] [--year ] +hourgit defaults schedule report [--month <1-12>] [--year ] ``` | Flag | Default | Description | diff --git a/web/docs/commands/project-management.md b/web/docs/commands/project-management.md index 724606e..db8eea7 100644 --- a/web/docs/commands/project-management.md +++ b/web/docs/commands/project-management.md @@ -16,14 +16,15 @@ hourgit project add [--mode ] ## `hourgit project assign` -Assign the current repository to a project. +Assign the current repository to a project. When no project is specified, auto-detects from the current repo's assignment. ```bash -hourgit project assign [--force] [--yes] +hourgit project assign [PROJECT] [--project ] [--force] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| +| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-f`, `--force` | `false` | Reassign repository to a different project | | `-y`, `--yes` | `false` | Skip confirmation prompt | @@ -53,12 +54,13 @@ hourgit project list ## `hourgit project remove` -Remove a project and clean up its repository assignments. +Remove a project and clean up its repository assignments. When no project is specified, auto-detects from the current repo's assignment. ```bash -hourgit project remove [--yes] +hourgit project remove [PROJECT] [--project ] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| +| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-y`, `--yes` | `false` | Skip confirmation prompt | diff --git a/web/docs/commands/schedule.md b/web/docs/commands/schedule.md index baf3845..ba65bee 100644 --- a/web/docs/commands/schedule.md +++ b/web/docs/commands/schedule.md @@ -2,24 +2,24 @@ Manage per-project schedule configuration. If `--project` is omitted, the project is auto-detected from the current repository. -## `hourgit config get` +## `hourgit project schedule get` Show the schedule configuration for a project. ```bash -hourgit config get [--project ] +hourgit project schedule get [--project ] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Project name or ID | -## `hourgit config set` +## `hourgit project schedule set` Interactively edit a project's schedule using a guided schedule builder. ```bash -hourgit config set [--project ] +hourgit project schedule set [--project ] ``` | Flag | Default | Description | @@ -33,12 +33,12 @@ The interactive editor lets you define: Each schedule entry defines one or more time ranges for the days it covers. -## `hourgit config reset` +## `hourgit project schedule reset` Reset a project's schedule to the defaults. ```bash -hourgit config reset [--project ] [--yes] +hourgit project schedule reset [--project ] [--yes] ``` | Flag | Default | Description | @@ -46,12 +46,12 @@ hourgit config reset [--project ] [--yes] | `-p`, `--project` | auto-detect | Project name or ID | | `-y`, `--yes` | `false` | Skip confirmation prompt | -## `hourgit config report` +## `hourgit project schedule report` Show expanded working hours for a given month (resolves schedule rules into concrete days and time ranges). ```bash -hourgit config report [--project ] [--month <1-12>] [--year ] +hourgit project schedule report [--project ] [--month <1-12>] [--year ] ``` | Flag | Default | Description | diff --git a/web/docs/configuration.md b/web/docs/configuration.md index 56c4d8e..5f8c073 100644 --- a/web/docs/configuration.md +++ b/web/docs/configuration.md @@ -4,7 +4,7 @@ Hourgit uses a schedule system to define working hours. The factory default is * ## Schedule Types -The interactive schedule editor (`config set` / `defaults set`) supports three schedule types: +The interactive schedule editor (`project schedule set` / `defaults schedule set`) supports three schedule types: - **Recurring** — repeats on a regular pattern (e.g., every weekday, every Monday/Wednesday/Friday) - **One-off** — applies to a single specific date (e.g., a holiday or overtime day) @@ -18,16 +18,16 @@ Every project starts with a copy of the defaults. You can then customize a proje ```bash # View current schedule -hourgit config get --project 'My Project' +hourgit project schedule get --project 'My Project' # Edit schedule interactively -hourgit config set --project 'My Project' +hourgit project schedule set --project 'My Project' # Revert to defaults -hourgit config reset --project 'My Project' +hourgit project schedule reset --project 'My Project' # See expanded hours for a month -hourgit config report --project 'My Project' --month 3 +hourgit project schedule report --project 'My Project' --month 3 ``` ## Precise Mode @@ -49,11 +49,11 @@ Changes to defaults only affect newly created projects. Existing projects keep t ```bash # View defaults -hourgit defaults get +hourgit defaults schedule get # Edit defaults -hourgit defaults set +hourgit defaults schedule set # Reset to factory (Mon-Fri, 9-5) -hourgit defaults reset +hourgit defaults schedule reset ```