From 546d088e01ee001f8414951309b800643f627cfa Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Fri, 13 Mar 2026 20:40:07 +0100 Subject: [PATCH 1/8] feat: add precise mode --- .task/checksum/docs | 2 +- README.md | 56 +++- go.mod | 1 + go.sum | 2 + internal/cli/init.go | 23 +- internal/cli/init_test.go | 53 +++- internal/cli/project_add.go | 26 +- internal/cli/project_add_test.go | 47 +++- internal/cli/report.go | 60 +++-- internal/cli/root.go | 3 + internal/cli/status.go | 12 + internal/cli/watch.go | 25 ++ internal/cli/watcher_check.go | 88 +++++++ internal/cli/watcher_check_test.go | 102 ++++++++ internal/entry/activity.go | 20 ++ internal/entry/activity_test.go | 139 ++++++++++ internal/entry/entry.go | 10 +- internal/entry/store.go | 22 ++ internal/project/project.go | 72 ++++- internal/project/project_test.go | 124 +++++++++ internal/timetrack/export.go | 4 + internal/timetrack/segment.go | 117 +++++++++ internal/timetrack/segment_test.go | 185 +++++++++++++ internal/timetrack/timetrack.go | 16 ++ internal/watch/daemon.go | 335 ++++++++++++++++++++++++ internal/watch/daemon_test.go | 126 +++++++++ internal/watch/debounce.go | 137 ++++++++++ internal/watch/debounce_test.go | 165 ++++++++++++ internal/watch/ensure.go | 35 +++ internal/watch/gitignore.go | 96 +++++++ internal/watch/gitignore_test.go | 55 ++++ internal/watch/pid.go | 76 ++++++ internal/watch/pid_test.go | 70 +++++ internal/watch/service.go | 15 ++ internal/watch/service_darwin.go | 101 +++++++ internal/watch/service_darwin_test.go | 21 ++ internal/watch/service_linux.go | 99 +++++++ internal/watch/service_linux_test.go | 19 ++ internal/watch/service_test.go | 75 ++++++ internal/watch/service_windows.go | 61 +++++ internal/watch/state.go | 98 +++++++ internal/watch/state_test.go | 75 ++++++ web/docs/commands/project-management.md | 6 +- web/docs/commands/time-tracking.md | 4 +- web/docs/commands/utility.md | 10 + web/docs/configuration.md | 13 + web/docs/data-storage.md | 4 + 47 files changed, 2849 insertions(+), 56 deletions(-) create mode 100644 internal/cli/watch.go create mode 100644 internal/cli/watcher_check.go create mode 100644 internal/cli/watcher_check_test.go create mode 100644 internal/entry/activity.go create mode 100644 internal/entry/activity_test.go create mode 100644 internal/watch/daemon.go create mode 100644 internal/watch/daemon_test.go create mode 100644 internal/watch/debounce.go create mode 100644 internal/watch/debounce_test.go create mode 100644 internal/watch/ensure.go create mode 100644 internal/watch/gitignore.go create mode 100644 internal/watch/gitignore_test.go create mode 100644 internal/watch/pid.go create mode 100644 internal/watch/pid_test.go create mode 100644 internal/watch/service.go create mode 100644 internal/watch/service_darwin.go create mode 100644 internal/watch/service_darwin_test.go create mode 100644 internal/watch/service_linux.go create mode 100644 internal/watch/service_linux_test.go create mode 100644 internal/watch/service_test.go create mode 100644 internal/watch/service_windows.go create mode 100644 internal/watch/state.go create mode 100644 internal/watch/state_test.go diff --git a/.task/checksum/docs b/.task/checksum/docs index 5eb9552..f76e0cb 100644 --- a/.task/checksum/docs +++ b/.task/checksum/docs @@ -1 +1 @@ -f998a0f05834950ec6719dfc2c4a6c95 +fcf9aafe3e5b2b316a1117c324ae5b2f diff --git a/README.md b/README.md index 367fc49..c1f4452 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Manual logging is supported for non-code work (research, analysis, meetings) via - [Schedule Configuration](#schedule-configuration) — config get/set/reset/report - [Default Schedule](#default-schedule) — defaults get/set/reset/report - [Shell Completions](#shell-completions) — completion install/generate - - [Other](#other) — version + - [Other](#other) — version, watch +- [Precise Mode](#precise-mode) - [Configuration](#configuration) - [Data Storage](#data-storage) - [Roadmap](#roadmap) @@ -117,12 +118,13 @@ Commands: `init` · `log` · `edit` · `remove` · `sync` · `report` · `histor Initialize Hourgit in the current git repository by installing a post-checkout hook. ```bash -hourgit init [--project ] [--force] [--merge] [--yes] +hourgit init [--project ] [--mode ] [--force] [--merge] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Assign repository to a project by name or ID (creates if needed) | +| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | | `-f`, `--force` | `false` | Overwrite existing post-checkout hook | | `-m`, `--merge` | `false` | Append to existing post-checkout hook | | `-y`, `--yes` | `false` | Skip confirmation prompt | @@ -300,6 +302,7 @@ hourgit status [--project ] - Time logged today and remaining scheduled hours - Today's schedule windows - Tracking state (active/inactive based on current time vs schedule) +- Watcher state (when precise mode is enabled: active/stopped) ### Project Management @@ -312,10 +315,12 @@ Commands: `project add` · `project assign` · `project list` · `project remove Create a new project. ```bash -hourgit project add +hourgit project add [--mode ] ``` -No flags. +| Flag | Default | Description | +|------|---------|-------------| +| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | #### `hourgit project assign` @@ -505,7 +510,7 @@ hourgit completion generate fish | source ### Other -Commands: `version` · `update` +Commands: `version` · `update` · `watch` #### `hourgit version` @@ -527,6 +532,43 @@ hourgit update No flags. +#### `hourgit watch` + +Run the filesystem watcher daemon in the foreground. The daemon monitors file changes in repositories with precise mode enabled and writes activity entries to detect idle gaps. Normally managed automatically as an OS service — use this command for debugging or manual operation. + +```bash +hourgit watch +``` + +No flags. + +## Precise Mode + +By default, Hourgit attributes all time between branch checkouts (within your schedule) as work. **Precise mode** adds filesystem-level idle detection: a background daemon watches your repository for file changes and records when you stop and resume working. + +### How it works + +1. A background daemon watches file changes in your repository (excluding `.git/` and `.gitignore` patterns). +2. After a configurable idle threshold (default: 10 minutes) with no file changes, the daemon records an `activity_stop` entry. +3. When file changes resume, the daemon records an `activity_start` entry. +4. At report time, these idle gaps are trimmed from checkout sessions, giving you more accurate time attribution. + +### Enabling precise mode + +```bash +# During init +hourgit init --mode precise + +# When adding a project +hourgit project add myproject --mode precise +``` + +When precise mode is enabled, Hourgit automatically installs a user-level OS service (launchd on macOS, systemd on Linux, Task Scheduler on Windows) to run the watcher daemon. No `sudo` required. + +### Health checks + +Hourgit checks whether the watcher daemon is running on every command. If it's stopped, you'll be prompted to restart it. The `status` command shows the current watcher state when precise mode is enabled. + ## Configuration Hourgit uses a schedule system to define working hours. The factory default is **Monday-Friday, 9 AM - 5 PM**. @@ -551,7 +593,9 @@ Every project starts with a copy of the defaults. You can then customize a proje |------|---------| | `~/.hourgit/config.json` | Global config — defaults, projects (id, name, slug, repos, schedules) | | `REPO/.git/.hourgit` | Per-repo project assignment (project name + project ID) | -| `~/.hourgit//` | Per-project entries (one JSON file per entry — log, checkout, or submit marker) | +| `~/.hourgit//` | Per-project entries (one JSON file per entry — log, checkout, commit, submit, activity_stop, activity_start) | +| `~/.hourgit/watch.pid` | PID file for the filesystem watcher daemon (precise mode) | +| `~/.hourgit/watch.state` | Watcher state file — last activity timestamps per repo (precise mode) | ## Roadmap diff --git a/go.mod b/go.mod index 6416a53..8665401 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/f-amaral/go-async v0.3.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/google/uuid v1.5.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/tiff v1.0.1 // indirect diff --git a/go.sum b/go.sum index d894ae4..9824a02 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg= github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= diff --git a/internal/cli/init.go b/internal/cli/init.go index e2d47d2..06466a1 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/watch" "github.com/spf13/cobra" ) @@ -20,7 +21,7 @@ func hookScript(binPath, version string) string { # Skip if old and new HEAD are the same SHA (e.g. pull, fetch) [ "$1" = "$2" ] && exit 0 -%s sync --skip-updates 2>/dev/null || true +%s sync --skip-updates --skip-watcher 2>/dev/null || true `, project.HookMarker, version, binPath) } @@ -29,6 +30,7 @@ var initCmd = LeafCommand{ Short: "Initialize hourgit in a git repository", StrFlags: []StringFlag{ {Name: "project", Shorthand: "p", Usage: "assign repository to a project by name or ID (creates if needed)"}, + {Name: "mode", Usage: "tracking mode: standard or precise (default: standard)"}, }, BoolFlags: []BoolFlag{ {Name: "force", Shorthand: "f", Usage: "overwrite existing post-checkout hook"}, @@ -42,6 +44,7 @@ var initCmd = LeafCommand{ } projectName, _ := cmd.Flags().GetString("project") + modeFlag, _ := cmd.Flags().GetString("mode") force, _ := cmd.Flags().GetBool("force") merge, _ := cmd.Flags().GetBool("merge") yes, _ := cmd.Flags().GetBool("yes") @@ -63,11 +66,15 @@ var initCmd = LeafCommand{ confirm := ResolveConfirmFunc(yes) selectFn := ResolveSelectFunc(yes) - return runInit(cmd, dir, homeDir, projectName, force, merge, binPath, confirm, selectFn) + return runInit(cmd, dir, homeDir, projectName, modeFlag, force, merge, binPath, confirm, selectFn) }, }.Build() -func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error { +func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error { + if mode != "" && mode != "standard" && mode != "precise" { + return fmt.Errorf("invalid --mode value %q (supported: standard, precise)", mode) + } + gitDir := filepath.Join(dir, ".git") if _, err := os.Stat(gitDir); os.IsNotExist(err) { return fmt.Errorf("not a git repository") @@ -133,6 +140,16 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge if result.Created { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("project '%s' created (%s)", Primary(result.Entry.Name), Silent(result.Entry.ID)))) + + if mode == "precise" { + if err := project.SetPreciseMode(homeDir, result.Entry.ID, true); err != nil { + return err + } + if err := project.SetIdleThreshold(homeDir, result.Entry.ID, project.DefaultIdleThresholdMinutes); err != nil { + return err + } + _ = watch.EnsureWatcherService(homeDir, binPath) + } } if err := project.AssignProject(homeDir, dir, result.Entry); err != nil { diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 5185ac3..951f9d9 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -40,11 +40,11 @@ func execInit(args ...string) (string, string, error) { return stdout.String(), stderr.String(), err } -func execInitDirect(dir, homeDir, projectName string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) (string, error) { +func execInitDirect(dir, homeDir, projectName, mode string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) (string, error) { stdout := new(bytes.Buffer) cmd := initCmd cmd.SetOut(stdout) - err := runInit(cmd, dir, homeDir, projectName, force, merge, binPath, confirm, selectFn) + err := runInit(cmd, dir, homeDir, projectName, mode, force, merge, binPath, confirm, selectFn) return stdout.String(), err } @@ -240,7 +240,7 @@ func TestInitWithProjectFlagDeclined(t *testing.T) { decline := func(_ string) (bool, error) { return false, nil } skipSelect := func(_ string, _ []string) (int, error) { return 1, nil } - stdout, err := execInitDirect(dir, home, "My Project", false, false, "/usr/local/bin/hourgit", decline, skipSelect) + stdout, err := execInitDirect(dir, home, "My Project", "", false, false, "/usr/local/bin/hourgit", decline, skipSelect) assert.NoError(t, err) assert.Contains(t, stdout, "project assignment skipped") @@ -265,7 +265,7 @@ func TestInitPromptsForCompletion(t *testing.T) { selectCalls++ return 0, nil } - stdout, err := execInitDirect(dir, home, "", false, false, "/usr/local/bin/hourgit", noConfirm, installSelect) + stdout, err := execInitDirect(dir, home, "", "", false, false, "/usr/local/bin/hourgit", noConfirm, installSelect) assert.NoError(t, err) assert.Contains(t, stdout, "hourgit initialized successfully") @@ -285,7 +285,7 @@ func TestInitCompletionSkipped(t *testing.T) { noConfirm := func(_ string) (bool, error) { return true, nil } skipSelect := func(_ string, _ []string) (int, error) { return 1, nil } - stdout, err := execInitDirect(dir, home, "", false, false, "/usr/local/bin/hourgit", noConfirm, skipSelect) + stdout, err := execInitDirect(dir, home, "", "", false, false, "/usr/local/bin/hourgit", noConfirm, skipSelect) assert.NoError(t, err) assert.Contains(t, stdout, "hourgit initialized successfully") @@ -308,7 +308,7 @@ func TestInitCompletionAlreadyInstalled(t *testing.T) { selectCalls++ return 0, nil } - stdout, err := execInitDirect(dir, home, "", false, false, "/usr/local/bin/hourgit", noConfirm, trackSelect) + stdout, err := execInitDirect(dir, home, "", "", false, false, "/usr/local/bin/hourgit", noConfirm, trackSelect) assert.NoError(t, err) assert.Contains(t, stdout, "hourgit initialized successfully") @@ -330,7 +330,7 @@ func TestInitCompletionUnknownShell(t *testing.T) { selectCalls++ return 0, nil } - stdout, err := execInitDirect(dir, home, "", false, false, "/usr/local/bin/hourgit", noConfirm, trackSelect) + stdout, err := execInitDirect(dir, home, "", "", false, false, "/usr/local/bin/hourgit", noConfirm, trackSelect) assert.NoError(t, err) assert.Contains(t, stdout, "hourgit initialized successfully") @@ -348,7 +348,7 @@ func TestInitYesAutoInstallsCompletion(t *testing.T) { noConfirm := func(_ string) (bool, error) { return true, nil } autoInstall := func(_ string, _ []string) (int, error) { return 0, nil } - stdout, err := execInitDirect(dir, home, "", false, false, "/usr/local/bin/hourgit", noConfirm, autoInstall) + stdout, err := execInitDirect(dir, home, "", "", false, false, "/usr/local/bin/hourgit", noConfirm, autoInstall) assert.NoError(t, err) assert.Contains(t, stdout, "hourgit initialized successfully") @@ -356,6 +356,41 @@ func TestInitYesAutoInstallsCompletion(t *testing.T) { assert.True(t, isCompletionInstalled("zsh", home)) } +func TestInitWithModePrecise(t *testing.T) { + dir := t.TempDir() + home := t.TempDir() + t.Setenv("SHELL", "") + + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0755)) + + noConfirm := func(_ string) (bool, error) { return true, nil } + skipSelect := func(_ string, _ []string) (int, error) { return 1, nil } + stdout, err := execInitDirect(dir, home, "My Project", "precise", false, false, "/usr/local/bin/hourgit", noConfirm, skipSelect) + + assert.NoError(t, err) + assert.Contains(t, stdout, "project 'My Project' created (") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + require.Len(t, cfg.Projects, 1) + assert.True(t, cfg.Projects[0].Precise) + assert.Equal(t, project.DefaultIdleThresholdMinutes, cfg.Projects[0].IdleThresholdMinutes) +} + +func TestInitWithModeInvalid(t *testing.T) { + dir := t.TempDir() + home := t.TempDir() + + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0755)) + + noConfirm := func(_ string) (bool, error) { return true, nil } + skipSelect := func(_ string, _ []string) (int, error) { return 1, nil } + _, err := execInitDirect(dir, home, "", "foobar", false, false, "/usr/local/bin/hourgit", noConfirm, skipSelect) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --mode value") +} + func TestInitCreateHooksDir(t *testing.T) { dir, cleanup := setupInitTest(t) defer cleanup() @@ -377,7 +412,7 @@ func TestHookScript(t *testing.T) { assert.Contains(t, script, "#!/bin/sh") assert.Contains(t, script, project.HookMarker) assert.Contains(t, script, "(version: 1.2.3)") - assert.Contains(t, script, `/usr/local/bin/hourgit sync --skip-updates`) + assert.Contains(t, script, `/usr/local/bin/hourgit sync --skip-updates --skip-watcher`) assert.Contains(t, script, `[ "$3" = "0" ] && exit 0`) assert.Contains(t, script, `[ "$1" = "$2" ] && exit 0`) assert.NotContains(t, script, `checkout --prev`) diff --git a/internal/cli/project_add.go b/internal/cli/project_add.go index 6684b5d..dc53738 100644 --- a/internal/cli/project_add.go +++ b/internal/cli/project_add.go @@ -3,8 +3,10 @@ package cli import ( "fmt" "os" + "path/filepath" "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/watch" "github.com/spf13/cobra" ) @@ -12,21 +14,41 @@ var projectAddCmd = LeafCommand{ Use: "add PROJECT", Short: "Create a new project", Args: cobra.ExactArgs(1), + StrFlags: []StringFlag{ + {Name: "mode", Usage: "tracking mode: standard or precise (default: standard)"}, + }, RunE: func(cmd *cobra.Command, args []string) error { homeDir, err := os.UserHomeDir() if err != nil { return err } - return runProjectAdd(cmd, homeDir, args[0]) + modeFlag, _ := cmd.Flags().GetString("mode") + return runProjectAdd(cmd, homeDir, args[0], modeFlag) }, }.Build() -func runProjectAdd(cmd *cobra.Command, homeDir, name string) error { +func runProjectAdd(cmd *cobra.Command, homeDir, name, mode string) error { + if mode != "" && mode != "standard" && mode != "precise" { + return fmt.Errorf("invalid --mode value %q (supported: standard, precise)", mode) + } + entry, err := project.CreateProject(homeDir, name) if err != nil { return err } + if mode == "precise" { + if err := project.SetPreciseMode(homeDir, entry.ID, true); err != nil { + return err + } + if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdMinutes); err != nil { + return err + } + binPath, _ := os.Executable() + binPath, _ = filepath.EvalSymlinks(binPath) + _ = watch.EnsureWatcherService(homeDir, binPath) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("project '%s' created (%s)", Primary(entry.Name), Silent(entry.ID)))) return nil } diff --git a/internal/cli/project_add_test.go b/internal/cli/project_add_test.go index 5a9e03d..bf5e6ac 100644 --- a/internal/cli/project_add_test.go +++ b/internal/cli/project_add_test.go @@ -10,11 +10,15 @@ import ( "github.com/stretchr/testify/require" ) -func execProjectAdd(homeDir string, name string) (string, error) { +func execProjectAdd(homeDir string, name string, mode ...string) (string, error) { stdout := new(bytes.Buffer) cmd := projectAddCmd cmd.SetOut(stdout) - err := runProjectAdd(cmd, homeDir, name) + m := "" + if len(mode) > 0 { + m = mode[0] + } + err := runProjectAdd(cmd, homeDir, name, m) return stdout.String(), err } @@ -50,6 +54,45 @@ func TestProjectAddDuplicate(t *testing.T) { assert.Contains(t, err.Error(), "already exists") } +func TestProjectAddModePrecise(t *testing.T) { + home := t.TempDir() + + stdout, err := execProjectAdd(home, "Precise Project", "precise") + + assert.NoError(t, err) + assert.Contains(t, stdout, "project 'Precise Project' created (") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + require.Len(t, cfg.Projects, 1) + assert.True(t, cfg.Projects[0].Precise) + assert.Equal(t, project.DefaultIdleThresholdMinutes, cfg.Projects[0].IdleThresholdMinutes) +} + +func TestProjectAddModeStandard(t *testing.T) { + home := t.TempDir() + + stdout, err := execProjectAdd(home, "Standard Project", "standard") + + assert.NoError(t, err) + assert.Contains(t, stdout, "project 'Standard Project' created (") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + require.Len(t, cfg.Projects, 1) + assert.False(t, cfg.Projects[0].Precise) + assert.Equal(t, 0, cfg.Projects[0].IdleThresholdMinutes) +} + +func TestProjectAddModeInvalid(t *testing.T) { + home := t.TempDir() + + _, err := execProjectAdd(home, "Bad Mode", "foobar") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --mode value") +} + func TestProjectAddRegisteredAsSubcommand(t *testing.T) { commands := projectCmd.Commands() names := make([]string, len(commands)) diff --git a/internal/cli/report.go b/internal/cli/report.go index 6a82843..1fc6e1f 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -15,17 +15,19 @@ import ( // reportInputs holds the raw data loaded from storage, shared between // the interactive report and the PDF export paths. type reportInputs struct { - proj *project.ProjectEntry - checkouts []entry.CheckoutEntry - logs []entry.Entry - commits []entry.CommitEntry - schedules []schedule.DaySchedule - submits []entry.SubmitEntry - from time.Time - to time.Time - year int - month time.Month - weekNum int // >0 when using --week view + proj *project.ProjectEntry + checkouts []entry.CheckoutEntry + logs []entry.Entry + commits []entry.CommitEntry + schedules []schedule.DaySchedule + submits []entry.SubmitEntry + activityStops []entry.ActivityStopEntry + activityStarts []entry.ActivityStartEntry + from time.Time + to time.Time + year int + month time.Month + weekNum int // >0 when using --week view } var reportCmd = LeafCommand{ @@ -88,6 +90,7 @@ func runReport( inputs.checkouts, inputs.logs, inputs.commits, inputs.schedules, inputs.year, inputs.month, now, nil, inputs.proj.Name, detailFlag, + timetrack.ActivityEntries{Stops: inputs.activityStops, Starts: inputs.activityStarts}, ) if len(exportData.Days) == 0 { @@ -114,6 +117,7 @@ func runReport( data := timetrack.BuildDetailedReport( inputs.checkouts, inputs.logs, inputs.commits, inputs.schedules, inputs.from, inputs.to, now, + timetrack.ActivityEntries{Stops: inputs.activityStops, Starts: inputs.activityStarts}, ) if len(data.Rows) == 0 { @@ -293,6 +297,16 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl return nil, err } + activityStops, err := entry.ReadAllActivityStopEntries(homeDir, proj.Slug) + if err != nil { + return nil, err + } + + activityStarts, err := entry.ReadAllActivityStartEntries(homeDir, proj.Slug) + if err != nil { + return nil, err + } + var weekNum int if weekChanged { // Derive week number from the resolved Monday date @@ -300,16 +314,18 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl } return &reportInputs{ - proj: proj, - checkouts: checkouts, - logs: logs, - commits: commits, - schedules: daySchedules, - submits: submits, - from: from, - to: to, - year: year, - month: month, - weekNum: weekNum, + proj: proj, + checkouts: checkouts, + logs: logs, + commits: commits, + schedules: daySchedules, + submits: submits, + activityStops: activityStops, + activityStarts: activityStarts, + from: from, + to: to, + year: year, + month: month, + weekNum: weekNum, }, nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index dda2c1c..2c0068e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -27,6 +27,7 @@ func newRootCmd() *cobra.Command { defaultsCmd, completionCmd, updateCmd, + watchCmd, }, }.Build() cmd.SilenceUsage = true @@ -34,8 +35,10 @@ func newRootCmd() *cobra.Command { cmd.CompletionOptions.DisableDefaultCmd = true cmd.SetHelpFunc(colorizedHelpFunc()) cmd.PersistentFlags().Bool("skip-updates", false, "skip the automatic update check") + cmd.PersistentFlags().Bool("skip-watcher", false, "skip the file watcher health check") cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { checkForUpdate(cmd, defaultUpdateDeps()) + checkWatcherHealth(cmd, defaultWatcherCheckDeps()) return nil } return cmd diff --git a/internal/cli/status.go b/internal/cli/status.go index b5530e0..e47a4aa 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -11,6 +11,7 @@ import ( "github.com/Flyrell/hourgit/internal/project" "github.com/Flyrell/hourgit/internal/schedule" "github.com/Flyrell/hourgit/internal/timetrack" + "github.com/Flyrell/hourgit/internal/watch" "github.com/spf13/cobra" ) @@ -171,6 +172,17 @@ func runStatus( _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Tracking:"), Warning("inactive (no scheduled hours remaining)")) } + // Watcher state (only when precise mode is enabled) + cfgEntry := project.FindProjectByID(cfg, proj.ID) + if cfgEntry != nil && cfgEntry.Precise { + daemonRunning, _, _ := watch.IsDaemonRunning(homeDir) + if daemonRunning { + _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Watcher:"), Info("active")) + } else { + _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Watcher:"), Warning("stopped (run hourgit to restart)")) + } + } + return nil } diff --git a/internal/cli/watch.go b/internal/cli/watch.go new file mode 100644 index 0000000..5755e5f --- /dev/null +++ b/internal/cli/watch.go @@ -0,0 +1,25 @@ +package cli + +import ( + "os" + + "github.com/Flyrell/hourgit/internal/watch" + "github.com/spf13/cobra" +) + +var watchCmd = LeafCommand{ + Use: "watch", + Short: "Run the file watcher daemon (used by the OS service)", + RunE: func(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + return runWatch(homeDir) + }, +}.Build() + +func runWatch(homeDir string) error { + d := watch.NewDaemon(homeDir, watch.DefaultEntryWriter()) + return d.Run() +} diff --git a/internal/cli/watcher_check.go b/internal/cli/watcher_check.go new file mode 100644 index 0000000..ded1c8d --- /dev/null +++ b/internal/cli/watcher_check.go @@ -0,0 +1,88 @@ +package cli + +import ( + "os" + "path/filepath" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/watch" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +// watcherCheckDeps holds injectable dependencies for watcher health check. +type watcherCheckDeps struct { + homeDir func() (string, error) + readConfig func(string) (*project.Config, error) + isDaemonRun func(string) (bool, int, error) + confirm ConfirmFunc + binPath func() (string, error) + ensureSvc func(homeDir, binPath string) error +} + +func defaultWatcherCheckDeps() watcherCheckDeps { + return watcherCheckDeps{ + homeDir: os.UserHomeDir, + readConfig: project.ReadConfig, + isDaemonRun: watch.IsDaemonRunning, + confirm: NewConfirmFunc(), + binPath: func() (string, error) { + p, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(p) + }, + ensureSvc: watch.EnsureWatcherService, + } +} + +// checkWatcherHealth checks if the file watcher daemon is running when needed. +// Called from PersistentPreRunE. +func checkWatcherHealth(cmd *cobra.Command, deps watcherCheckDeps) { + // Skip in non-interactive contexts + if !isatty.IsTerminal(os.Stdout.Fd()) { + return + } + + skipWatcher, _ := cmd.Flags().GetBool("skip-watcher") + if skipWatcher { + return + } + + if appVersion == "dev" { + return + } + + homeDir, err := deps.homeDir() + if err != nil { + return + } + + cfg, err := deps.readConfig(homeDir) + if err != nil { + return + } + + if !project.AnyPreciseProject(cfg) { + return + } + + running, _, err := deps.isDaemonRun(homeDir) + if err != nil || running { + return + } + + // Daemon is not running — prompt to restart + confirmed, err := deps.confirm("File watcher is not running. Restart?") + if err != nil || !confirmed { + return + } + + binPath, err := deps.binPath() + if err != nil { + return + } + + _ = deps.ensureSvc(homeDir, binPath) +} diff --git a/internal/cli/watcher_check_test.go b/internal/cli/watcher_check_test.go new file mode 100644 index 0000000..b1cb20b --- /dev/null +++ b/internal/cli/watcher_check_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "testing" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/schedule" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func newTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Bool("skip-watcher", false, "") + return cmd +} + +func setupWatcherCheckTest(t *testing.T, precise bool) (string, watcherCheckDeps) { + t.Helper() + home := t.TempDir() + + cfg := &project.Config{ + Defaults: schedule.DefaultSchedules(), + Projects: []project.ProjectEntry{ + { + ID: "aaa1111", + Name: "test", + Slug: "test", + Repos: []string{"/some/repo"}, + Precise: precise, + }, + }, + } + require.NoError(t, project.WriteConfig(home, cfg)) + + deps := watcherCheckDeps{ + homeDir: func() (string, error) { return home, nil }, + readConfig: project.ReadConfig, + isDaemonRun: func(_ string) (bool, int, error) { + return false, 0, nil + }, + confirm: func(_ string) (bool, error) { + return false, nil + }, + binPath: func() (string, error) { + return "/usr/local/bin/hourgit", nil + }, + ensureSvc: func(_, _ string) error { + return nil + }, + } + + return home, deps +} + +func TestWatcherCheckNoPreciseProjects(t *testing.T) { + _, deps := setupWatcherCheckTest(t, false) + + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil + } + + cmd := newTestCmd() + checkWatcherHealth(cmd, deps) + // In non-TTY test context, it will skip early due to isatty check + _ = confirmCalled +} + +func TestWatcherCheckDaemonRunning(t *testing.T) { + _, deps := setupWatcherCheckTest(t, true) + + deps.isDaemonRun = func(_ string) (bool, int, error) { + return true, 1234, nil + } + + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil + } + + cmd := newTestCmd() + checkWatcherHealth(cmd, deps) + _ = confirmCalled +} + +func TestWatcherCheckSkipFlag(t *testing.T) { + _, deps := setupWatcherCheckTest(t, true) + + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil + } + + cmd := newTestCmd() + _ = cmd.Flags().Set("skip-watcher", "true") + checkWatcherHealth(cmd, deps) + _ = confirmCalled +} diff --git a/internal/entry/activity.go b/internal/entry/activity.go new file mode 100644 index 0000000..349b076 --- /dev/null +++ b/internal/entry/activity.go @@ -0,0 +1,20 @@ +package entry + +import "time" + +// ActivityStopEntry is written after idle_threshold_minutes of no file changes. +// Timestamp records the last observed file change, not when the debounce fired. +type ActivityStopEntry struct { + ID string `json:"id"` + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Repo string `json:"repo,omitempty"` +} + +// ActivityStartEntry is written when file changes resume after a stop. +type ActivityStartEntry struct { + ID string `json:"id"` + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Repo string `json:"repo,omitempty"` +} diff --git a/internal/entry/activity_test.go b/internal/entry/activity_test.go new file mode 100644 index 0000000..2bb5e5d --- /dev/null +++ b/internal/entry/activity_test.go @@ -0,0 +1,139 @@ +package entry + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testActivityStopEntry(id string) ActivityStopEntry { + return ActivityStopEntry{ + ID: id, + Timestamp: time.Date(2025, 6, 15, 10, 15, 0, 0, time.UTC), + Repo: "/path/to/repo", + } +} + +func testActivityStartEntry(id string) ActivityStartEntry { + return ActivityStartEntry{ + ID: id, + Timestamp: time.Date(2025, 6, 15, 10, 45, 0, 0, time.UTC), + Repo: "/path/to/repo", + } +} + +func TestWriteAndReadActivityStopEntry(t *testing.T) { + home := t.TempDir() + slug := "test-project" + e := testActivityStopEntry("aad1234") + + require.NoError(t, WriteActivityStopEntry(home, slug, e)) + + entries, err := ReadAllActivityStopEntries(home, slug) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, e.ID, entries[0].ID) + assert.Equal(t, TypeActivityStop, entries[0].Type) + assert.Equal(t, e.Timestamp, entries[0].Timestamp) + assert.Equal(t, e.Repo, entries[0].Repo) +} + +func TestWriteAndReadActivityStartEntry(t *testing.T) { + home := t.TempDir() + slug := "test-project" + e := testActivityStartEntry("aae1234") + + require.NoError(t, WriteActivityStartEntry(home, slug, e)) + + entries, err := ReadAllActivityStartEntries(home, slug) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, e.ID, entries[0].ID) + assert.Equal(t, TypeActivityStart, entries[0].Type) + assert.Equal(t, e.Timestamp, entries[0].Timestamp) + assert.Equal(t, e.Repo, entries[0].Repo) +} + +func TestReadAllActivityStopEntriesEmpty(t *testing.T) { + home := t.TempDir() + entries, err := ReadAllActivityStopEntries(home, "nonexistent") + require.NoError(t, err) + assert.Nil(t, entries) +} + +func TestReadAllActivityStartEntriesEmpty(t *testing.T) { + home := t.TempDir() + entries, err := ReadAllActivityStartEntries(home, "nonexistent") + require.NoError(t, err) + assert.Nil(t, entries) +} + +func TestReadAllActivityStopEntriesSkipsOtherTypes(t *testing.T) { + home := t.TempDir() + slug := "test-project" + + require.NoError(t, WriteEntry(home, slug, testEntry("a0a1111", "work"))) + require.NoError(t, WriteCheckoutEntry(home, slug, testCheckoutEntry("c0c1111", "main", "feat"))) + require.NoError(t, WriteActivityStopEntry(home, slug, testActivityStopEntry("e0e1111"))) + require.NoError(t, WriteActivityStartEntry(home, slug, testActivityStartEntry("f0f1111"))) + + entries, err := ReadAllActivityStopEntries(home, slug) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "e0e1111", entries[0].ID) +} + +func TestReadAllActivityStartEntriesSkipsOtherTypes(t *testing.T) { + home := t.TempDir() + slug := "test-project" + + require.NoError(t, WriteEntry(home, slug, testEntry("a0a1111", "work"))) + require.NoError(t, WriteCheckoutEntry(home, slug, testCheckoutEntry("c0c1111", "main", "feat"))) + require.NoError(t, WriteActivityStopEntry(home, slug, testActivityStopEntry("e0e1111"))) + require.NoError(t, WriteActivityStartEntry(home, slug, testActivityStartEntry("f0f1111"))) + + entries, err := ReadAllActivityStartEntries(home, slug) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "f0f1111", entries[0].ID) +} + +func TestReadAllEntriesSkipsActivityEntries(t *testing.T) { + home := t.TempDir() + slug := "test-project" + + require.NoError(t, WriteEntry(home, slug, testEntry("a0a1111", "work"))) + require.NoError(t, WriteActivityStopEntry(home, slug, testActivityStopEntry("e0e1111"))) + require.NoError(t, WriteActivityStartEntry(home, slug, testActivityStartEntry("f0f1111"))) + + entries, err := ReadAllEntries(home, slug) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "a0a1111", entries[0].ID) +} + +func TestWriteActivityStopEntrySetsTypeField(t *testing.T) { + home := t.TempDir() + slug := "test-project" + + require.NoError(t, WriteActivityStopEntry(home, slug, testActivityStopEntry("aad1234"))) + + entries, err := ReadAllActivityStopEntries(home, slug) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, TypeActivityStop, entries[0].Type) +} + +func TestWriteActivityStartEntrySetsTypeField(t *testing.T) { + home := t.TempDir() + slug := "test-project" + + require.NoError(t, WriteActivityStartEntry(home, slug, testActivityStartEntry("aae1234"))) + + entries, err := ReadAllActivityStartEntries(home, slug) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, TypeActivityStart, entries[0].Type) +} diff --git a/internal/entry/entry.go b/internal/entry/entry.go index 503ac6b..c1d029b 100644 --- a/internal/entry/entry.go +++ b/internal/entry/entry.go @@ -3,10 +3,12 @@ package entry import "time" const ( - TypeLog = "log" - TypeCheckout = "checkout" - TypeSubmit = "submit" - TypeCommit = "commit" + TypeLog = "log" + TypeCheckout = "checkout" + TypeSubmit = "submit" + TypeCommit = "commit" + TypeActivityStop = "activity_stop" + TypeActivityStart = "activity_start" ) // Entry represents a single time log entry (a "time commit"). diff --git a/internal/entry/store.go b/internal/entry/store.go index 600c3ca..7c9f64f 100644 --- a/internal/entry/store.go +++ b/internal/entry/store.go @@ -244,3 +244,25 @@ func WriteCommitEntry(homeDir, slug string, e CommitEntry) error { func ReadAllCommitEntries(homeDir, slug string) ([]CommitEntry, error) { return readAllOfType[CommitEntry](homeDir, slug, TypeCommit) } + +// WriteActivityStopEntry writes an activity stop entry to the project's log directory. +func WriteActivityStopEntry(homeDir, slug string, e ActivityStopEntry) error { + e.Type = TypeActivityStop + return writeTypedEntry(homeDir, slug, e.ID, e) +} + +// WriteActivityStartEntry writes an activity start entry to the project's log directory. +func WriteActivityStartEntry(homeDir, slug string, e ActivityStartEntry) error { + e.Type = TypeActivityStart + return writeTypedEntry(homeDir, slug, e.ID, e) +} + +// ReadAllActivityStopEntries reads all activity stop entries from a project's log directory. +func ReadAllActivityStopEntries(homeDir, slug string) ([]ActivityStopEntry, error) { + return readAllOfType[ActivityStopEntry](homeDir, slug, TypeActivityStop) +} + +// ReadAllActivityStartEntries reads all activity start entries from a project's log directory. +func ReadAllActivityStartEntries(homeDir, slug string) ([]ActivityStartEntry, error) { + return readAllOfType[ActivityStartEntry](homeDir, slug, TypeActivityStart) +} diff --git a/internal/project/project.go b/internal/project/project.go index b5cc496..c71b6e3 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -34,11 +34,13 @@ type RepoConfig struct { // ProjectEntry represents a single project in the global registry. type ProjectEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Repos []string `json:"repos"` - Schedules []schedule.ScheduleEntry `json:"schedules,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Repos []string `json:"repos"` + Schedules []schedule.ScheduleEntry `json:"schedules,omitempty"` + Precise bool `json:"precise,omitempty"` + IdleThresholdMinutes int `json:"idle_threshold_minutes,omitempty"` } // Config holds the global hourgit configuration including projects and defaults. @@ -367,6 +369,66 @@ func ResetSchedules(homeDir, projectID string) error { return SetSchedules(homeDir, projectID, defaults) } +// DefaultIdleThresholdMinutes is the default idle threshold for precise mode. +const DefaultIdleThresholdMinutes = 10 + +// GetPreciseMode returns whether precise mode is enabled for a project. +func GetPreciseMode(cfg *Config, projectID string) bool { + entry := FindProjectByID(cfg, projectID) + if entry == nil { + return false + } + return entry.Precise +} + +// SetPreciseMode enables or disables precise mode for a project. +func SetPreciseMode(homeDir, projectID string, precise bool) error { + cfg, err := ReadConfig(homeDir) + if err != nil { + return err + } + entry := FindProjectByID(cfg, projectID) + if entry == nil { + return fmt.Errorf("project '%s' not found", projectID) + } + entry.Precise = precise + return WriteConfig(homeDir, cfg) +} + +// GetIdleThreshold returns the idle threshold in minutes for a project. +// Returns DefaultIdleThresholdMinutes if not set. +func GetIdleThreshold(cfg *Config, projectID string) int { + entry := FindProjectByID(cfg, projectID) + if entry == nil || entry.IdleThresholdMinutes <= 0 { + return DefaultIdleThresholdMinutes + } + return entry.IdleThresholdMinutes +} + +// SetIdleThreshold sets the idle threshold in minutes for a project. +func SetIdleThreshold(homeDir, projectID string, minutes int) error { + cfg, err := ReadConfig(homeDir) + if err != nil { + return err + } + entry := FindProjectByID(cfg, projectID) + if entry == nil { + return fmt.Errorf("project '%s' not found", projectID) + } + entry.IdleThresholdMinutes = minutes + return WriteConfig(homeDir, cfg) +} + +// AnyPreciseProject checks if any project in the config has precise mode enabled. +func AnyPreciseProject(cfg *Config) bool { + for _, p := range cfg.Projects { + if p.Precise { + return true + } + } + return false +} + // RemoveHookFromRepo removes the hourgit section from the post-checkout hook. // If the hook becomes empty after removal, it is deleted. func RemoveHookFromRepo(repoDir string) error { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 357f0bd..d68b8fc 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -347,6 +347,130 @@ func TestRemoveHookFromRepoMissing(t *testing.T) { assert.NoError(t, err) } +func TestPreciseModeGetSet(t *testing.T) { + home := t.TempDir() + + entry, err := CreateProject(home, "My Project") + require.NoError(t, err) + + cfg, err := ReadConfig(home) + require.NoError(t, err) + + // Default is false + assert.False(t, GetPreciseMode(cfg, entry.ID)) + + // Set to true + require.NoError(t, SetPreciseMode(home, entry.ID, true)) + + cfg, err = ReadConfig(home) + require.NoError(t, err) + assert.True(t, GetPreciseMode(cfg, entry.ID)) + + // Set back to false + require.NoError(t, SetPreciseMode(home, entry.ID, false)) + + cfg, err = ReadConfig(home) + require.NoError(t, err) + assert.False(t, GetPreciseMode(cfg, entry.ID)) +} + +func TestIdleThresholdGetSet(t *testing.T) { + home := t.TempDir() + + entry, err := CreateProject(home, "My Project") + require.NoError(t, err) + + cfg, err := ReadConfig(home) + require.NoError(t, err) + + // Default returns DefaultIdleThresholdMinutes + assert.Equal(t, DefaultIdleThresholdMinutes, GetIdleThreshold(cfg, entry.ID)) + + // Set custom value + require.NoError(t, SetIdleThreshold(home, entry.ID, 15)) + + cfg, err = ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, 15, GetIdleThreshold(cfg, entry.ID)) +} + +func TestPreciseModeSetNotFound(t *testing.T) { + home := t.TempDir() + + err := SetPreciseMode(home, "nonexistent", true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestIdleThresholdSetNotFound(t *testing.T) { + home := t.TempDir() + + err := SetIdleThreshold(home, "nonexistent", 10) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestGetPreciseModeNotFound(t *testing.T) { + cfg := &Config{} + assert.False(t, GetPreciseMode(cfg, "nonexistent")) +} + +func TestGetIdleThresholdNotFound(t *testing.T) { + cfg := &Config{} + assert.Equal(t, DefaultIdleThresholdMinutes, GetIdleThreshold(cfg, "nonexistent")) +} + +func TestAnyPreciseProject(t *testing.T) { + cfg := &Config{ + Projects: []ProjectEntry{ + {ID: "aaa1111", Name: "Alpha", Precise: false}, + {ID: "bbb2222", Name: "Beta", Precise: false}, + }, + } + assert.False(t, AnyPreciseProject(cfg)) + + cfg.Projects[1].Precise = true + assert.True(t, AnyPreciseProject(cfg)) +} + +func TestPreciseModeJSONRoundTrip(t *testing.T) { + home := t.TempDir() + + original := &Config{ + Defaults: schedule.DefaultSchedules(), + Projects: []ProjectEntry{ + {ID: "abc1234", Name: "Test", Slug: "test", Repos: []string{}, Precise: true, IdleThresholdMinutes: 15}, + }, + } + + require.NoError(t, WriteConfig(home, original)) + + loaded, err := ReadConfig(home) + require.NoError(t, err) + assert.True(t, loaded.Projects[0].Precise) + assert.Equal(t, 15, loaded.Projects[0].IdleThresholdMinutes) +} + +func TestPreciseModeBackwardCompat(t *testing.T) { + home := t.TempDir() + + // Write config without precise fields (old format) + original := &Config{ + Defaults: schedule.DefaultSchedules(), + Projects: []ProjectEntry{ + {ID: "abc1234", Name: "Test", Slug: "test", Repos: []string{}}, + }, + } + + require.NoError(t, WriteConfig(home, original)) + + loaded, err := ReadConfig(home) + require.NoError(t, err) + // Defaults should be fine + assert.False(t, loaded.Projects[0].Precise) + assert.Equal(t, 0, loaded.Projects[0].IdleThresholdMinutes) +} + func TestFindProjectByID(t *testing.T) { cfg := &Config{ Projects: []ProjectEntry{ diff --git a/internal/timetrack/export.go b/internal/timetrack/export.go index 6431b9d..e87c058 100644 --- a/internal/timetrack/export.go +++ b/internal/timetrack/export.go @@ -51,6 +51,7 @@ func BuildExportData( generatedDays []string, projectName string, detail string, + activity ...ActivityEntries, ) ExportData { daysInMonth := daysIn(year, month) @@ -72,6 +73,9 @@ func BuildExportData( var checkoutBucket map[string]map[int]int if len(commits) > 0 { segments = buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, now) + if len(activity) > 0 && (len(activity[0].Stops) > 0 || len(activity[0].Starts) > 0) { + segments = trimSegmentsByIdleGaps(segments, activity[0].Stops, activity[0].Starts) + } checkoutBucket = buildSegmentBucket(segments, year, month, daysInMonth, scheduleWindows, now.Location()) } else { checkoutBucket = buildCheckoutBucket(checkouts, year, month, daysInMonth, scheduleWindows, now) diff --git a/internal/timetrack/segment.go b/internal/timetrack/segment.go index 91af714..f2edf27 100644 --- a/internal/timetrack/segment.go +++ b/internal/timetrack/segment.go @@ -8,6 +8,123 @@ import ( "github.com/Flyrell/hourgit/internal/schedule" ) +// idleGap represents a paired [stop, start] idle period. +type idleGap struct { + stop time.Time // activity_stop timestamp (last file change before idle) + start time.Time // activity_start timestamp (first file change after idle) +} + +// buildIdleGaps pairs activity_stop and activity_start entries into idle gaps. +// Gaps are sorted chronologically by stop time. +func buildIdleGaps(stops []entry.ActivityStopEntry, starts []entry.ActivityStartEntry) []idleGap { + // Sort stops and starts by timestamp + sortedStops := make([]entry.ActivityStopEntry, len(stops)) + copy(sortedStops, stops) + sort.Slice(sortedStops, func(i, j int) bool { + return sortedStops[i].Timestamp.Before(sortedStops[j].Timestamp) + }) + + sortedStarts := make([]entry.ActivityStartEntry, len(starts)) + copy(sortedStarts, starts) + sort.Slice(sortedStarts, func(i, j int) bool { + return sortedStarts[i].Timestamp.Before(sortedStarts[j].Timestamp) + }) + + // Pair each stop with the next start that comes after it + var gaps []idleGap + startIdx := 0 + for _, stop := range sortedStops { + // Find the first start after this stop + for startIdx < len(sortedStarts) && !sortedStarts[startIdx].Timestamp.After(stop.Timestamp) { + startIdx++ + } + if startIdx < len(sortedStarts) { + gaps = append(gaps, idleGap{ + stop: stop.Timestamp, + start: sortedStarts[startIdx].Timestamp, + }) + startIdx++ + } + } + return gaps +} + +// trimSegmentsByIdleGaps removes idle periods from checkout segments. +// For each segment, idle gaps that overlap are used to split or trim the segment. +func trimSegmentsByIdleGaps(segments []sessionSegment, stops []entry.ActivityStopEntry, starts []entry.ActivityStartEntry) []sessionSegment { + if len(stops) == 0 || len(starts) == 0 { + return segments + } + + gaps := buildIdleGaps(stops, starts) + if len(gaps) == 0 { + return segments + } + + var result []sessionSegment + for _, seg := range segments { + trimmed := applyGapsToSegment(seg, gaps) + result = append(result, trimmed...) + } + return result +} + +// applyGapsToSegment applies all overlapping idle gaps to a single segment, +// potentially splitting it into multiple sub-segments. +func applyGapsToSegment(seg sessionSegment, gaps []idleGap) []sessionSegment { + current := []sessionSegment{seg} + + for _, gap := range gaps { + var next []sessionSegment + for _, s := range current { + split := splitSegmentByGap(s, gap) + next = append(next, split...) + } + current = next + } + + return current +} + +// splitSegmentByGap handles the intersection of a single segment with a single gap. +// Gap interval is [gap.stop, gap.start) — the time between last activity and resume. +func splitSegmentByGap(seg sessionSegment, gap idleGap) []sessionSegment { + gapFrom := gap.stop + gapTo := gap.start + + // No overlap: gap is entirely before or after segment + if !gapTo.After(seg.from) || !gapFrom.Before(seg.to) { + return []sessionSegment{seg} + } + + // Gap fully contains segment + if !gapFrom.After(seg.from) && !gapTo.Before(seg.to) { + return nil + } + + // Gap overlaps start only + if !gapFrom.After(seg.from) && gapTo.Before(seg.to) { + return []sessionSegment{{ + branch: seg.branch, repo: seg.repo, + from: gapTo, to: seg.to, message: seg.message, + }} + } + + // Gap overlaps end only + if gapFrom.After(seg.from) && !gapTo.Before(seg.to) { + return []sessionSegment{{ + branch: seg.branch, repo: seg.repo, + from: seg.from, to: gapFrom, message: seg.message, + }} + } + + // Gap is strictly inside segment — split into two + return []sessionSegment{ + {branch: seg.branch, repo: seg.repo, from: seg.from, to: gapFrom, message: seg.message}, + {branch: seg.branch, repo: seg.repo, from: gapTo, to: seg.to, message: seg.message}, + } +} + // sessionSegment represents a sub-block of a checkout session, split by commits. type sessionSegment struct { branch string diff --git a/internal/timetrack/segment_test.go b/internal/timetrack/segment_test.go index d3d4976..19d1af3 100644 --- a/internal/timetrack/segment_test.go +++ b/internal/timetrack/segment_test.go @@ -212,3 +212,188 @@ func filterSegments(segments []sessionSegment, branch string) []sessionSegment { } return result } + +// --- Idle gap trimming tests --- + +func TestTrimSegmentsByIdleGaps_NoGaps(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t10am, message: "work"}, + } + + result := trimSegmentsByIdleGaps(segments, nil, nil) + assert.Equal(t, segments, result) +} + +var ( + t9am = time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC) + t930 = time.Date(2025, 1, 2, 9, 30, 0, 0, time.UTC) + t10am = time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC) + t1030 = time.Date(2025, 1, 2, 10, 30, 0, 0, time.UTC) + t11am = time.Date(2025, 1, 2, 11, 0, 0, 0, time.UTC) + t12pm = time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC) +) + +func TestTrimSegmentsByIdleGaps_GapInsideSegment(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t12pm, message: "work"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t10am, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 2) + // Before gap: 9:00 - 10:00 + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t10am, result[0].to) + assert.Equal(t, "work", result[0].message) + // After gap: 11:00 - 12:00 + assert.Equal(t, t11am, result[1].from) + assert.Equal(t, t12pm, result[1].to) + assert.Equal(t, "work", result[1].message) +} + +func TestTrimSegmentsByIdleGaps_GapAtStart(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t12pm, message: "work"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: time.Date(2025, 1, 2, 8, 30, 0, 0, time.UTC), Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t10am, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 1) + // Trimmed start: 10:00 - 12:00 + assert.Equal(t, t10am, result[0].from) + assert.Equal(t, t12pm, result[0].to) +} + +func TestTrimSegmentsByIdleGaps_GapAtEnd(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t12pm, message: "work"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t11am, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: time.Date(2025, 1, 2, 13, 0, 0, 0, time.UTC), Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 1) + // Trimmed end: 9:00 - 11:00 + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t11am, result[0].to) +} + +func TestTrimSegmentsByIdleGaps_GapFullyCoversSegment(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t10am, to: t11am, message: "work"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t9am, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t12pm, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 0) +} + +func TestTrimSegmentsByIdleGaps_MultipleGapsInOneSegment(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t12pm, message: "work"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t930, Repo: "/repo"}, + {ID: "s2", Timestamp: t1030, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t10am, Repo: "/repo"}, + {ID: "a2", Timestamp: t11am, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + // Should be: [9:00-9:30], [10:00-10:30], [11:00-12:00] + assert.Len(t, result, 3) + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t930, result[0].to) + assert.Equal(t, t10am, result[1].from) + assert.Equal(t, t1030, result[1].to) + assert.Equal(t, t11am, result[2].from) + assert.Equal(t, t12pm, result[2].to) +} + +func TestTrimSegmentsByIdleGaps_GapSpansMultipleSegments(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t10am, message: "commit-1"}, + {branch: "main", from: t10am, to: t12pm, message: "commit-2"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t930, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + // First segment [9:00-10:00] trimmed to [9:00-9:30] + // Second segment [10:00-12:00] trimmed to [11:00-12:00] + assert.Len(t, result, 2) + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t930, result[0].to) + assert.Equal(t, "commit-1", result[0].message) + assert.Equal(t, t11am, result[1].from) + assert.Equal(t, t12pm, result[1].to) + assert.Equal(t, "commit-2", result[1].message) +} + +func TestTrimSegmentsByIdleGaps_CommitMessagePreserved(t *testing.T) { + segments := []sessionSegment{ + {branch: "feat", from: t9am, to: t12pm, message: "fix: important bug"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t10am, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + for _, seg := range result { + assert.Equal(t, "fix: important bug", seg.message) + assert.Equal(t, "feat", seg.branch) + } +} + +func TestTrimSegmentsByIdleGaps_NoOverlap(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", from: t9am, to: t10am, message: "work"}, + } + + // Gap is entirely after the segment + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t11am, Repo: "/repo"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t12pm, Repo: "/repo"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 1) + assert.Equal(t, segments[0], result[0]) +} diff --git a/internal/timetrack/timetrack.go b/internal/timetrack/timetrack.go index eab7d42..cd4d7e7 100644 --- a/internal/timetrack/timetrack.go +++ b/internal/timetrack/timetrack.go @@ -72,6 +72,12 @@ type DetailedReportData struct { // checkout ranges clipped to schedule windows. Days listed in generatedDays // (format "2006-01-02") are excluded from checkout attribution — they have // already been materialized as editable log entries by the generate command. +// ActivityEntries holds optional activity entries for precise mode idle trimming. +type ActivityEntries struct { + Stops []entry.ActivityStopEntry + Starts []entry.ActivityStartEntry +} + func BuildReport( checkouts []entry.CheckoutEntry, logs []entry.Entry, @@ -80,6 +86,7 @@ func BuildReport( year int, month time.Month, now time.Time, generatedDays []string, + activity ...ActivityEntries, ) ReportData { daysInMonth := daysIn(year, month) @@ -101,6 +108,10 @@ func BuildReport( var checkoutBucket map[string]map[int]int if len(commits) > 0 { segments := buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, now) + // Trim idle gaps if activity entries provided + if len(activity) > 0 && (len(activity[0].Stops) > 0 || len(activity[0].Starts) > 0) { + segments = trimSegmentsByIdleGaps(segments, activity[0].Stops, activity[0].Starts) + } checkoutBucket = buildSegmentBucket(segments, year, month, daysInMonth, scheduleWindows, now.Location()) } else { checkoutBucket = buildCheckoutBucket(checkouts, year, month, daysInMonth, scheduleWindows, now) @@ -365,6 +376,7 @@ func BuildDetailedReport( daySchedules []schedule.DaySchedule, from, to time.Time, now time.Time, + activity ...ActivityEntries, ) DetailedReportData { year := from.Year() month := from.Month() @@ -379,6 +391,10 @@ func BuildDetailedReport( // Build segments (checkout sessions split by commits) segments := buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, now) + // Trim idle gaps if activity entries provided + if len(activity) > 0 && (len(activity[0].Stops) > 0 || len(activity[0].Starts) > 0) { + segments = trimSegmentsByIdleGaps(segments, activity[0].Stops, activity[0].Starts) + } loc := now.Location() // Compute aggregated checkout bucket from segments (for schedule deduction) diff --git a/internal/watch/daemon.go b/internal/watch/daemon.go new file mode 100644 index 0000000..b4fabd7 --- /dev/null +++ b/internal/watch/daemon.go @@ -0,0 +1,335 @@ +package watch + +import ( + "context" + "log" + "os" + "os/signal" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/hashutil" + "github.com/Flyrell/hourgit/internal/project" + "github.com/fsnotify/fsnotify" +) + +const stateFlushInterval = 60 * time.Second + +// DaemonConfig holds the daemon's runtime configuration for a single repo. +type DaemonConfig struct { + Repo string + Slug string + Threshold time.Duration +} + +// Daemon is the central background watcher that monitors repos with precise mode. +type Daemon struct { + homeDir string + writer EntryWriter + state *WatchState + mu sync.Mutex + debouncers map[string]*RepoDebouncer // repo path -> debouncer + watchers map[string]*fsnotify.Watcher + cancel context.CancelFunc +} + +// NewDaemon creates a new daemon instance. +func NewDaemon(homeDir string, writer EntryWriter) *Daemon { + return &Daemon{ + homeDir: homeDir, + writer: writer, + debouncers: make(map[string]*RepoDebouncer), + watchers: make(map[string]*fsnotify.Watcher), + } +} + +// Run starts the daemon, loads config, sets up watchers, and blocks until stopped. +func (d *Daemon) Run() error { + // Write PID file + if err := WritePID(d.homeDir); err != nil { + return err + } + defer func() { _ = RemovePID(d.homeDir) }() + + // Load or create state + state, err := LoadWatchState(d.homeDir) + if err != nil { + state = NewWatchState() + } + d.state = state + + // Recover from crash — check for unpaired activity_start entries + d.recoverFromCrash() + + ctx, cancel := context.WithCancel(context.Background()) + d.cancel = cancel + + // Load initial config and set up watchers + if err := d.reloadConfig(); err != nil { + log.Printf("warning: failed to load config: %v", err) + } + + // Watch config file for changes + configWatcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("warning: cannot watch config file: %v", err) + } else { + configPath := project.ConfigPath(d.homeDir) + _ = configWatcher.Add(filepath.Dir(configPath)) + go d.watchConfigChanges(ctx, configWatcher, configPath) + } + + // State flush ticker + flushTicker := time.NewTicker(stateFlushInterval) + defer flushTicker.Stop() + go func() { + for { + select { + case <-ctx.Done(): + return + case <-flushTicker.C: + _ = d.state.Flush(d.homeDir) + } + } + }() + + // Wait for shutdown signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + + select { + case <-sigCh: + case <-ctx.Done(): + } + + // Graceful shutdown + d.shutdown() + if configWatcher != nil { + _ = configWatcher.Close() + } + + return nil +} + +// Stop signals the daemon to stop. +func (d *Daemon) Stop() { + if d.cancel != nil { + d.cancel() + } +} + +// shutdown stops all watchers and writes final activity_stop entries. +func (d *Daemon) shutdown() { + d.mu.Lock() + defer d.mu.Unlock() + + for _, db := range d.debouncers { + db.Shutdown() + } + for _, w := range d.watchers { + _ = w.Close() + } + _ = d.state.Flush(d.homeDir) + _ = RemoveState(d.homeDir) +} + +// reloadConfig reads the config and updates watchers for repos with precise mode. +func (d *Daemon) reloadConfig() error { + cfg, err := project.ReadConfig(d.homeDir) + if err != nil { + return err + } + + d.mu.Lock() + defer d.mu.Unlock() + + // Collect repos that should be watched + wanted := make(map[string]DaemonConfig) + for _, p := range cfg.Projects { + if !p.Precise { + continue + } + threshold := p.IdleThresholdMinutes + if threshold <= 0 { + threshold = project.DefaultIdleThresholdMinutes + } + for _, repo := range p.Repos { + wanted[repo] = DaemonConfig{ + Repo: repo, + Slug: p.Slug, + Threshold: time.Duration(threshold) * time.Minute, + } + } + } + + // Remove watchers for repos no longer wanted + for repo, db := range d.debouncers { + if _, ok := wanted[repo]; !ok { + db.Shutdown() + if w, ok := d.watchers[repo]; ok { + _ = w.Close() + } + delete(d.debouncers, repo) + delete(d.watchers, repo) + } + } + + // Add watchers for new repos + for repo, dc := range wanted { + if _, ok := d.debouncers[repo]; ok { + continue + } + if err := d.addRepoWatcher(dc); err != nil { + log.Printf("warning: cannot watch %s: %v", repo, err) + } + } + + return nil +} + +// addRepoWatcher sets up an fsnotify watcher and debouncer for a repo. +func (d *Daemon) addRepoWatcher(dc DaemonConfig) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + // Walk directory tree and add non-.git directories + err = filepath.WalkDir(dc.Repo, func(path string, info os.DirEntry, err error) error { + if err != nil { + return nil // skip inaccessible + } + if !info.IsDir() { + return nil + } + if ShouldIgnore(dc.Repo, path) { + return filepath.SkipDir + } + return watcher.Add(path) + }) + if err != nil { + _ = watcher.Close() + return err + } + + db := NewRepoDebouncer(dc.Repo, dc.Slug, d.homeDir, dc.Threshold, d.writer, d.state) + d.debouncers[dc.Repo] = db + d.watchers[dc.Repo] = watcher + + go d.watchRepo(watcher, db, dc.Repo) + return nil +} + +// watchRepo processes fsnotify events for a single repo. +func (d *Daemon) watchRepo(watcher *fsnotify.Watcher, db *RepoDebouncer, repoDir string) { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + // Only care about writes and creates + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + if ShouldIgnore(repoDir, event.Name) { + continue + } + db.OnFileEvent(time.Now()) + case _, ok := <-watcher.Errors: + if !ok { + return + } + } + } +} + +// watchConfigChanges watches for config file changes and reloads. +func (d *Daemon) watchConfigChanges(ctx context.Context, watcher *fsnotify.Watcher, configPath string) { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Name != configPath { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + // Small delay to avoid reading partial writes + time.Sleep(100 * time.Millisecond) + if err := d.reloadConfig(); err != nil { + log.Printf("warning: config reload failed: %v", err) + } + case _, ok := <-watcher.Errors: + if !ok { + return + } + } + } +} + +// recoverFromCrash checks for unpaired activity_start entries and writes +// retrospective activity_stop entries using state file timestamps. +func (d *Daemon) recoverFromCrash() { + cfg, err := project.ReadConfig(d.homeDir) + if err != nil { + return + } + + for _, p := range cfg.Projects { + if !p.Precise { + continue + } + + starts, err := entry.ReadAllActivityStartEntries(d.homeDir, p.Slug) + if err != nil { + continue + } + stops, err := entry.ReadAllActivityStopEntries(d.homeDir, p.Slug) + if err != nil { + continue + } + + // Build set of stop timestamps to find unpaired starts + stopTimes := make(map[string]bool) + for _, s := range stops { + stopTimes[s.Repo+s.Timestamp.Format(time.RFC3339)] = true + } + + // Find the latest stop per repo + latestStop := make(map[string]time.Time) + for _, s := range stops { + if s.Timestamp.After(latestStop[s.Repo]) { + latestStop[s.Repo] = s.Timestamp + } + } + + // Check each start for a matching stop after it + for _, start := range starts { + stopAfter := latestStop[start.Repo] + if stopAfter.After(start.Timestamp) || stopAfter.Equal(start.Timestamp) { + continue // Has a stop after this start + } + + // Unpaired start — write retrospective stop + stopTime := start.Timestamp // conservative default + if lastAct, ok := d.state.GetLastActivity(start.Repo); ok && lastAct.After(start.Timestamp) { + stopTime = lastAct + } + + _ = d.writer.WriteActivityStop(d.homeDir, p.Slug, entry.ActivityStopEntry{ + ID: hashutil.GenerateID(start.Repo + stopTime.String() + "recovery"), + Timestamp: stopTime, + Repo: start.Repo, + }) + } + } +} diff --git a/internal/watch/daemon_test.go b/internal/watch/daemon_test.go new file mode 100644 index 0000000..664b9c8 --- /dev/null +++ b/internal/watch/daemon_test.go @@ -0,0 +1,126 @@ +package watch + +import ( + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/schedule" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupDaemonTest(t *testing.T) string { + t.Helper() + home := t.TempDir() + + cfg := &project.Config{ + Defaults: schedule.DefaultSchedules(), + Projects: []project.ProjectEntry{ + { + ID: "aaa1111", + Name: "test", + Slug: "test", + Repos: []string{"/some/repo"}, + Precise: true, + IdleThresholdMinutes: 5, + }, + }, + } + require.NoError(t, project.WriteConfig(home, cfg)) + return home +} + +func TestDaemonReloadConfig(t *testing.T) { + home := setupDaemonTest(t) + writer := &mockEntryWriter{} + d := NewDaemon(home, writer) + d.state = NewWatchState() + + // reloadConfig should not error even if repos don't exist on disk + err := d.reloadConfig() + // It may warn about repos not existing, but shouldn't error fatally + // In practice the watcher.Add will fail silently + _ = err +} + +func TestDaemonRecoverFromCrash(t *testing.T) { + home := setupDaemonTest(t) + writer := &mockEntryWriter{} + + // Write an unpaired activity_start + startEntry := entry.ActivityStartEntry{ + ID: "aab1234", + Type: entry.TypeActivityStart, + Timestamp: time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC), + Repo: "/some/repo", + } + require.NoError(t, entry.WriteActivityStartEntry(home, "test", startEntry)) + + // Create state with later lastActivity + state := NewWatchState() + state.SetLastActivity("/some/repo", time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)) + + d := NewDaemon(home, writer) + d.state = state + + d.recoverFromCrash() + + // Should have written a retrospective activity_stop + assert.Equal(t, 1, writer.stopCount()) + writer.mu.Lock() + assert.Equal(t, time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC), writer.stops[0].Timestamp) + writer.mu.Unlock() +} + +func TestDaemonRecoverFromCrashNoState(t *testing.T) { + home := setupDaemonTest(t) + writer := &mockEntryWriter{} + + // Write an unpaired activity_start + startEntry := entry.ActivityStartEntry{ + ID: "aab1234", + Type: entry.TypeActivityStart, + Timestamp: time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC), + Repo: "/some/repo", + } + require.NoError(t, entry.WriteActivityStartEntry(home, "test", startEntry)) + + // No state file — should use start timestamp as conservative stop + d := NewDaemon(home, writer) + d.state = NewWatchState() + + d.recoverFromCrash() + + assert.Equal(t, 1, writer.stopCount()) + writer.mu.Lock() + // Uses start timestamp as fallback + assert.Equal(t, time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC), writer.stops[0].Timestamp) + writer.mu.Unlock() +} + +func TestDaemonRecoverFromCrashPairedStart(t *testing.T) { + home := setupDaemonTest(t) + writer := &mockEntryWriter{} + + // Write paired start and stop + require.NoError(t, entry.WriteActivityStartEntry(home, "test", entry.ActivityStartEntry{ + ID: "aab1234", + Timestamp: time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC), + Repo: "/some/repo", + })) + require.NoError(t, entry.WriteActivityStopEntry(home, "test", entry.ActivityStopEntry{ + ID: "aac1234", + Timestamp: time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC), + Repo: "/some/repo", + })) + + d := NewDaemon(home, writer) + d.state = NewWatchState() + + d.recoverFromCrash() + + // No additional stops should be written — start is already paired + assert.Equal(t, 0, writer.stopCount()) +} diff --git a/internal/watch/debounce.go b/internal/watch/debounce.go new file mode 100644 index 0000000..514332d --- /dev/null +++ b/internal/watch/debounce.go @@ -0,0 +1,137 @@ +package watch + +import ( + "sync" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/hashutil" +) + +// EntryWriter abstracts entry writing for testability. +type EntryWriter interface { + WriteActivityStop(homeDir, slug string, e entry.ActivityStopEntry) error + WriteActivityStart(homeDir, slug string, e entry.ActivityStartEntry) error +} + +// defaultEntryWriter uses the real entry package functions. +type defaultEntryWriter struct{} + +func (d defaultEntryWriter) WriteActivityStop(homeDir, slug string, e entry.ActivityStopEntry) error { + return entry.WriteActivityStopEntry(homeDir, slug, e) +} + +func (d defaultEntryWriter) WriteActivityStart(homeDir, slug string, e entry.ActivityStartEntry) error { + return entry.WriteActivityStartEntry(homeDir, slug, e) +} + +// DefaultEntryWriter returns the real entry writer. +func DefaultEntryWriter() EntryWriter { + return defaultEntryWriter{} +} + +// RepoDebouncer manages the debounce state machine for a single repo. +type RepoDebouncer struct { + mu sync.Mutex + repo string + slug string + homeDir string + threshold time.Duration + lastActivity time.Time + idle bool + timer *time.Timer + writer EntryWriter + state *WatchState +} + +// NewRepoDebouncer creates a debouncer for a single repo. +func NewRepoDebouncer(repo, slug, homeDir string, threshold time.Duration, writer EntryWriter, state *WatchState) *RepoDebouncer { + return &RepoDebouncer{ + repo: repo, + slug: slug, + homeDir: homeDir, + threshold: threshold, + idle: true, // start as idle until first file event + writer: writer, + state: state, + } +} + +// OnFileEvent is called when a file change is detected in the repo. +func (d *RepoDebouncer) OnFileEvent(now time.Time) { + d.mu.Lock() + defer d.mu.Unlock() + + // If idle, write activity_start + if d.idle { + d.idle = false + _ = d.writer.WriteActivityStart(d.homeDir, d.slug, entry.ActivityStartEntry{ + ID: hashutil.GenerateID(d.repo + now.String()), + Timestamp: now, + Repo: d.repo, + }) + } + + d.lastActivity = now + d.state.SetLastActivity(d.repo, now) + + // Reset or start debounce timer + if d.timer != nil { + d.timer.Stop() + } + d.timer = time.AfterFunc(d.threshold, func() { + d.onIdle() + }) +} + +// onIdle is called when the debounce timer fires (no file changes for threshold duration). +func (d *RepoDebouncer) onIdle() { + d.mu.Lock() + defer d.mu.Unlock() + + if d.idle { + return + } + + d.idle = true + // Write activity_stop with lastActivity timestamp (not current time) + _ = d.writer.WriteActivityStop(d.homeDir, d.slug, entry.ActivityStopEntry{ + ID: hashutil.GenerateID(d.repo + d.lastActivity.String()), + Timestamp: d.lastActivity, + Repo: d.repo, + }) +} + +// Shutdown writes activity_stop if currently active and stops the timer. +func (d *RepoDebouncer) Shutdown() { + d.mu.Lock() + defer d.mu.Unlock() + + if d.timer != nil { + d.timer.Stop() + d.timer = nil + } + + if !d.idle && !d.lastActivity.IsZero() { + d.idle = true + _ = d.writer.WriteActivityStop(d.homeDir, d.slug, entry.ActivityStopEntry{ + ID: hashutil.GenerateID(d.repo + d.lastActivity.String() + "shutdown"), + Timestamp: d.lastActivity, + Repo: d.repo, + }) + } +} + +// IsIdle returns whether the debouncer is in idle state. +func (d *RepoDebouncer) IsIdle() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.idle +} + +// LastActivity returns the last observed file change time. +func (d *RepoDebouncer) LastActivity() time.Time { + d.mu.Lock() + defer d.mu.Unlock() + return d.lastActivity +} diff --git a/internal/watch/debounce_test.go b/internal/watch/debounce_test.go new file mode 100644 index 0000000..89376ae --- /dev/null +++ b/internal/watch/debounce_test.go @@ -0,0 +1,165 @@ +package watch + +import ( + "sync" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockEntryWriter records written entries for assertions. +type mockEntryWriter struct { + mu sync.Mutex + stops []entry.ActivityStopEntry + starts []entry.ActivityStartEntry +} + +func (m *mockEntryWriter) WriteActivityStop(_ string, _ string, e entry.ActivityStopEntry) error { + m.mu.Lock() + defer m.mu.Unlock() + m.stops = append(m.stops, e) + return nil +} + +func (m *mockEntryWriter) WriteActivityStart(_ string, _ string, e entry.ActivityStartEntry) error { + m.mu.Lock() + defer m.mu.Unlock() + m.starts = append(m.starts, e) + return nil +} + +func (m *mockEntryWriter) stopCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.stops) +} + +func (m *mockEntryWriter) startCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.starts) +} + +func TestDebouncerFirstEventWritesStart(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 100*time.Millisecond, writer, state) + + now := time.Now() + db.OnFileEvent(now) + + assert.Equal(t, 1, writer.startCount()) + assert.Equal(t, 0, writer.stopCount()) + assert.False(t, db.IsIdle()) + + // Cleanup + db.Shutdown() +} + +func TestDebouncerIdleAfterThreshold(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 50*time.Millisecond, writer, state) + + now := time.Now() + db.OnFileEvent(now) + + // Wait for debounce timer to fire + time.Sleep(100 * time.Millisecond) + + assert.Equal(t, 1, writer.startCount()) + assert.Equal(t, 1, writer.stopCount()) + assert.True(t, db.IsIdle()) + + // Stop timestamp should be the lastActivity, not the timer fire time + writer.mu.Lock() + assert.Equal(t, now, writer.stops[0].Timestamp) + writer.mu.Unlock() +} + +func TestDebouncerResetOnActivity(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 80*time.Millisecond, writer, state) + + t1 := time.Now() + db.OnFileEvent(t1) + + // Send another event before threshold + time.Sleep(40 * time.Millisecond) + t2 := time.Now() + db.OnFileEvent(t2) + + // Should not have gone idle yet + assert.Equal(t, 0, writer.stopCount()) + + // Wait for debounce timer + time.Sleep(120 * time.Millisecond) + + assert.Equal(t, 1, writer.stopCount()) + assert.Equal(t, 1, writer.startCount()) // Only one start since we never went idle +} + +func TestDebouncerIdleToActiveTransition(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 50*time.Millisecond, writer, state) + + // First activity + db.OnFileEvent(time.Now()) + time.Sleep(80 * time.Millisecond) // Go idle + + require.Equal(t, 1, writer.startCount()) + require.Equal(t, 1, writer.stopCount()) + + // Resume activity + db.OnFileEvent(time.Now()) + + assert.Equal(t, 2, writer.startCount()) + assert.False(t, db.IsIdle()) + + db.Shutdown() +} + +func TestDebouncerShutdownWritesStop(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 10*time.Second, writer, state) + + now := time.Now() + db.OnFileEvent(now) + assert.False(t, db.IsIdle()) + + db.Shutdown() + + assert.Equal(t, 1, writer.stopCount()) + assert.True(t, db.IsIdle()) +} + +func TestDebouncerShutdownIdleNoOp(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 10*time.Second, writer, state) + + // Never active, shutdown should not write stop + db.Shutdown() + assert.Equal(t, 0, writer.stopCount()) +} + +func TestDebouncerUpdatesState(t *testing.T) { + writer := &mockEntryWriter{} + state := NewWatchState() + db := NewRepoDebouncer("/repo", "test", "/home", 10*time.Second, writer, state) + + now := time.Now() + db.OnFileEvent(now) + + got, ok := state.GetLastActivity("/repo") + assert.True(t, ok) + assert.Equal(t, now, got) + + db.Shutdown() +} diff --git a/internal/watch/ensure.go b/internal/watch/ensure.go new file mode 100644 index 0000000..03cdc8e --- /dev/null +++ b/internal/watch/ensure.go @@ -0,0 +1,35 @@ +package watch + +import ( + "github.com/Flyrell/hourgit/internal/project" +) + +// EnsureWatcherService checks if the watcher service should be installed or removed +// based on whether any project has precise mode enabled. +// binPath is the path to the hourgit binary. +func EnsureWatcherService(homeDir, binPath string) error { + cfg, err := project.ReadConfig(homeDir) + if err != nil { + return err + } + + sm, err := NewServiceManager(homeDir) + if err != nil { + return nil // unsupported platform, silently skip + } + + anyPrecise := project.AnyPreciseProject(cfg) + + if anyPrecise && !sm.IsInstalled() { + if err := sm.Install(binPath); err != nil { + return err + } + return sm.Start() + } + + if !anyPrecise && sm.IsInstalled() { + return sm.Remove() + } + + return nil +} diff --git a/internal/watch/gitignore.go b/internal/watch/gitignore.go new file mode 100644 index 0000000..b9f526f --- /dev/null +++ b/internal/watch/gitignore.go @@ -0,0 +1,96 @@ +package watch + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// ShouldIgnore checks if a file path should be ignored based on the repo's +// .gitignore patterns and built-in exclusions. +func ShouldIgnore(repoDir, filePath string) bool { + // Always exclude .git directory + rel, err := filepath.Rel(repoDir, filePath) + if err != nil { + return true + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, p := range parts { + if p == ".git" { + return true + } + } + + patterns := loadGitignorePatterns(repoDir) + return matchesAnyPattern(rel, patterns) +} + +// loadGitignorePatterns reads .gitignore from the repo root and returns patterns. +func loadGitignorePatterns(repoDir string) []string { + path := filepath.Join(repoDir, ".gitignore") + f, err := os.Open(path) + if err != nil { + return nil + } + defer func() { _ = f.Close() }() + + var patterns []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + patterns = append(patterns, line) + } + return patterns +} + +// matchesAnyPattern checks if a relative path matches any gitignore pattern. +// Supports basic patterns: exact names, directory prefixes, and wildcard globs. +func matchesAnyPattern(relPath string, patterns []string) bool { + for _, pattern := range patterns { + if matchPattern(relPath, pattern) { + return true + } + } + return false +} + +// matchPattern checks a single gitignore pattern against a relative path. +func matchPattern(relPath, pattern string) bool { + // Handle negation (we don't support it — skip) + if strings.HasPrefix(pattern, "!") { + return false + } + + // Strip trailing slash (directory marker) + dirOnly := strings.HasSuffix(pattern, "/") + pattern = strings.TrimSuffix(pattern, "/") + + // Check each path component for basename match + parts := strings.Split(relPath, string(filepath.Separator)) + + // If pattern contains a slash, match against the full path + if strings.Contains(pattern, "/") { + matched, _ := filepath.Match(pattern, relPath) + return matched + } + + // Otherwise, match against any path component + for i, part := range parts { + matched, _ := filepath.Match(pattern, part) + if matched { + // If dirOnly, only match directories (not the last component unless it's a prefix) + if dirOnly && i == len(parts)-1 { + // We can't tell if the last component is a dir from the path alone, + // but for gitignore purposes, if it matched a dir pattern mid-path, that's fine. + // For the leaf, we still match since fsnotify won't send events for dirs themselves. + return true + } + return true + } + } + return false +} diff --git a/internal/watch/gitignore_test.go b/internal/watch/gitignore_test.go new file mode 100644 index 0000000..7bde2f4 --- /dev/null +++ b/internal/watch/gitignore_test.go @@ -0,0 +1,55 @@ +package watch + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldIgnoreGitDir(t *testing.T) { + assert.True(t, ShouldIgnore("/repo", "/repo/.git/objects/pack")) + assert.True(t, ShouldIgnore("/repo", "/repo/.git/HEAD")) + assert.True(t, ShouldIgnore("/repo", "/repo/.git")) +} + +func TestShouldIgnoreNormalFiles(t *testing.T) { + assert.False(t, ShouldIgnore("/repo", "/repo/main.go")) + assert.False(t, ShouldIgnore("/repo", "/repo/src/app.go")) +} + +func TestShouldIgnoreGitignorePatterns(t *testing.T) { + repo := t.TempDir() + gitignore := "node_modules\n*.log\nbuild/\n" + require.NoError(t, os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(gitignore), 0644)) + + assert.True(t, ShouldIgnore(repo, filepath.Join(repo, "node_modules", "pkg", "index.js"))) + assert.True(t, ShouldIgnore(repo, filepath.Join(repo, "app.log"))) + assert.True(t, ShouldIgnore(repo, filepath.Join(repo, "build", "output.js"))) + assert.False(t, ShouldIgnore(repo, filepath.Join(repo, "src", "main.go"))) +} + +func TestMatchPatternWildcard(t *testing.T) { + assert.True(t, matchPattern("foo.log", "*.log")) + assert.False(t, matchPattern("foo.txt", "*.log")) +} + +func TestMatchPatternExactName(t *testing.T) { + assert.True(t, matchPattern("node_modules/pkg/file.js", "node_modules")) + assert.False(t, matchPattern("src/main.go", "node_modules")) +} + +func TestMatchPatternDirSlash(t *testing.T) { + assert.True(t, matchPattern("build/output.js", "build")) +} + +func TestMatchPatternWithSlash(t *testing.T) { + assert.True(t, matchPattern("dist/bundle.js", "dist/*")) +} + +func TestMatchPatternNegation(t *testing.T) { + // Negation patterns are skipped (not supported) + assert.False(t, matchPattern("important.log", "!important.log")) +} diff --git a/internal/watch/pid.go b/internal/watch/pid.go new file mode 100644 index 0000000..e285aa1 --- /dev/null +++ b/internal/watch/pid.go @@ -0,0 +1,76 @@ +package watch + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// PIDPath returns the path to the PID file. +func PIDPath(homeDir string) string { + return filepath.Join(homeDir, ".hourgit", "watch.pid") +} + +// WritePID writes the current process PID to the PID file. +func WritePID(homeDir string) error { + path := PIDPath(homeDir) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0644) +} + +// ReadPID reads the PID from the PID file. Returns 0 if the file doesn't exist. +func ReadPID(homeDir string) (int, error) { + data, err := os.ReadFile(PIDPath(homeDir)) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return 0, fmt.Errorf("invalid PID file: %w", err) + } + return pid, nil +} + +// IsProcessAlive checks if a process with the given PID is running. +func IsProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + // Signal 0 checks if the process exists without actually sending a signal. + err = proc.Signal(syscall.Signal(0)) + return err == nil +} + +// RemovePID removes the PID file. +func RemovePID(homeDir string) error { + err := os.Remove(PIDPath(homeDir)) + if os.IsNotExist(err) { + return nil + } + return err +} + +// IsDaemonRunning checks if the daemon is running by reading the PID file +// and verifying the process is alive. +func IsDaemonRunning(homeDir string) (bool, int, error) { + pid, err := ReadPID(homeDir) + if err != nil { + return false, 0, err + } + if pid == 0 { + return false, 0, nil + } + return IsProcessAlive(pid), pid, nil +} diff --git a/internal/watch/pid_test.go b/internal/watch/pid_test.go new file mode 100644 index 0000000..30a9a7d --- /dev/null +++ b/internal/watch/pid_test.go @@ -0,0 +1,70 @@ +package watch + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPIDWriteReadRemove(t *testing.T) { + home := t.TempDir() + + // Write PID + require.NoError(t, WritePID(home)) + + // Read PID + pid, err := ReadPID(home) + require.NoError(t, err) + assert.Equal(t, os.Getpid(), pid) + + // Remove PID + require.NoError(t, RemovePID(home)) + + // Read after remove + pid, err = ReadPID(home) + require.NoError(t, err) + assert.Equal(t, 0, pid) +} + +func TestReadPIDMissing(t *testing.T) { + home := t.TempDir() + + pid, err := ReadPID(home) + require.NoError(t, err) + assert.Equal(t, 0, pid) +} + +func TestRemovePIDMissing(t *testing.T) { + home := t.TempDir() + assert.NoError(t, RemovePID(home)) +} + +func TestIsProcessAlive(t *testing.T) { + // Current process should be alive + assert.True(t, IsProcessAlive(os.Getpid())) + + // PID 0 should not be alive + assert.False(t, IsProcessAlive(0)) + + // Negative PID + assert.False(t, IsProcessAlive(-1)) +} + +func TestIsDaemonRunning(t *testing.T) { + home := t.TempDir() + + // No PID file + running, pid, err := IsDaemonRunning(home) + require.NoError(t, err) + assert.False(t, running) + assert.Equal(t, 0, pid) + + // Write our own PID (which is alive) + require.NoError(t, WritePID(home)) + running, pid, err = IsDaemonRunning(home) + require.NoError(t, err) + assert.True(t, running) + assert.Equal(t, os.Getpid(), pid) +} diff --git a/internal/watch/service.go b/internal/watch/service.go new file mode 100644 index 0000000..ab4fd6e --- /dev/null +++ b/internal/watch/service.go @@ -0,0 +1,15 @@ +package watch + +// ServiceManager manages the daemon as an OS service. +type ServiceManager interface { + Install(binPath string) error + Remove() error + Start() error + Stop() error + IsInstalled() bool + IsRunning() bool +} + +// NewServiceManager returns the platform-specific ServiceManager. +// Implemented per-platform in service_.go files. +// Returns nil, error for unsupported platforms. diff --git a/internal/watch/service_darwin.go b/internal/watch/service_darwin.go new file mode 100644 index 0000000..22e70a1 --- /dev/null +++ b/internal/watch/service_darwin.go @@ -0,0 +1,101 @@ +//go:build darwin + +package watch + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// NewServiceManager returns the macOS (launchd) service manager. +func NewServiceManager(homeDir string) (ServiceManager, error) { + return newLaunchdManager(homeDir), nil +} + +const ( + launchdLabel = "com.hourgit.watch" +) + +type launchdManager struct { + homeDir string + plistPath string +} + +func newLaunchdManager(homeDir string) *launchdManager { + home, _ := os.UserHomeDir() + return &launchdManager{ + homeDir: homeDir, + plistPath: filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist"), + } +} + +// PlistContent generates the launchd plist XML for the watcher daemon. +func PlistContent(binPath string) string { + return fmt.Sprintf(` + + + + Label + %s + ProgramArguments + + %s + watch + + KeepAlive + + RunAtLoad + + StandardOutPath + /tmp/hourgit-watch.log + StandardErrorPath + /tmp/hourgit-watch.log + + +`, launchdLabel, binPath) +} + +func (m *launchdManager) Install(binPath string) error { + dir := filepath.Dir(m.plistPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + content := PlistContent(binPath) + return os.WriteFile(m.plistPath, []byte(content), 0644) +} + +func (m *launchdManager) Remove() error { + // Unload first if loaded + if m.IsRunning() { + _ = m.Stop() + } + err := os.Remove(m.plistPath) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (m *launchdManager) Start() error { + return exec.Command("launchctl", "load", m.plistPath).Run() +} + +func (m *launchdManager) Stop() error { + return exec.Command("launchctl", "unload", m.plistPath).Run() +} + +func (m *launchdManager) IsInstalled() bool { + _, err := os.Stat(m.plistPath) + return err == nil +} + +func (m *launchdManager) IsRunning() bool { + out, err := exec.Command("launchctl", "list").Output() + if err != nil { + return false + } + return strings.Contains(string(out), launchdLabel) +} diff --git a/internal/watch/service_darwin_test.go b/internal/watch/service_darwin_test.go new file mode 100644 index 0000000..a9f990e --- /dev/null +++ b/internal/watch/service_darwin_test.go @@ -0,0 +1,21 @@ +//go:build darwin + +package watch + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlistContent(t *testing.T) { + content := PlistContent("/usr/local/bin/hourgit") + + assert.Contains(t, content, "com.hourgit.watch") + assert.Contains(t, content, "/usr/local/bin/hourgit") + assert.Contains(t, content, "watch") + assert.Contains(t, content, "KeepAlive") + assert.Contains(t, content, "RunAtLoad") + assert.True(t, strings.HasPrefix(content, " +hourgit project add [--mode ] ``` +| Flag | Default | Description | +|------|---------|-------------| +| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | + ## `hourgit project assign` Assign the current repository to a project. diff --git a/web/docs/commands/time-tracking.md b/web/docs/commands/time-tracking.md index 367c9bd..28f8439 100644 --- a/web/docs/commands/time-tracking.md +++ b/web/docs/commands/time-tracking.md @@ -7,12 +7,13 @@ Core commands for recording, viewing, and managing your time entries. Initialize Hourgit in the current git repository by installing a post-checkout hook. ```bash -hourgit init [--project ] [--force] [--merge] [--yes] +hourgit init [--project ] [--mode ] [--force] [--merge] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Assign repository to a project by name or ID (creates if needed) | +| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | | `-f`, `--force` | `false` | Overwrite existing post-checkout hook | | `-m`, `--merge` | `false` | Append to existing post-checkout hook | | `-y`, `--yes` | `false` | Skip confirmation prompt | @@ -184,3 +185,4 @@ hourgit status [--project ] - Time logged today and remaining scheduled hours - Today's schedule windows - Tracking state (active/inactive based on current time vs schedule) +- Watcher state (when precise mode is enabled: active/stopped) diff --git a/web/docs/commands/utility.md b/web/docs/commands/utility.md index 37d76fd..dce5173 100644 --- a/web/docs/commands/utility.md +++ b/web/docs/commands/utility.md @@ -25,3 +25,13 @@ This command always fetches the latest version from GitHub, bypassing the cached ### Auto-update vs manual update Hourgit also checks for updates automatically when you run any interactive command (with an 8-hour cache). The `update` command is for when you want to check right now, regardless of when the last check happened. + +## `hourgit watch` + +Run the filesystem watcher daemon in the foreground. The daemon monitors file changes in repositories with precise mode enabled and writes activity entries to detect idle gaps. + +```bash +hourgit watch +``` + +Normally the watcher is managed automatically as an OS service when precise mode is enabled. Use this command for debugging or manual operation. diff --git a/web/docs/configuration.md b/web/docs/configuration.md index b64b8dd..56c4d8e 100644 --- a/web/docs/configuration.md +++ b/web/docs/configuration.md @@ -30,6 +30,19 @@ hourgit config reset --project 'My Project' hourgit config report --project 'My Project' --month 3 ``` +## Precise Mode + +By default, Hourgit attributes all time between branch checkouts (within your schedule) as work. **Precise mode** adds filesystem-level idle detection: a background daemon watches your repository for file changes and records when you stop and resume working. Idle gaps are automatically trimmed from checkout sessions at report time. + +Enable precise mode during init or project creation: + +```bash +hourgit init --mode precise +hourgit project add myproject --mode precise +``` + +The idle threshold defaults to 10 minutes — after 10 minutes of no file changes, the daemon records an idle stop. When precise mode is enabled, Hourgit auto-installs a user-level OS service to run the watcher daemon. + ## Editing Defaults Changes to defaults only affect newly created projects. Existing projects keep their current schedule. diff --git a/web/docs/data-storage.md b/web/docs/data-storage.md index c2a77df..f0f5d86 100644 --- a/web/docs/data-storage.md +++ b/web/docs/data-storage.md @@ -9,6 +9,8 @@ All Hourgit data is stored locally on your machine. There are no servers or clou | `~/.hourgit/config.json` | Global config — defaults, projects (id, name, slug, repos, schedules) | | `REPO/.git/.hourgit` | Per-repo project assignment (project name + project ID) | | `~/.hourgit//` | Per-project entries (one JSON file per entry) | +| `~/.hourgit/watch.pid` | PID file for the filesystem watcher daemon (precise mode) | +| `~/.hourgit/watch.state` | Watcher state file — last activity timestamps per repo (precise mode) | ## Entry Types @@ -18,6 +20,8 @@ Each entry is a JSON file identified by a 7-character hex hash (similar to git c - **`checkout`** — branch checkout event recorded by the git hook (previous branch, next branch, timestamp, repo) - **`commit`** — git commit event from reflog (commit ref, timestamp, message, branch, repo); used to split checkout sessions into finer time blocks - **`submit`** — submission marker for a report period (date range, creation timestamp) +- **`activity_stop`** — idle detection: records when file activity stops (timestamp of last file change, repo path) +- **`activity_start`** — idle detection: records when file activity resumes (timestamp, repo path) ## Projects From 6c39e6619908b6eb1abc0995683648e8c8eadbe7 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Fri, 13 Mar 2026 21:00:29 +0100 Subject: [PATCH 2/8] refactor: fix dead code, swallowed errors, untestable tests, and init precise-mode bug --- internal/cli/init.go | 23 ++++++++------- internal/cli/init_test.go | 26 +++++++++++++++++ internal/cli/mode.go | 11 ++++++++ internal/cli/project_add.go | 25 ++++++++++++----- internal/cli/project_add_test.go | 2 +- internal/cli/resolve.go | 5 +++- internal/cli/watcher_check.go | 4 ++- internal/cli/watcher_check_test.go | 45 +++++++++++++++++++++++++++--- internal/watch/daemon.go | 17 ++++++----- internal/watch/gitignore.go | 26 ++++++++--------- internal/watch/gitignore_test.go | 26 +++++++++++++++++ 11 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 internal/cli/mode.go diff --git a/internal/cli/init.go b/internal/cli/init.go index 06466a1..5bbb08e 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -71,8 +71,8 @@ var initCmd = LeafCommand{ }.Build() func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error { - if mode != "" && mode != "standard" && mode != "precise" { - return fmt.Errorf("invalid --mode value %q (supported: standard, precise)", mode) + if err := validateMode(mode); err != nil { + return err } gitDir := filepath.Join(dir, ".git") @@ -140,15 +140,18 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, if result.Created { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("project '%s' created (%s)", Primary(result.Entry.Name), Silent(result.Entry.ID)))) + } - if mode == "precise" { - if err := project.SetPreciseMode(homeDir, result.Entry.ID, true); err != nil { - return err - } - if err := project.SetIdleThreshold(homeDir, result.Entry.ID, project.DefaultIdleThresholdMinutes); err != nil { - return err - } - _ = watch.EnsureWatcherService(homeDir, binPath) + if mode == "precise" { + if err := project.SetPreciseMode(homeDir, result.Entry.ID, true); err != nil { + return err + } + if err := project.SetIdleThreshold(homeDir, result.Entry.ID, project.DefaultIdleThresholdMinutes); err != nil { + return err + } + if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", + Warning(fmt.Sprintf("warning: could not configure watcher service: %s", err))) } } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 951f9d9..69c8ab0 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -377,6 +377,32 @@ func TestInitWithModePrecise(t *testing.T) { assert.Equal(t, project.DefaultIdleThresholdMinutes, cfg.Projects[0].IdleThresholdMinutes) } +func TestInitWithModePreciseExistingProject(t *testing.T) { + dir := t.TempDir() + home := t.TempDir() + t.Setenv("SHELL", "") + + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0755)) + + // Pre-create the project without precise mode + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + noConfirm := func(_ string) (bool, error) { return true, nil } + skipSelect := func(_ string, _ []string) (int, error) { return 1, nil } + stdout, err := execInitDirect(dir, home, "My Project", "precise", false, false, "/usr/local/bin/hourgit", noConfirm, skipSelect) + + assert.NoError(t, err) + assert.NotContains(t, stdout, "created") + assert.Contains(t, stdout, "repository assigned to project 'My Project'") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + require.Len(t, cfg.Projects, 1) + assert.True(t, cfg.Projects[0].Precise) + assert.Equal(t, project.DefaultIdleThresholdMinutes, cfg.Projects[0].IdleThresholdMinutes) +} + func TestInitWithModeInvalid(t *testing.T) { dir := t.TempDir() home := t.TempDir() diff --git a/internal/cli/mode.go b/internal/cli/mode.go new file mode 100644 index 0000000..170a27e --- /dev/null +++ b/internal/cli/mode.go @@ -0,0 +1,11 @@ +package cli + +import "fmt" + +// validateMode checks that a --mode flag value is valid. +func validateMode(mode string) error { + if mode != "" && mode != "standard" && mode != "precise" { + return fmt.Errorf("invalid --mode value %q (supported: standard, precise)", mode) + } + return nil +} diff --git a/internal/cli/project_add.go b/internal/cli/project_add.go index dc53738..b5432ee 100644 --- a/internal/cli/project_add.go +++ b/internal/cli/project_add.go @@ -23,13 +23,23 @@ var projectAddCmd = LeafCommand{ return err } modeFlag, _ := cmd.Flags().GetString("mode") - return runProjectAdd(cmd, homeDir, args[0], modeFlag) + + binPath, err := os.Executable() + if err != nil { + return fmt.Errorf("could not resolve binary path: %w", err) + } + binPath, err = filepath.EvalSymlinks(binPath) + if err != nil { + return fmt.Errorf("could not resolve binary path: %w", err) + } + + return runProjectAdd(cmd, homeDir, args[0], modeFlag, binPath) }, }.Build() -func runProjectAdd(cmd *cobra.Command, homeDir, name, mode string) error { - if mode != "" && mode != "standard" && mode != "precise" { - return fmt.Errorf("invalid --mode value %q (supported: standard, precise)", mode) +func runProjectAdd(cmd *cobra.Command, homeDir, name, mode, binPath string) error { + if err := validateMode(mode); err != nil { + return err } entry, err := project.CreateProject(homeDir, name) @@ -44,9 +54,10 @@ func runProjectAdd(cmd *cobra.Command, homeDir, name, mode string) error { if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdMinutes); err != nil { return err } - binPath, _ := os.Executable() - binPath, _ = filepath.EvalSymlinks(binPath) - _ = watch.EnsureWatcherService(homeDir, binPath) + if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", + Warning(fmt.Sprintf("warning: could not configure watcher service: %s", err))) + } } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("project '%s' created (%s)", Primary(entry.Name), Silent(entry.ID)))) diff --git a/internal/cli/project_add_test.go b/internal/cli/project_add_test.go index bf5e6ac..b9f4401 100644 --- a/internal/cli/project_add_test.go +++ b/internal/cli/project_add_test.go @@ -18,7 +18,7 @@ func execProjectAdd(homeDir string, name string, mode ...string) (string, error) if len(mode) > 0 { m = mode[0] } - err := runProjectAdd(cmd, homeDir, name, m) + err := runProjectAdd(cmd, homeDir, name, m, "/usr/local/bin/hourgit") return stdout.String(), err } diff --git a/internal/cli/resolve.go b/internal/cli/resolve.go index 0045afd..55c53aa 100644 --- a/internal/cli/resolve.go +++ b/internal/cli/resolve.go @@ -13,7 +13,10 @@ func getContextPaths() (homeDir, repoDir string, err error) { if err != nil { return "", "", err } - repoDir, _ = os.Getwd() + repoDir, err = os.Getwd() + if err != nil { + return "", "", fmt.Errorf("could not determine working directory: %w", err) + } return homeDir, repoDir, nil } diff --git a/internal/cli/watcher_check.go b/internal/cli/watcher_check.go index ded1c8d..d74b1d6 100644 --- a/internal/cli/watcher_check.go +++ b/internal/cli/watcher_check.go @@ -18,6 +18,7 @@ type watcherCheckDeps struct { confirm ConfirmFunc binPath func() (string, error) ensureSvc func(homeDir, binPath string) error + isTTY func() bool } func defaultWatcherCheckDeps() watcherCheckDeps { @@ -34,6 +35,7 @@ func defaultWatcherCheckDeps() watcherCheckDeps { return filepath.EvalSymlinks(p) }, ensureSvc: watch.EnsureWatcherService, + isTTY: func() bool { return isatty.IsTerminal(os.Stdout.Fd()) }, } } @@ -41,7 +43,7 @@ func defaultWatcherCheckDeps() watcherCheckDeps { // Called from PersistentPreRunE. func checkWatcherHealth(cmd *cobra.Command, deps watcherCheckDeps) { // Skip in non-interactive contexts - if !isatty.IsTerminal(os.Stdout.Fd()) { + if !deps.isTTY() { return } diff --git a/internal/cli/watcher_check_test.go b/internal/cli/watcher_check_test.go index b1cb20b..1ee6435 100644 --- a/internal/cli/watcher_check_test.go +++ b/internal/cli/watcher_check_test.go @@ -6,6 +6,7 @@ import ( "github.com/Flyrell/hourgit/internal/project" "github.com/Flyrell/hourgit/internal/schedule" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,6 +20,10 @@ func setupWatcherCheckTest(t *testing.T, precise bool) (string, watcherCheckDeps t.Helper() home := t.TempDir() + // Set non-dev version so the dev guard doesn't skip + SetVersionInfo("1.0.0") + t.Cleanup(func() { SetVersionInfo("dev") }) + cfg := &project.Config{ Defaults: schedule.DefaultSchedules(), Projects: []project.ProjectEntry{ @@ -48,6 +53,7 @@ func setupWatcherCheckTest(t *testing.T, precise bool) (string, watcherCheckDeps ensureSvc: func(_, _ string) error { return nil }, + isTTY: func() bool { return true }, } return home, deps @@ -64,8 +70,7 @@ func TestWatcherCheckNoPreciseProjects(t *testing.T) { cmd := newTestCmd() checkWatcherHealth(cmd, deps) - // In non-TTY test context, it will skip early due to isatty check - _ = confirmCalled + assert.False(t, confirmCalled, "should not prompt when no precise projects") } func TestWatcherCheckDaemonRunning(t *testing.T) { @@ -83,7 +88,7 @@ func TestWatcherCheckDaemonRunning(t *testing.T) { cmd := newTestCmd() checkWatcherHealth(cmd, deps) - _ = confirmCalled + assert.False(t, confirmCalled, "should not prompt when daemon is running") } func TestWatcherCheckSkipFlag(t *testing.T) { @@ -98,5 +103,37 @@ func TestWatcherCheckSkipFlag(t *testing.T) { cmd := newTestCmd() _ = cmd.Flags().Set("skip-watcher", "true") checkWatcherHealth(cmd, deps) - _ = confirmCalled + assert.False(t, confirmCalled, "should not prompt when skip-watcher flag is set") +} + +func TestWatcherCheckPromptRestart(t *testing.T) { + _, deps := setupWatcherCheckTest(t, true) + + ensureCalled := false + deps.confirm = func(_ string) (bool, error) { + return true, nil + } + deps.ensureSvc = func(_, _ string) error { + ensureCalled = true + return nil + } + + cmd := newTestCmd() + checkWatcherHealth(cmd, deps) + assert.True(t, ensureCalled, "should call ensureSvc when user confirms restart") +} + +func TestWatcherCheckNonTTY(t *testing.T) { + _, deps := setupWatcherCheckTest(t, true) + deps.isTTY = func() bool { return false } + + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil + } + + cmd := newTestCmd() + checkWatcherHealth(cmd, deps) + assert.False(t, confirmCalled, "should not prompt in non-TTY context") } diff --git a/internal/watch/daemon.go b/internal/watch/daemon.go index b4fabd7..8a7a83a 100644 --- a/internal/watch/daemon.go +++ b/internal/watch/daemon.go @@ -33,6 +33,7 @@ type Daemon struct { mu sync.Mutex debouncers map[string]*RepoDebouncer // repo path -> debouncer watchers map[string]*fsnotify.Watcher + patterns map[string][]string // repo path -> cached gitignore patterns cancel context.CancelFunc } @@ -43,6 +44,7 @@ func NewDaemon(homeDir string, writer EntryWriter) *Daemon { writer: writer, debouncers: make(map[string]*RepoDebouncer), watchers: make(map[string]*fsnotify.Watcher), + patterns: make(map[string][]string), } } @@ -174,6 +176,7 @@ func (d *Daemon) reloadConfig() error { } delete(d.debouncers, repo) delete(d.watchers, repo) + delete(d.patterns, repo) } } @@ -215,16 +218,18 @@ func (d *Daemon) addRepoWatcher(dc DaemonConfig) error { return err } + patterns := LoadGitignorePatterns(dc.Repo) db := NewRepoDebouncer(dc.Repo, dc.Slug, d.homeDir, dc.Threshold, d.writer, d.state) d.debouncers[dc.Repo] = db d.watchers[dc.Repo] = watcher + d.patterns[dc.Repo] = patterns - go d.watchRepo(watcher, db, dc.Repo) + go d.watchRepo(watcher, db, dc.Repo, patterns) return nil } // watchRepo processes fsnotify events for a single repo. -func (d *Daemon) watchRepo(watcher *fsnotify.Watcher, db *RepoDebouncer, repoDir string) { +func (d *Daemon) watchRepo(watcher *fsnotify.Watcher, db *RepoDebouncer, repoDir string, patterns []string) { for { select { case event, ok := <-watcher.Events: @@ -235,7 +240,7 @@ func (d *Daemon) watchRepo(watcher *fsnotify.Watcher, db *RepoDebouncer, repoDir if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { continue } - if ShouldIgnore(repoDir, event.Name) { + if ShouldIgnoreWithPatterns(repoDir, event.Name, patterns) { continue } db.OnFileEvent(time.Now()) @@ -298,12 +303,6 @@ func (d *Daemon) recoverFromCrash() { continue } - // Build set of stop timestamps to find unpaired starts - stopTimes := make(map[string]bool) - for _, s := range stops { - stopTimes[s.Repo+s.Timestamp.Format(time.RFC3339)] = true - } - // Find the latest stop per repo latestStop := make(map[string]time.Time) for _, s := range stops { diff --git a/internal/watch/gitignore.go b/internal/watch/gitignore.go index b9f526f..919b702 100644 --- a/internal/watch/gitignore.go +++ b/internal/watch/gitignore.go @@ -8,8 +8,17 @@ import ( ) // ShouldIgnore checks if a file path should be ignored based on the repo's -// .gitignore patterns and built-in exclusions. +// .gitignore patterns and built-in exclusions. Reads .gitignore from disk +// on each call — use ShouldIgnoreWithPatterns for the hot path. func ShouldIgnore(repoDir, filePath string) bool { + patterns := LoadGitignorePatterns(repoDir) + return ShouldIgnoreWithPatterns(repoDir, filePath, patterns) +} + +// ShouldIgnoreWithPatterns checks if a file path should be ignored using +// pre-loaded gitignore patterns. Use this on the hot path to avoid re-reading +// .gitignore from disk on every event. +func ShouldIgnoreWithPatterns(repoDir, filePath string, patterns []string) bool { // Always exclude .git directory rel, err := filepath.Rel(repoDir, filePath) if err != nil { @@ -22,12 +31,11 @@ func ShouldIgnore(repoDir, filePath string) bool { } } - patterns := loadGitignorePatterns(repoDir) return matchesAnyPattern(rel, patterns) } -// loadGitignorePatterns reads .gitignore from the repo root and returns patterns. -func loadGitignorePatterns(repoDir string) []string { +// LoadGitignorePatterns reads .gitignore from the repo root and returns patterns. +func LoadGitignorePatterns(repoDir string) []string { path := filepath.Join(repoDir, ".gitignore") f, err := os.Open(path) if err != nil { @@ -66,7 +74,6 @@ func matchPattern(relPath, pattern string) bool { } // Strip trailing slash (directory marker) - dirOnly := strings.HasSuffix(pattern, "/") pattern = strings.TrimSuffix(pattern, "/") // Check each path component for basename match @@ -79,16 +86,9 @@ func matchPattern(relPath, pattern string) bool { } // Otherwise, match against any path component - for i, part := range parts { + for _, part := range parts { matched, _ := filepath.Match(pattern, part) if matched { - // If dirOnly, only match directories (not the last component unless it's a prefix) - if dirOnly && i == len(parts)-1 { - // We can't tell if the last component is a dir from the path alone, - // but for gitignore purposes, if it matched a dir pattern mid-path, that's fine. - // For the leaf, we still match since fsnotify won't send events for dirs themselves. - return true - } return true } } diff --git a/internal/watch/gitignore_test.go b/internal/watch/gitignore_test.go index 7bde2f4..785c12e 100644 --- a/internal/watch/gitignore_test.go +++ b/internal/watch/gitignore_test.go @@ -31,6 +31,32 @@ func TestShouldIgnoreGitignorePatterns(t *testing.T) { assert.False(t, ShouldIgnore(repo, filepath.Join(repo, "src", "main.go"))) } +func TestShouldIgnoreWithPatterns(t *testing.T) { + repo := t.TempDir() + patterns := []string{"node_modules", "*.log", "build"} + + assert.True(t, ShouldIgnoreWithPatterns(repo, filepath.Join(repo, "node_modules", "pkg", "index.js"), patterns)) + assert.True(t, ShouldIgnoreWithPatterns(repo, filepath.Join(repo, "app.log"), patterns)) + assert.True(t, ShouldIgnoreWithPatterns(repo, filepath.Join(repo, "build", "output.js"), patterns)) + assert.False(t, ShouldIgnoreWithPatterns(repo, filepath.Join(repo, "src", "main.go"), patterns)) + // .git is always excluded regardless of patterns + assert.True(t, ShouldIgnoreWithPatterns(repo, filepath.Join(repo, ".git", "HEAD"), nil)) +} + +func TestLoadGitignorePatterns(t *testing.T) { + repo := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("node_modules\n# comment\n\n*.log\n"), 0644)) + + patterns := LoadGitignorePatterns(repo) + assert.Equal(t, []string{"node_modules", "*.log"}, patterns) +} + +func TestLoadGitignorePatternsNoFile(t *testing.T) { + repo := t.TempDir() + patterns := LoadGitignorePatterns(repo) + assert.Nil(t, patterns) +} + func TestMatchPatternWildcard(t *testing.T) { assert.True(t, matchPattern("foo.log", "*.log")) assert.False(t, matchPattern("foo.txt", "*.log")) From 6f4c3acc9c58e9658a5b62eda7c39ea568502dc2 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Fri, 13 Mar 2026 21:19:21 +0100 Subject: [PATCH 3/8] refactor: rename --merge to --append on init, add -m shorthand for --mode --- README.md | 8 ++++---- internal/cli/init.go | 16 ++++++++-------- internal/cli/init_test.go | 10 +++++----- internal/cli/project_add.go | 2 +- web/docs/commands/project-management.md | 2 +- web/docs/commands/time-tracking.md | 6 +++--- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c1f4452..5eaef25 100644 --- a/README.md +++ b/README.md @@ -118,15 +118,15 @@ Commands: `init` · `log` · `edit` · `remove` · `sync` · `report` · `histor Initialize Hourgit in the current git repository by installing a post-checkout hook. ```bash -hourgit init [--project ] [--mode ] [--force] [--merge] [--yes] +hourgit init [--project ] [--mode ] [--force] [--append] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Assign repository to a project by name or ID (creates if needed) | -| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | +| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | | `-f`, `--force` | `false` | Overwrite existing post-checkout hook | -| `-m`, `--merge` | `false` | Append to existing post-checkout hook | +| `-a`, `--append` | `false` | Append to existing post-checkout hook | | `-y`, `--yes` | `false` | Skip confirmation prompt | #### `hourgit log` @@ -320,7 +320,7 @@ hourgit project add [--mode ] | Flag | Default | Description | |------|---------|-------------| -| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | +| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | #### `hourgit project assign` diff --git a/internal/cli/init.go b/internal/cli/init.go index 5bbb08e..82a4521 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -30,11 +30,11 @@ var initCmd = LeafCommand{ Short: "Initialize hourgit in a git repository", StrFlags: []StringFlag{ {Name: "project", Shorthand: "p", Usage: "assign repository to a project by name or ID (creates if needed)"}, - {Name: "mode", Usage: "tracking mode: standard or precise (default: standard)"}, + {Name: "mode", Shorthand: "m", Usage: "tracking mode: standard or precise (default: standard)"}, }, BoolFlags: []BoolFlag{ {Name: "force", Shorthand: "f", Usage: "overwrite existing post-checkout hook"}, - {Name: "merge", Shorthand: "m", Usage: "append to existing post-checkout hook"}, + {Name: "append", Shorthand: "a", Usage: "append to existing post-checkout hook"}, {Name: "yes", Shorthand: "y", Usage: "skip confirmation prompt"}, }, RunE: func(cmd *cobra.Command, args []string) error { @@ -46,7 +46,7 @@ var initCmd = LeafCommand{ projectName, _ := cmd.Flags().GetString("project") modeFlag, _ := cmd.Flags().GetString("mode") force, _ := cmd.Flags().GetBool("force") - merge, _ := cmd.Flags().GetBool("merge") + appendHook, _ := cmd.Flags().GetBool("append") yes, _ := cmd.Flags().GetBool("yes") homeDir, err := os.UserHomeDir() @@ -66,11 +66,11 @@ var initCmd = LeafCommand{ confirm := ResolveConfirmFunc(yes) selectFn := ResolveSelectFunc(yes) - return runInit(cmd, dir, homeDir, projectName, modeFlag, force, merge, binPath, confirm, selectFn) + return runInit(cmd, dir, homeDir, projectName, modeFlag, force, appendHook, binPath, confirm, selectFn) }, }.Build() -func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error { +func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, appendHook bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error { if err := validateMode(mode); err != nil { return err } @@ -91,11 +91,11 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, return fmt.Errorf("hourgit is already initialized") } - if !force && !merge { - return fmt.Errorf("post-checkout hook already exists (use --force to overwrite or --merge to append)") + if !force && !appendHook { + return fmt.Errorf("post-checkout hook already exists (use --force to overwrite or --append to append)") } - if merge { + if appendHook { merged := content + "\n" + hook if err := os.WriteFile(hookPath, []byte(merged), 0755); err != nil { return err diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 69c8ab0..2ce582d 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -40,11 +40,11 @@ func execInit(args ...string) (string, string, error) { return stdout.String(), stderr.String(), err } -func execInitDirect(dir, homeDir, projectName, mode string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) (string, error) { +func execInitDirect(dir, homeDir, projectName, mode string, force, appendHook bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) (string, error) { stdout := new(bytes.Buffer) cmd := initCmd cmd.SetOut(stdout) - err := runInit(cmd, dir, homeDir, projectName, mode, force, merge, binPath, confirm, selectFn) + err := runInit(cmd, dir, homeDir, projectName, mode, force, appendHook, binPath, confirm, selectFn) return stdout.String(), err } @@ -108,7 +108,7 @@ func TestInitHookExistsNoFlag(t *testing.T) { _, stderr, err := execInit() assert.Error(t, err) - assert.Contains(t, stderr, "post-checkout hook already exists (use --force to overwrite or --merge to append)") + assert.Contains(t, stderr, "post-checkout hook already exists (use --force to overwrite or --append to append)") } func TestInitHookExistsForce(t *testing.T) { @@ -131,7 +131,7 @@ func TestInitHookExistsForce(t *testing.T) { assert.NotContains(t, string(content), "echo existing") } -func TestInitHookExistsMerge(t *testing.T) { +func TestInitHookExistsAppend(t *testing.T) { dir, cleanup := setupInitTest(t) defer cleanup() t.Setenv("SHELL", "") @@ -140,7 +140,7 @@ func TestInitHookExistsMerge(t *testing.T) { require.NoError(t, os.MkdirAll(hooksDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-checkout"), []byte("#!/bin/sh\necho existing"), 0755)) - stdout, _, err := execInit("--merge") + stdout, _, err := execInit("--append") assert.NoError(t, err) assert.Contains(t, stdout, "hourgit initialized successfully") diff --git a/internal/cli/project_add.go b/internal/cli/project_add.go index b5432ee..fcdda59 100644 --- a/internal/cli/project_add.go +++ b/internal/cli/project_add.go @@ -15,7 +15,7 @@ var projectAddCmd = LeafCommand{ Short: "Create a new project", Args: cobra.ExactArgs(1), StrFlags: []StringFlag{ - {Name: "mode", Usage: "tracking mode: standard or precise (default: standard)"}, + {Name: "mode", Shorthand: "m", Usage: "tracking mode: standard or precise (default: standard)"}, }, RunE: func(cmd *cobra.Command, args []string) error { homeDir, err := os.UserHomeDir() diff --git a/web/docs/commands/project-management.md b/web/docs/commands/project-management.md index 177c20f..4b8f790 100644 --- a/web/docs/commands/project-management.md +++ b/web/docs/commands/project-management.md @@ -12,7 +12,7 @@ hourgit project add [--mode ] | Flag | Default | Description | |------|---------|-------------| -| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | +| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | ## `hourgit project assign` diff --git a/web/docs/commands/time-tracking.md b/web/docs/commands/time-tracking.md index 28f8439..0691b6c 100644 --- a/web/docs/commands/time-tracking.md +++ b/web/docs/commands/time-tracking.md @@ -7,15 +7,15 @@ Core commands for recording, viewing, and managing your time entries. Initialize Hourgit in the current git repository by installing a post-checkout hook. ```bash -hourgit init [--project ] [--mode ] [--force] [--merge] [--yes] +hourgit init [--project ] [--mode ] [--force] [--append] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-p`, `--project` | auto-detect | Assign repository to a project by name or ID (creates if needed) | -| `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | +| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | | `-f`, `--force` | `false` | Overwrite existing post-checkout hook | -| `-m`, `--merge` | `false` | Append to existing post-checkout hook | +| `-a`, `--append` | `false` | Append to existing post-checkout hook | | `-y`, `--yes` | `false` | Skip confirmation prompt | ## `hourgit log` From aa95023d1b1da7622e3db7e5ca8990d1e0f4476a Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Fri, 13 Mar 2026 22:40:13 +0100 Subject: [PATCH 4/8] fix: crash recovery hash collision, service manager bugs, and missing tests --- README.md | 9 +++++++ internal/cli/init.go | 4 +++ internal/cli/init_test.go | 14 +++++++++++ internal/cli/mode_test.go | 28 +++++++++++++++++++++ internal/cli/project_add_test.go | 14 ++++------- internal/cli/watch.go | 14 ++++++++--- internal/cli/watch_test.go | 42 ++++++++++++++++++++++++++++++++ internal/timetrack/timetrack.go | 10 ++++---- internal/watch/daemon.go | 8 +++--- internal/watch/daemon_test.go | 36 +++++++++++++++++++++++++++ internal/watch/ensure.go | 4 +++ internal/watch/service_darwin.go | 3 +-- internal/watch/service_linux.go | 3 +-- web/docs/commands/utility.md | 9 +++++++ 14 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 internal/cli/mode_test.go create mode 100644 internal/cli/watch_test.go diff --git a/README.md b/README.md index 5eaef25..a53ba29 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,15 @@ hourgit watch No flags. +### Global Flags + +These flags are available on all commands. + +| Flag | Description | +|------|-------------| +| `--skip-updates` | Skip the automatic update check | +| `--skip-watcher` | Skip the file watcher health check | + ## Precise Mode By default, Hourgit attributes all time between branch checkouts (within your schedule) as work. **Precise mode** adds filesystem-level idle detection: a background daemon watches your repository for file changes and records when you stop and resume working. diff --git a/internal/cli/init.go b/internal/cli/init.go index 82a4521..b61ffb0 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -75,6 +75,10 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, return err } + if mode == "precise" && projectName == "" { + return fmt.Errorf("--mode precise requires --project") + } + gitDir := filepath.Join(dir, ".git") if _, err := os.Stat(gitDir); os.IsNotExist(err) { return fmt.Errorf("not a git repository") diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 2ce582d..ccacb71 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -403,6 +403,20 @@ func TestInitWithModePreciseExistingProject(t *testing.T) { assert.Equal(t, project.DefaultIdleThresholdMinutes, cfg.Projects[0].IdleThresholdMinutes) } +func TestInitModePreciseRequiresProject(t *testing.T) { + dir := t.TempDir() + home := t.TempDir() + + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0755)) + + noConfirm := func(_ string) (bool, error) { return true, nil } + skipSelect := func(_ string, _ []string) (int, error) { return 1, nil } + _, err := execInitDirect(dir, home, "", "precise", false, false, "/usr/local/bin/hourgit", noConfirm, skipSelect) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "--mode precise requires --project") +} + func TestInitWithModeInvalid(t *testing.T) { dir := t.TempDir() home := t.TempDir() diff --git a/internal/cli/mode_test.go b/internal/cli/mode_test.go new file mode 100644 index 0000000..0738cf0 --- /dev/null +++ b/internal/cli/mode_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateModeEmpty(t *testing.T) { + err := validateMode("") + assert.NoError(t, err) +} + +func TestValidateModeStandard(t *testing.T) { + err := validateMode("standard") + assert.NoError(t, err) +} + +func TestValidateModePrecise(t *testing.T) { + err := validateMode("precise") + assert.NoError(t, err) +} + +func TestValidateModeInvalid(t *testing.T) { + err := validateMode("foobar") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --mode value") +} diff --git a/internal/cli/project_add_test.go b/internal/cli/project_add_test.go index b9f4401..8d53d7c 100644 --- a/internal/cli/project_add_test.go +++ b/internal/cli/project_add_test.go @@ -10,22 +10,18 @@ import ( "github.com/stretchr/testify/require" ) -func execProjectAdd(homeDir string, name string, mode ...string) (string, error) { +func execProjectAdd(homeDir string, name string, mode string) (string, error) { stdout := new(bytes.Buffer) cmd := projectAddCmd cmd.SetOut(stdout) - m := "" - if len(mode) > 0 { - m = mode[0] - } - err := runProjectAdd(cmd, homeDir, name, m, "/usr/local/bin/hourgit") + err := runProjectAdd(cmd, homeDir, name, mode, "/usr/local/bin/hourgit") return stdout.String(), err } func TestProjectAddHappyPath(t *testing.T) { home := t.TempDir() - stdout, err := execProjectAdd(home, "My Project") + stdout, err := execProjectAdd(home, "My Project", "") assert.NoError(t, err) assert.Contains(t, stdout, "project 'My Project' created (") @@ -45,10 +41,10 @@ func TestProjectAddHappyPath(t *testing.T) { func TestProjectAddDuplicate(t *testing.T) { home := t.TempDir() - _, err := execProjectAdd(home, "My Project") + _, err := execProjectAdd(home, "My Project", "") require.NoError(t, err) - _, err = execProjectAdd(home, "My Project") + _, err = execProjectAdd(home, "My Project", "") assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 5755e5f..3f7d469 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -7,6 +7,13 @@ import ( "github.com/spf13/cobra" ) +type daemonRunner func(homeDir string) error + +func defaultDaemonRunner(homeDir string) error { + d := watch.NewDaemon(homeDir, watch.DefaultEntryWriter()) + return d.Run() +} + var watchCmd = LeafCommand{ Use: "watch", Short: "Run the file watcher daemon (used by the OS service)", @@ -15,11 +22,10 @@ var watchCmd = LeafCommand{ if err != nil { return err } - return runWatch(homeDir) + return runWatch(homeDir, defaultDaemonRunner) }, }.Build() -func runWatch(homeDir string) error { - d := watch.NewDaemon(homeDir, watch.DefaultEntryWriter()) - return d.Run() +func runWatch(homeDir string, runner daemonRunner) error { + return runner(homeDir) } diff --git a/internal/cli/watch_test.go b/internal/cli/watch_test.go new file mode 100644 index 0000000..1ffbda8 --- /dev/null +++ b/internal/cli/watch_test.go @@ -0,0 +1,42 @@ +package cli + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRunWatchCallsRunner(t *testing.T) { + called := false + runner := func(homeDir string) error { + called = true + assert.Equal(t, "/test/home", homeDir) + return nil + } + + err := runWatch("/test/home", runner) + + assert.NoError(t, err) + assert.True(t, called) +} + +func TestRunWatchPropagatesError(t *testing.T) { + runner := func(homeDir string) error { + return fmt.Errorf("daemon failed") + } + + err := runWatch("/test/home", runner) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "daemon failed") +} + +func TestWatchRegistered(t *testing.T) { + commands := rootCmd.Commands() + names := make([]string, len(commands)) + for i, cmd := range commands { + names[i] = cmd.Name() + } + assert.Contains(t, names, "watch") +} diff --git a/internal/timetrack/timetrack.go b/internal/timetrack/timetrack.go index cd4d7e7..b9ea270 100644 --- a/internal/timetrack/timetrack.go +++ b/internal/timetrack/timetrack.go @@ -67,17 +67,17 @@ type DetailedReportData struct { ScheduledDays map[int]bool // day-of-month -> true if day has scheduled working hours } -// BuildReport computes a monthly time report from checkout entries, manual log -// entries, and expanded day schedules. Time is attributed to branches based on -// checkout ranges clipped to schedule windows. Days listed in generatedDays -// (format "2006-01-02") are excluded from checkout attribution — they have -// already been materialized as editable log entries by the generate command. // ActivityEntries holds optional activity entries for precise mode idle trimming. type ActivityEntries struct { Stops []entry.ActivityStopEntry Starts []entry.ActivityStartEntry } +// BuildReport computes a monthly time report from checkout entries, manual log +// entries, and expanded day schedules. Time is attributed to branches based on +// checkout ranges clipped to schedule windows. Days listed in generatedDays +// (format "2006-01-02") are excluded from checkout attribution — they have +// already been materialized as editable log entries by the generate command. func BuildReport( checkouts []entry.CheckoutEntry, logs []entry.Entry, diff --git a/internal/watch/daemon.go b/internal/watch/daemon.go index 8a7a83a..d3fdb59 100644 --- a/internal/watch/daemon.go +++ b/internal/watch/daemon.go @@ -200,6 +200,8 @@ func (d *Daemon) addRepoWatcher(dc DaemonConfig) error { return err } + patterns := LoadGitignorePatterns(dc.Repo) + // Walk directory tree and add non-.git directories err = filepath.WalkDir(dc.Repo, func(path string, info os.DirEntry, err error) error { if err != nil { @@ -208,7 +210,7 @@ func (d *Daemon) addRepoWatcher(dc DaemonConfig) error { if !info.IsDir() { return nil } - if ShouldIgnore(dc.Repo, path) { + if ShouldIgnoreWithPatterns(dc.Repo, path, patterns) { return filepath.SkipDir } return watcher.Add(path) @@ -217,8 +219,6 @@ func (d *Daemon) addRepoWatcher(dc DaemonConfig) error { _ = watcher.Close() return err } - - patterns := LoadGitignorePatterns(dc.Repo) db := NewRepoDebouncer(dc.Repo, dc.Slug, d.homeDir, dc.Threshold, d.writer, d.state) d.debouncers[dc.Repo] = db d.watchers[dc.Repo] = watcher @@ -325,7 +325,7 @@ func (d *Daemon) recoverFromCrash() { } _ = d.writer.WriteActivityStop(d.homeDir, p.Slug, entry.ActivityStopEntry{ - ID: hashutil.GenerateID(start.Repo + stopTime.String() + "recovery"), + ID: hashutil.GenerateIDFromSeed(start.Repo + start.ID + stopTime.String() + "recovery"), Timestamp: stopTime, Repo: start.Repo, }) diff --git a/internal/watch/daemon_test.go b/internal/watch/daemon_test.go index 664b9c8..f342371 100644 --- a/internal/watch/daemon_test.go +++ b/internal/watch/daemon_test.go @@ -100,6 +100,42 @@ func TestDaemonRecoverFromCrashNoState(t *testing.T) { writer.mu.Unlock() } +func TestDaemonRecoverFromCrashDistinctIDs(t *testing.T) { + home := setupDaemonTest(t) + writer := &mockEntryWriter{} + + stopTime := time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + + // Write two unpaired activity_starts for the same repo with different IDs + require.NoError(t, entry.WriteActivityStartEntry(home, "test", entry.ActivityStartEntry{ + ID: "aab1111", + Type: entry.TypeActivityStart, + Timestamp: time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC), + Repo: "/some/repo", + })) + require.NoError(t, entry.WriteActivityStartEntry(home, "test", entry.ActivityStartEntry{ + ID: "aab2222", + Type: entry.TypeActivityStart, + Timestamp: time.Date(2025, 6, 15, 10, 15, 0, 0, time.UTC), + Repo: "/some/repo", + })) + + // State returns the same stopTime for both + state := NewWatchState() + state.SetLastActivity("/some/repo", stopTime) + + d := NewDaemon(home, writer) + d.state = state + + d.recoverFromCrash() + + // Should have written two stops with distinct IDs + assert.Equal(t, 2, writer.stopCount()) + writer.mu.Lock() + assert.NotEqual(t, writer.stops[0].ID, writer.stops[1].ID) + writer.mu.Unlock() +} + func TestDaemonRecoverFromCrashPairedStart(t *testing.T) { home := setupDaemonTest(t) writer := &mockEntryWriter{} diff --git a/internal/watch/ensure.go b/internal/watch/ensure.go index 03cdc8e..1b20d3a 100644 --- a/internal/watch/ensure.go +++ b/internal/watch/ensure.go @@ -27,6 +27,10 @@ func EnsureWatcherService(homeDir, binPath string) error { return sm.Start() } + if anyPrecise && sm.IsInstalled() && !sm.IsRunning() { + return sm.Start() + } + if !anyPrecise && sm.IsInstalled() { return sm.Remove() } diff --git a/internal/watch/service_darwin.go b/internal/watch/service_darwin.go index 22e70a1..f730a85 100644 --- a/internal/watch/service_darwin.go +++ b/internal/watch/service_darwin.go @@ -25,10 +25,9 @@ type launchdManager struct { } func newLaunchdManager(homeDir string) *launchdManager { - home, _ := os.UserHomeDir() return &launchdManager{ homeDir: homeDir, - plistPath: filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist"), + plistPath: filepath.Join(homeDir, "Library", "LaunchAgents", launchdLabel+".plist"), } } diff --git a/internal/watch/service_linux.go b/internal/watch/service_linux.go index 127c1fa..f65374e 100644 --- a/internal/watch/service_linux.go +++ b/internal/watch/service_linux.go @@ -23,10 +23,9 @@ type systemdManager struct { } func newSystemdManager(homeDir string) *systemdManager { - home, _ := os.UserHomeDir() return &systemdManager{ homeDir: homeDir, - servicePath: filepath.Join(home, ".config", "systemd", "user", systemdServiceName+".service"), + servicePath: filepath.Join(homeDir, ".config", "systemd", "user", systemdServiceName+".service"), } } diff --git a/web/docs/commands/utility.md b/web/docs/commands/utility.md index dce5173..03b65ab 100644 --- a/web/docs/commands/utility.md +++ b/web/docs/commands/utility.md @@ -35,3 +35,12 @@ hourgit watch ``` Normally the watcher is managed automatically as an OS service when precise mode is enabled. Use this command for debugging or manual operation. + +## Global Flags + +These flags are available on all commands. + +| Flag | Description | +|------|-------------| +| `--skip-updates` | Skip the automatic update check | +| `--skip-watcher` | Skip the file watcher health check | From 22f5deda47bac9e9c9f63117be6406680c77e121 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Fri, 13 Mar 2026 23:37:12 +0100 Subject: [PATCH 5/8] feat: add project edit command for renaming and mode changes Allow editing existing projects without remove/re-create cycle. Supports --name and --mode flags for direct changes, or interactive mode when no flags are provided. Includes RenameProject() with data directory migration and repo config updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 30 ++- internal/cli/project.go | 1 + internal/cli/project_edit.go | 190 ++++++++++++++++ internal/cli/project_edit_test.go | 289 ++++++++++++++++++++++++ internal/project/project.go | 54 +++++ internal/project/project_test.go | 103 +++++++++ web/docs/commands/project-management.md | 15 ++ 7 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 internal/cli/project_edit.go create mode 100644 internal/cli/project_edit_test.go diff --git a/README.md b/README.md index a53ba29..19a3705 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Manual logging is supported for non-code work (research, analysis, meetings) via - [Quick Start](#quick-start) - [Commands](#commands) - [Time Tracking](#time-tracking) — init, log, edit, remove, sync, report, history, status - - [Project Management](#project-management) — project add/assign/list/remove + - [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 - [Shell Completions](#shell-completions) — completion install/generate @@ -308,7 +308,7 @@ hourgit status [--project ] Group repositories into projects for organized time tracking. -Commands: `project add` · `project assign` · `project list` · `project remove` +Commands: `project add` · `project assign` · `project edit` · `project list` · `project remove` #### `hourgit project add` @@ -335,6 +335,32 @@ hourgit project assign [--force] [--yes] | `-f`, `--force` | `false` | Reassign repository to a different project | | `-y`, `--yes` | `false` | Skip confirmation prompt | +#### `hourgit project edit` + +Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode. + +```bash +hourgit project edit [PROJECT] [--name ] [--mode ] [--project ] [--yes] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-n`, `--name` | — | New project name | +| `-m`, `--mode` | — | New tracking mode: `standard` or `precise` | +| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | +| `-y`, `--yes` | `false` | Skip confirmation prompt | + +> `PROJECT` is an optional positional argument (project name or ID). Falls back to `--project` flag or the current repository's project. + +**Examples** + +```bash +hourgit project edit myproject --name newname +hourgit project edit myproject --mode precise +hourgit project edit --name newname --project myproject +hourgit project edit myproject # interactive mode +``` + #### `hourgit project list` List all projects and their repositories. diff --git a/internal/cli/project.go b/internal/cli/project.go index 6dbfdc4..3ac9088 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -8,6 +8,7 @@ var projectCmd = GroupCommand{ Subcommands: []*cobra.Command{ projectAddCmd, projectAssignCmd, + projectEditCmd, projectListCmd, projectRemoveCmd, }, diff --git a/internal/cli/project_edit.go b/internal/cli/project_edit.go new file mode 100644 index 0000000..4424683 --- /dev/null +++ b/internal/cli/project_edit.go @@ -0,0 +1,190 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/watch" + "github.com/spf13/cobra" +) + +var projectEditCmd = LeafCommand{ + Use: "edit [PROJECT]", + Short: "Edit project name or tracking mode", + Args: cobra.MaximumNArgs(1), + BoolFlags: []BoolFlag{ + {Name: "yes", Shorthand: "y", Usage: "skip confirmation prompts"}, + }, + StrFlags: []StringFlag{ + {Name: "project", Shorthand: "p", Usage: "project name or ID"}, + {Name: "name", Shorthand: "n", Usage: "new project name"}, + {Name: "mode", Shorthand: "m", Usage: "tracking mode: standard or precise"}, + }, + RunE: func(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + repoDir, _ := os.Getwd() + + binPath, err := os.Executable() + if err != nil { + return fmt.Errorf("could not resolve binary path: %w", err) + } + binPath, err = filepath.EvalSymlinks(binPath) + if err != nil { + return fmt.Errorf("could not resolve binary path: %w", err) + } + + projectFlag, _ := cmd.Flags().GetString("project") + nameFlag, _ := cmd.Flags().GetString("name") + modeFlag, _ := cmd.Flags().GetString("mode") + yes, _ := cmd.Flags().GetBool("yes") + + // Resolve project identifier: positional arg > --project flag > repo config + var identifier string + if len(args) > 0 { + identifier = args[0] + } else if projectFlag != "" { + identifier = projectFlag + } + + pk := PromptKit{ + PromptWithDefault: NewPromptWithDefaultFunc(), + Select: NewSelectFunc(), + Confirm: ResolveConfirmFunc(yes), + } + + return runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, binPath, pk) + }, +}.Build() + +func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, modeFlag, binPath string, pk PromptKit) error { + if err := validateMode(modeFlag); err != nil { + return err + } + + // Resolve project + entry, err := resolveEditProject(homeDir, repoDir, identifier) + if err != nil { + return err + } + + newName := nameFlag + newMode := modeFlag + + // Interactive mode: prompt for values if no flags provided + if nameFlag == "" && modeFlag == "" { + newName, newMode, err = promptProjectEdit(entry, pk) + if err != nil { + return err + } + } + + // Determine what changed + nameChanged := newName != "" && newName != entry.Name + currentMode := "standard" + if entry.Precise { + currentMode = "precise" + } + modeChanged := newMode != "" && newMode != currentMode + + if !nameChanged && !modeChanged { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), Text("no changes")) + return nil + } + + // Apply name change + if nameChanged { + oldName := entry.Name + entry, err = project.RenameProject(homeDir, entry.ID, newName) + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("name: %s → %s", Silent(oldName), Primary(entry.Name)))) + } + + // Apply mode change + if modeChanged { + if newMode == "precise" { + if err := project.SetPreciseMode(homeDir, entry.ID, true); err != nil { + return err + } + if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdMinutes); err != nil { + return err + } + if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", + Warning(fmt.Sprintf("warning: could not configure watcher service: %s", err))) + } + } else { + if err := project.SetPreciseMode(homeDir, entry.ID, false); err != nil { + return err + } + if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", + Warning(fmt.Sprintf("warning: could not configure watcher service: %s", err))) + } + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("mode: %s → %s", Silent(currentMode), Primary(newMode)))) + } + + return nil +} + +func resolveEditProject(homeDir, repoDir, identifier string) (*project.ProjectEntry, error) { + cfg, err := project.ReadConfig(homeDir) + if err != nil { + return nil, err + } + + if identifier != "" { + entry := project.ResolveProject(cfg, identifier) + if entry == nil { + return nil, fmt.Errorf("project '%s' not found", identifier) + } + return entry, nil + } + + // Fall back to repo config + if repoDir != "" { + repoCfg, err := project.ReadRepoConfig(repoDir) + if err != nil { + return nil, err + } + if repoCfg != nil { + entry := project.FindProjectByID(cfg, repoCfg.ProjectID) + if entry != nil { + return entry, nil + } + } + } + + return nil, fmt.Errorf("no project specified (use positional arg, --project flag, or run from an assigned repo)") +} + +func promptProjectEdit(entry *project.ProjectEntry, pk PromptKit) (name, mode string, err error) { + name, err = pk.PromptWithDefault("Project name", entry.Name) + if err != nil { + return "", "", err + } + + currentMode := 0 + if entry.Precise { + currentMode = 1 + } + modes := []string{"standard", "precise"} + // Pre-select current mode by putting it first + if currentMode == 1 { + modes = []string{"precise", "standard"} + } + idx, err := pk.Select("Tracking mode", modes) + if err != nil { + return "", "", err + } + mode = modes[idx] + + return name, mode, nil +} diff --git a/internal/cli/project_edit_test.go b/internal/cli/project_edit_test.go new file mode 100644 index 0000000..e48ea80 --- /dev/null +++ b/internal/cli/project_edit_test.go @@ -0,0 +1,289 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func execProjectEdit(homeDir, repoDir, identifier, nameFlag, modeFlag string) (string, error) { + stdout := new(bytes.Buffer) + cmd := projectEditCmd + cmd.SetOut(stdout) + + pk := PromptKit{ + Confirm: AlwaysYes(), + } + + err := runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, "/usr/local/bin/hourgit", pk) + return stdout.String(), err +} + +func TestProjectEditRenameHappyPath(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "Old Name") + require.NoError(t, err) + + // Assign a repo so we can verify repo config update + repo := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0755)) + require.NoError(t, project.AssignProject(home, repo, entry)) + + stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "") + + assert.NoError(t, err) + assert.Contains(t, stdout, "Old Name") + assert.Contains(t, stdout, "New Name") + + // Verify config updated + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + require.Len(t, cfg.Projects, 1) + assert.Equal(t, "New Name", cfg.Projects[0].Name) + assert.Equal(t, "new-name", cfg.Projects[0].Slug) + + // Verify data directory renamed + _, err = os.Stat(project.LogDir(home, "new-name")) + assert.NoError(t, err) + _, err = os.Stat(project.LogDir(home, "old-name")) + assert.True(t, os.IsNotExist(err)) + + // Verify repo config updated + rc, err := project.ReadRepoConfig(repo) + require.NoError(t, err) + assert.Equal(t, "New Name", rc.Project) +} + +func TestProjectEditRenameConflict(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "Project A") + require.NoError(t, err) + _, err = project.CreateProject(home, "Project B") + require.NoError(t, err) + + _, err = execProjectEdit(home, "", "Project A", "Project B", "") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestProjectEditRenameSameSlug(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "my-project") + require.NoError(t, err) + + // "My Project" slugifies to the same "my-project" — no dir rename needed + stdout, err := execProjectEdit(home, "", "my-project", "My Project", "") + + assert.NoError(t, err) + assert.Contains(t, stdout, "My Project") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, "My Project", cfg.Projects[0].Name) + assert.Equal(t, "my-project", cfg.Projects[0].Slug) +} + +func TestProjectEditRenameMissingRepoDir(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "Old Name") + require.NoError(t, err) + + // Add a non-existent repo path + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + cfg.Projects[0].Repos = []string{"/nonexistent/repo"} + require.NoError(t, project.WriteConfig(home, cfg)) + _ = entry + + stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "") + + assert.NoError(t, err) + assert.Contains(t, stdout, "New Name") +} + +func TestProjectEditModeStandardToPrecise(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + stdout, err := execProjectEdit(home, "", "My Project", "", "precise") + + assert.NoError(t, err) + assert.Contains(t, stdout, "standard") + assert.Contains(t, stdout, "precise") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.True(t, cfg.Projects[0].Precise) + assert.Equal(t, project.DefaultIdleThresholdMinutes, cfg.Projects[0].IdleThresholdMinutes) +} + +func TestProjectEditModePreciseToStandard(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) + + stdout, err := execProjectEdit(home, "", "My Project", "", "standard") + + assert.NoError(t, err) + assert.Contains(t, stdout, "precise") + assert.Contains(t, stdout, "standard") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.False(t, cfg.Projects[0].Precise) +} + +func TestProjectEditNameAndMode(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "Old Name") + require.NoError(t, err) + + stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "precise") + + assert.NoError(t, err) + assert.Contains(t, stdout, "New Name") + assert.Contains(t, stdout, "precise") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, "New Name", cfg.Projects[0].Name) + assert.True(t, cfg.Projects[0].Precise) +} + +func TestProjectEditNoChanges(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + // Same name, same mode (standard is default) + stdout, err := execProjectEdit(home, "", "My Project", "My Project", "standard") + + assert.NoError(t, err) + assert.Contains(t, stdout, "no changes") +} + +func TestProjectEditNotFound(t *testing.T) { + home := t.TempDir() + + _, err := execProjectEdit(home, "", "nonexistent", "New Name", "") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestProjectEditInvalidMode(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + _, err = execProjectEdit(home, "", "My Project", "", "foobar") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --mode value") +} + +func TestProjectEditNoProjectSpecified(t *testing.T) { + home := t.TempDir() + + _, err := execProjectEdit(home, "", "", "New Name", "") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no project specified") +} + +func TestProjectEditResolvesFromRepoConfig(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + repo := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0755)) + require.NoError(t, project.AssignProject(home, repo, entry)) + + // No identifier — should resolve from repo config + stdout, err := execProjectEdit(home, repo, "", "Renamed", "") + + assert.NoError(t, err) + assert.Contains(t, stdout, "Renamed") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, "Renamed", cfg.Projects[0].Name) +} + +func TestProjectEditByID(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + stdout, err := execProjectEdit(home, "", entry.ID, "Renamed", "") + + assert.NoError(t, err) + assert.Contains(t, stdout, "Renamed") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, "Renamed", cfg.Projects[0].Name) +} + +func TestProjectEditInteractiveMode(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + stdout := new(bytes.Buffer) + cmd := projectEditCmd + cmd.SetOut(stdout) + + pk := PromptKit{ + Confirm: AlwaysYes(), + PromptWithDefault: func(prompt, defaultValue string) (string, error) { + assert.Equal(t, "My Project", defaultValue) + return "New Name", nil + }, + Select: func(title string, options []string) (int, error) { + // First option is "standard" (current mode), pick "precise" (index 1) + return 1, nil + }, + } + + err = runProjectEdit(cmd, home, "", "My Project", "", "", "/usr/local/bin/hourgit", pk) + + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "New Name") + assert.Contains(t, stdout.String(), "precise") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, "New Name", cfg.Projects[0].Name) + assert.True(t, cfg.Projects[0].Precise) +} + +func TestProjectEditRegisteredAsSubcommand(t *testing.T) { + commands := projectCmd.Commands() + names := make([]string, len(commands)) + for i, cmd := range commands { + names[i] = cmd.Name() + } + assert.Contains(t, names, "edit") +} diff --git a/internal/project/project.go b/internal/project/project.go index c71b6e3..f6bb0bf 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -429,6 +429,60 @@ func AnyPreciseProject(cfg *Config) bool { return false } +// RenameProject renames a project by ID, updating the config, data directory, and repo configs. +// Returns the updated ProjectEntry or an error if the new name conflicts. +func RenameProject(homeDir, projectID, newName string) (*ProjectEntry, error) { + cfg, err := ReadConfig(homeDir) + if err != nil { + return nil, err + } + + entry := FindProjectByID(cfg, projectID) + if entry == nil { + return nil, fmt.Errorf("project '%s' not found", projectID) + } + + // Check for name conflict + if existing := FindProject(cfg, newName); existing != nil && existing.ID != projectID { + return nil, fmt.Errorf("project '%s' already exists (%s)", newName, existing.ID) + } + + oldSlug := entry.Slug + newSlug := stringutil.Slugify(newName) + + // Rename data directory if slug changed + if newSlug != oldSlug { + oldDir := LogDir(homeDir, oldSlug) + newDir := LogDir(homeDir, newSlug) + if _, err := os.Stat(oldDir); err == nil { + if err := os.Rename(oldDir, newDir); err != nil { + return nil, fmt.Errorf("could not rename data directory: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + + entry.Name = newName + entry.Slug = newSlug + + if err := WriteConfig(homeDir, cfg); err != nil { + return nil, err + } + + // Best-effort update repo configs + for _, repoDir := range entry.Repos { + rc, err := ReadRepoConfig(repoDir) + if err != nil || rc == nil { + continue + } + rc.Project = newName + _ = WriteRepoConfig(repoDir, rc) + } + + return entry, nil +} + // RemoveHookFromRepo removes the hourgit section from the post-checkout hook. // If the hook becomes empty after removal, it is deleted. func RemoveHookFromRepo(repoDir string) error { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index d68b8fc..c0c2f86 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -471,6 +471,109 @@ func TestPreciseModeBackwardCompat(t *testing.T) { assert.Equal(t, 0, loaded.Projects[0].IdleThresholdMinutes) } +func TestRenameProjectHappyPath(t *testing.T) { + home := t.TempDir() + + entry, err := CreateProject(home, "Old Name") + require.NoError(t, err) + + // Assign a repo + repo := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(repo, ".git"), 0755)) + require.NoError(t, AssignProject(home, repo, entry)) + + renamed, err := RenameProject(home, entry.ID, "New Name") + require.NoError(t, err) + assert.Equal(t, "New Name", renamed.Name) + assert.Equal(t, "new-name", renamed.Slug) + + // Verify config + cfg, err := ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, "New Name", cfg.Projects[0].Name) + assert.Equal(t, "new-name", cfg.Projects[0].Slug) + + // Verify directory renamed + _, err = os.Stat(LogDir(home, "new-name")) + assert.NoError(t, err) + _, err = os.Stat(LogDir(home, "old-name")) + assert.True(t, os.IsNotExist(err)) + + // Verify repo config updated + rc, err := ReadRepoConfig(repo) + require.NoError(t, err) + assert.Equal(t, "New Name", rc.Project) +} + +func TestRenameProjectSameSlug(t *testing.T) { + home := t.TempDir() + + entry, err := CreateProject(home, "my-project") + require.NoError(t, err) + + renamed, err := RenameProject(home, entry.ID, "My Project") + require.NoError(t, err) + assert.Equal(t, "My Project", renamed.Name) + assert.Equal(t, "my-project", renamed.Slug) + + // Directory should still exist (no rename attempted) + _, err = os.Stat(LogDir(home, "my-project")) + assert.NoError(t, err) +} + +func TestRenameProjectConflict(t *testing.T) { + home := t.TempDir() + + entryA, err := CreateProject(home, "Project A") + require.NoError(t, err) + _, err = CreateProject(home, "Project B") + require.NoError(t, err) + + _, err = RenameProject(home, entryA.ID, "Project B") + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestRenameProjectNotFound(t *testing.T) { + home := t.TempDir() + + _, err := RenameProject(home, "nonexistent", "New Name") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRenameProjectMissingOldDir(t *testing.T) { + home := t.TempDir() + + entry, err := CreateProject(home, "My Project") + require.NoError(t, err) + + // Remove the log dir to simulate a missing directory + require.NoError(t, os.RemoveAll(LogDir(home, "my-project"))) + + renamed, err := RenameProject(home, entry.ID, "New Name") + require.NoError(t, err) + assert.Equal(t, "New Name", renamed.Name) + assert.Equal(t, "new-name", renamed.Slug) +} + +func TestRenameProjectMissingRepoDir(t *testing.T) { + home := t.TempDir() + + entry, err := CreateProject(home, "My Project") + require.NoError(t, err) + + // Manually add a non-existent repo + cfg, err := ReadConfig(home) + require.NoError(t, err) + cfg.Projects[0].Repos = []string{"/nonexistent/repo"} + require.NoError(t, WriteConfig(home, cfg)) + + renamed, err := RenameProject(home, entry.ID, "New Name") + require.NoError(t, err) + assert.Equal(t, "New Name", renamed.Name) +} + func TestFindProjectByID(t *testing.T) { cfg := &Config{ Projects: []ProjectEntry{ diff --git a/web/docs/commands/project-management.md b/web/docs/commands/project-management.md index 4b8f790..e16e4b3 100644 --- a/web/docs/commands/project-management.md +++ b/web/docs/commands/project-management.md @@ -27,6 +27,21 @@ hourgit project assign [--force] [--yes] | `-f`, `--force` | `false` | Reassign repository to a different project | | `-y`, `--yes` | `false` | Skip confirmation prompt | +## `hourgit project edit` + +Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode. + +```bash +hourgit project edit [PROJECT] [--name ] [--mode ] [--project ] [--yes] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-n`, `--name` | — | New project name | +| `-m`, `--mode` | — | New tracking mode: `standard` or `precise` | +| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | +| `-y`, `--yes` | `false` | Skip confirmation prompt | + ## `hourgit project list` List all projects and their repositories. From f2eb0a1db30ffff993573934e20b153f3e72313e Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Sat, 14 Mar 2026 00:00:30 +0100 Subject: [PATCH 6/8] feat: add --idle-threshold flag to project edit command Allow users to configure the idle detection threshold (in minutes) for precise mode tracking via `project edit --idle-threshold ` or interactively. The flag validates that the target project uses precise mode and rejects non-positive values. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 +- internal/cli/project_edit.go | 78 +++++++-- internal/cli/project_edit_test.go | 200 +++++++++++++++++++++--- web/docs/commands/project-management.md | 3 +- 4 files changed, 256 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 19a3705..a3eff3c 100644 --- a/README.md +++ b/README.md @@ -340,13 +340,14 @@ hourgit project assign [--force] [--yes] Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode. ```bash -hourgit project edit [PROJECT] [--name ] [--mode ] [--project ] [--yes] +hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-threshold ] [--project ] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-n`, `--name` | — | New project name | | `-m`, `--mode` | — | New tracking mode: `standard` or `precise` | +| `-t`, `--idle-threshold` | — | Idle threshold in minutes (precise mode only) | | `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-y`, `--yes` | `false` | Skip confirmation prompt | @@ -357,6 +358,7 @@ hourgit project edit [PROJECT] [--name ] [--mode ] [--project --project flag > repo config var identifier string if len(args) > 0 { @@ -57,11 +72,11 @@ var projectEditCmd = LeafCommand{ Confirm: ResolveConfirmFunc(yes), } - return runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, binPath, pk) + return runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, idleThreshold, binPath, pk) }, }.Build() -func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, modeFlag, binPath string, pk PromptKit) error { +func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, modeFlag string, idleThreshold int, binPath string, pk PromptKit) error { if err := validateMode(modeFlag); err != nil { return err } @@ -74,10 +89,11 @@ func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, newName := nameFlag newMode := modeFlag + newIdleThreshold := idleThreshold // Interactive mode: prompt for values if no flags provided - if nameFlag == "" && modeFlag == "" { - newName, newMode, err = promptProjectEdit(entry, pk) + if nameFlag == "" && modeFlag == "" && idleThreshold == 0 { + newName, newMode, newIdleThreshold, err = promptProjectEdit(entry, pk) if err != nil { return err } @@ -91,7 +107,25 @@ func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, } modeChanged := newMode != "" && newMode != currentMode - if !nameChanged && !modeChanged { + // Determine effective mode after changes + effectiveMode := currentMode + if modeChanged { + effectiveMode = newMode + } + + // Idle threshold is only valid for precise mode + if newIdleThreshold > 0 && effectiveMode != "precise" { + return fmt.Errorf("--idle-threshold is only valid for precise mode") + } + + cfg, err := project.ReadConfig(homeDir) + if err != nil { + return err + } + currentThreshold := project.GetIdleThreshold(cfg, entry.ID) + thresholdChanged := newIdleThreshold > 0 && newIdleThreshold != currentThreshold + + if !nameChanged && !modeChanged && !thresholdChanged { _, _ = fmt.Fprintln(cmd.OutOrStdout(), Text("no changes")) return nil } @@ -131,6 +165,15 @@ func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("mode: %s → %s", Silent(currentMode), Primary(newMode)))) } + // Apply idle threshold change + if thresholdChanged { + if err := project.SetIdleThreshold(homeDir, entry.ID, newIdleThreshold); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("idle threshold: %s → %s", + Silent(fmt.Sprintf("%dm", currentThreshold)), Primary(fmt.Sprintf("%dm", newIdleThreshold))))) + } + return nil } @@ -165,10 +208,10 @@ func resolveEditProject(homeDir, repoDir, identifier string) (*project.ProjectEn return nil, fmt.Errorf("no project specified (use positional arg, --project flag, or run from an assigned repo)") } -func promptProjectEdit(entry *project.ProjectEntry, pk PromptKit) (name, mode string, err error) { +func promptProjectEdit(entry *project.ProjectEntry, pk PromptKit) (name, mode string, idleThreshold int, err error) { name, err = pk.PromptWithDefault("Project name", entry.Name) if err != nil { - return "", "", err + return "", "", 0, err } currentMode := 0 @@ -182,9 +225,26 @@ func promptProjectEdit(entry *project.ProjectEntry, pk PromptKit) (name, mode st } idx, err := pk.Select("Tracking mode", modes) if err != nil { - return "", "", err + return "", "", 0, err } mode = modes[idx] - return name, mode, nil + // Prompt for idle threshold if mode is/becomes precise + if mode == "precise" { + currentThreshold := entry.IdleThresholdMinutes + if currentThreshold <= 0 { + currentThreshold = project.DefaultIdleThresholdMinutes + } + thresholdStr, err := pk.PromptWithDefault("Idle threshold (minutes)", strconv.Itoa(currentThreshold)) + if err != nil { + return "", "", 0, err + } + v, err := strconv.Atoi(thresholdStr) + if err != nil || v <= 0 { + return "", "", 0, fmt.Errorf("invalid idle threshold %q: must be a positive number", thresholdStr) + } + idleThreshold = v + } + + return name, mode, idleThreshold, nil } diff --git a/internal/cli/project_edit_test.go b/internal/cli/project_edit_test.go index e48ea80..2f777f6 100644 --- a/internal/cli/project_edit_test.go +++ b/internal/cli/project_edit_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func execProjectEdit(homeDir, repoDir, identifier, nameFlag, modeFlag string) (string, error) { +func execProjectEdit(homeDir, repoDir, identifier, nameFlag, modeFlag string, idleThreshold int) (string, error) { stdout := new(bytes.Buffer) cmd := projectEditCmd cmd.SetOut(stdout) @@ -20,7 +20,7 @@ func execProjectEdit(homeDir, repoDir, identifier, nameFlag, modeFlag string) (s Confirm: AlwaysYes(), } - err := runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, "/usr/local/bin/hourgit", pk) + err := runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, idleThreshold, "/usr/local/bin/hourgit", pk) return stdout.String(), err } @@ -35,7 +35,7 @@ func TestProjectEditRenameHappyPath(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0755)) require.NoError(t, project.AssignProject(home, repo, entry)) - stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "") + stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "", 0) assert.NoError(t, err) assert.Contains(t, stdout, "Old Name") @@ -68,7 +68,7 @@ func TestProjectEditRenameConflict(t *testing.T) { _, err = project.CreateProject(home, "Project B") require.NoError(t, err) - _, err = execProjectEdit(home, "", "Project A", "Project B", "") + _, err = execProjectEdit(home, "", "Project A", "Project B", "", 0) assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") @@ -81,7 +81,7 @@ func TestProjectEditRenameSameSlug(t *testing.T) { require.NoError(t, err) // "My Project" slugifies to the same "my-project" — no dir rename needed - stdout, err := execProjectEdit(home, "", "my-project", "My Project", "") + stdout, err := execProjectEdit(home, "", "my-project", "My Project", "", 0) assert.NoError(t, err) assert.Contains(t, stdout, "My Project") @@ -105,7 +105,7 @@ func TestProjectEditRenameMissingRepoDir(t *testing.T) { require.NoError(t, project.WriteConfig(home, cfg)) _ = entry - stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "") + stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "", 0) assert.NoError(t, err) assert.Contains(t, stdout, "New Name") @@ -117,7 +117,7 @@ func TestProjectEditModeStandardToPrecise(t *testing.T) { _, err := project.CreateProject(home, "My Project") require.NoError(t, err) - stdout, err := execProjectEdit(home, "", "My Project", "", "precise") + stdout, err := execProjectEdit(home, "", "My Project", "", "precise", 0) assert.NoError(t, err) assert.Contains(t, stdout, "standard") @@ -136,7 +136,7 @@ func TestProjectEditModePreciseToStandard(t *testing.T) { require.NoError(t, err) require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) - stdout, err := execProjectEdit(home, "", "My Project", "", "standard") + stdout, err := execProjectEdit(home, "", "My Project", "", "standard", 0) assert.NoError(t, err) assert.Contains(t, stdout, "precise") @@ -153,7 +153,7 @@ func TestProjectEditNameAndMode(t *testing.T) { _, err := project.CreateProject(home, "Old Name") require.NoError(t, err) - stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "precise") + stdout, err := execProjectEdit(home, "", "Old Name", "New Name", "precise", 0) assert.NoError(t, err) assert.Contains(t, stdout, "New Name") @@ -172,7 +172,7 @@ func TestProjectEditNoChanges(t *testing.T) { require.NoError(t, err) // Same name, same mode (standard is default) - stdout, err := execProjectEdit(home, "", "My Project", "My Project", "standard") + stdout, err := execProjectEdit(home, "", "My Project", "My Project", "standard", 0) assert.NoError(t, err) assert.Contains(t, stdout, "no changes") @@ -181,7 +181,7 @@ func TestProjectEditNoChanges(t *testing.T) { func TestProjectEditNotFound(t *testing.T) { home := t.TempDir() - _, err := execProjectEdit(home, "", "nonexistent", "New Name", "") + _, err := execProjectEdit(home, "", "nonexistent", "New Name", "", 0) assert.Error(t, err) assert.Contains(t, err.Error(), "not found") @@ -193,7 +193,7 @@ func TestProjectEditInvalidMode(t *testing.T) { _, err := project.CreateProject(home, "My Project") require.NoError(t, err) - _, err = execProjectEdit(home, "", "My Project", "", "foobar") + _, err = execProjectEdit(home, "", "My Project", "", "foobar", 0) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid --mode value") @@ -202,7 +202,7 @@ func TestProjectEditInvalidMode(t *testing.T) { func TestProjectEditNoProjectSpecified(t *testing.T) { home := t.TempDir() - _, err := execProjectEdit(home, "", "", "New Name", "") + _, err := execProjectEdit(home, "", "", "New Name", "", 0) assert.Error(t, err) assert.Contains(t, err.Error(), "no project specified") @@ -219,7 +219,7 @@ func TestProjectEditResolvesFromRepoConfig(t *testing.T) { require.NoError(t, project.AssignProject(home, repo, entry)) // No identifier — should resolve from repo config - stdout, err := execProjectEdit(home, repo, "", "Renamed", "") + stdout, err := execProjectEdit(home, repo, "", "Renamed", "", 0) assert.NoError(t, err) assert.Contains(t, stdout, "Renamed") @@ -235,7 +235,7 @@ func TestProjectEditByID(t *testing.T) { entry, err := project.CreateProject(home, "My Project") require.NoError(t, err) - stdout, err := execProjectEdit(home, "", entry.ID, "Renamed", "") + stdout, err := execProjectEdit(home, "", entry.ID, "Renamed", "", 0) assert.NoError(t, err) assert.Contains(t, stdout, "Renamed") @@ -255,11 +255,17 @@ func TestProjectEditInteractiveMode(t *testing.T) { cmd := projectEditCmd cmd.SetOut(stdout) + promptCalls := 0 pk := PromptKit{ Confirm: AlwaysYes(), PromptWithDefault: func(prompt, defaultValue string) (string, error) { - assert.Equal(t, "My Project", defaultValue) - return "New Name", nil + promptCalls++ + if promptCalls == 1 { + assert.Equal(t, "My Project", defaultValue) + return "New Name", nil + } + // Idle threshold prompt — accept default + return defaultValue, nil }, Select: func(title string, options []string) (int, error) { // First option is "standard" (current mode), pick "precise" (index 1) @@ -267,9 +273,10 @@ func TestProjectEditInteractiveMode(t *testing.T) { }, } - err = runProjectEdit(cmd, home, "", "My Project", "", "", "/usr/local/bin/hourgit", pk) + err = runProjectEdit(cmd, home, "", "My Project", "", "", 0, "/usr/local/bin/hourgit", pk) assert.NoError(t, err) + assert.Equal(t, 2, promptCalls, "should prompt for name and idle threshold") assert.Contains(t, stdout.String(), "New Name") assert.Contains(t, stdout.String(), "precise") @@ -279,6 +286,163 @@ func TestProjectEditInteractiveMode(t *testing.T) { assert.True(t, cfg.Projects[0].Precise) } +func TestProjectEditIdleThresholdHappyPath(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) + require.NoError(t, project.SetIdleThreshold(home, entry.ID, project.DefaultIdleThresholdMinutes)) + + stdout, err := execProjectEdit(home, "", "My Project", "", "", 15) + + assert.NoError(t, err) + assert.Contains(t, stdout, "idle threshold") + assert.Contains(t, stdout, "10m") + assert.Contains(t, stdout, "15m") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, 15, cfg.Projects[0].IdleThresholdMinutes) +} + +func TestProjectEditIdleThresholdOnStandardProject(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + _, err = execProjectEdit(home, "", "My Project", "", "", 15) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "only valid for precise mode") +} + +func TestProjectEditIdleThresholdNoChange(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) + require.NoError(t, project.SetIdleThreshold(home, entry.ID, 10)) + + stdout, err := execProjectEdit(home, "", "My Project", "", "", 10) + + assert.NoError(t, err) + assert.Contains(t, stdout, "no changes") +} + +func TestProjectEditIdleThresholdWithModeChange(t *testing.T) { + home := t.TempDir() + + _, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + + // Switch to precise and set idle threshold in one command + stdout, err := execProjectEdit(home, "", "My Project", "", "precise", 20) + + assert.NoError(t, err) + assert.Contains(t, stdout, "precise") + assert.Contains(t, stdout, "idle threshold") + assert.Contains(t, stdout, "10m") + assert.Contains(t, stdout, "20m") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.True(t, cfg.Projects[0].Precise) + assert.Equal(t, 20, cfg.Projects[0].IdleThresholdMinutes) +} + +func TestProjectEditIdleThresholdWithModeChangeToStandard(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) + + // Switching to standard while setting idle threshold should error + _, err = execProjectEdit(home, "", "My Project", "", "standard", 15) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "only valid for precise mode") +} + +func TestProjectEditIdleThresholdInvalidInteractive(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) + require.NoError(t, project.SetIdleThreshold(home, entry.ID, 10)) + + stdout := new(bytes.Buffer) + cmd := projectEditCmd + cmd.SetOut(stdout) + + promptCalls := 0 + pk := PromptKit{ + Confirm: AlwaysYes(), + PromptWithDefault: func(prompt, defaultValue string) (string, error) { + promptCalls++ + if promptCalls == 1 { + return defaultValue, nil + } + return "abc", nil // invalid threshold + }, + Select: func(title string, options []string) (int, error) { + return 0, nil + }, + } + + err = runProjectEdit(cmd, home, "", "My Project", "", "", 0, "/usr/local/bin/hourgit", pk) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid idle threshold") +} + +func TestProjectEditInteractivePreciseIdleThreshold(t *testing.T) { + home := t.TempDir() + + entry, err := project.CreateProject(home, "My Project") + require.NoError(t, err) + require.NoError(t, project.SetPreciseMode(home, entry.ID, true)) + require.NoError(t, project.SetIdleThreshold(home, entry.ID, 10)) + + stdout := new(bytes.Buffer) + cmd := projectEditCmd + cmd.SetOut(stdout) + + promptCalls := 0 + pk := PromptKit{ + Confirm: AlwaysYes(), + PromptWithDefault: func(prompt, defaultValue string) (string, error) { + promptCalls++ + if promptCalls == 1 { + return defaultValue, nil // keep name + } + // Idle threshold prompt — change to 20 + assert.Equal(t, "10", defaultValue) + return "20", nil + }, + Select: func(title string, options []string) (int, error) { + // "precise" is first (current mode), keep it + return 0, nil + }, + } + + err = runProjectEdit(cmd, home, "", "My Project", "", "", 0, "/usr/local/bin/hourgit", pk) + + assert.NoError(t, err) + assert.Equal(t, 2, promptCalls, "should prompt for name and idle threshold") + assert.Contains(t, stdout.String(), "idle threshold") + assert.Contains(t, stdout.String(), "10m") + assert.Contains(t, stdout.String(), "20m") + + cfg, err := project.ReadConfig(home) + require.NoError(t, err) + assert.Equal(t, 20, cfg.Projects[0].IdleThresholdMinutes) +} + func TestProjectEditRegisteredAsSubcommand(t *testing.T) { commands := projectCmd.Commands() names := make([]string, len(commands)) diff --git a/web/docs/commands/project-management.md b/web/docs/commands/project-management.md index e16e4b3..724606e 100644 --- a/web/docs/commands/project-management.md +++ b/web/docs/commands/project-management.md @@ -32,13 +32,14 @@ hourgit project assign [--force] [--yes] Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode. ```bash -hourgit project edit [PROJECT] [--name ] [--mode ] [--project ] [--yes] +hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-threshold ] [--project ] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-n`, `--name` | — | New project name | | `-m`, `--mode` | — | New tracking mode: `standard` or `precise` | +| `-t`, `--idle-threshold` | — | Idle threshold in minutes (precise mode only) | | `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-y`, `--yes` | `false` | Skip confirmation prompt | From fee35c164ad4b3fc92e6de51d1676c722967aef2 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Sat, 14 Mar 2026 13:34:24 +0100 Subject: [PATCH 7/8] fix: status --project shows wrong branch when run from different repo defaultGitBranch() previously always queried CWD, so running `hourgit status --project X` from project Y's directory would show Y's branch. Now accepts a repoDir parameter and uses `git -C` to query the correct repository. runStatus() resolves which repo to query: CWD if it belongs to the target project, otherwise the project's first assigned repo. Skips branch display when no repos are assigned. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/status.go | 32 +++++++++--- internal/cli/status_test.go | 98 +++++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/internal/cli/status.go b/internal/cli/status.go index e47a4aa..12bbc6a 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -29,11 +29,12 @@ var statusCmd = LeafCommand{ projectFlag, _ := cmd.Flags().GetString("project") return runStatus(cmd, homeDir, repoDir, projectFlag, defaultGitBranch, time.Now) }, + }.Build() -// defaultGitBranch returns the current git branch name. -func defaultGitBranch() (string, error) { - out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() +// defaultGitBranch returns the current git branch name for the given repo directory. +func defaultGitBranch(repoDir string) (string, error) { + out, err := exec.Command("git", "-C", repoDir, "rev-parse", "--abbrev-ref", "HEAD").Output() if err != nil { return "", err } @@ -43,7 +44,7 @@ func defaultGitBranch() (string, error) { func runStatus( cmd *cobra.Command, homeDir, repoDir, projectFlag string, - gitBranchFunc func() (string, error), + gitBranchFunc func(repoDir string) (string, error), nowFunc func() time.Time, ) error { proj, err := ResolveProjectContext(homeDir, repoDir, projectFlag) @@ -62,10 +63,26 @@ func runStatus( // Project _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Project:"), Primary(proj.Name)) + // Resolve the repo directory for branch lookup: + // If CWD is one of the project's repos, use it; otherwise pick the first assigned repo. + branchRepoDir := "" + cfgEntry := project.FindProjectByID(cfg, proj.ID) + if cfgEntry != nil && len(cfgEntry.Repos) > 0 { + branchRepoDir = cfgEntry.Repos[0] + for _, r := range cfgEntry.Repos { + if r == repoDir { + branchRepoDir = repoDir + break + } + } + } + // Branch - branch, branchErr := gitBranchFunc() - if branchErr == nil && branch != "" { - _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Branch:"), Primary(branch)) + if branchRepoDir != "" { + branch, branchErr := gitBranchFunc(branchRepoDir) + if branchErr == nil && branch != "" { + _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Branch:"), Primary(branch)) + } } // Last checkout @@ -173,7 +190,6 @@ func runStatus( } // Watcher state (only when precise mode is enabled) - cfgEntry := project.FindProjectByID(cfg, proj.ID) if cfgEntry != nil && cfgEntry.Precise { daemonRunning, _, _ := watch.IsDaemonRunning(homeDir) if daemonRunning { diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index e1e1490..77b5aa9 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -36,7 +36,7 @@ func setupStatusTest(t *testing.T) (homeDir string, proj *project.ProjectEntry) func execStatus( homeDir, repoDir, projectFlag string, - gitBranch func() (string, error), + gitBranch func(string) (string, error), now func() time.Time, ) (string, error) { stdout := new(bytes.Buffer) @@ -47,12 +47,12 @@ func execStatus( return stdout.String(), err } -func mockGitBranch(name string) func() (string, error) { - return func() (string, error) { return name, nil } +func mockGitBranch(name string) func(string) (string, error) { + return func(string) (string, error) { return name, nil } } -func mockGitBranchErr() func() (string, error) { - return func() (string, error) { return "", errors.New("not a git repo") } +func mockGitBranchErr() func(string) (string, error) { + return func(string) (string, error) { return "", errors.New("not a git repo") } } func mockNow(t time.Time) func() time.Time { @@ -227,6 +227,94 @@ func TestStatusMultiWindowSchedule(t *testing.T) { assert.Contains(t, stdout, "1:00 PM - 5:00 PM") } +func TestStatusCrossProjectShowsCorrectBranch(t *testing.T) { + homeDir, proj := setupStatusTest(t) + + require.NoError(t, project.SetSchedules(homeDir, proj.ID, weekdaySchedule(9, 0, 17, 0))) + + // Saturday so we get "not a working day" and skip schedule logic + now := time.Date(2025, 6, 14, 10, 0, 0, 0, time.UTC) + + // Read config to get the assigned repo path + cfg, err := project.ReadConfig(homeDir) + require.NoError(t, err) + cfgEntry := project.FindProjectByID(cfg, proj.ID) + require.NotNil(t, cfgEntry) + require.NotEmpty(t, cfgEntry.Repos) + expectedRepoDir := cfgEntry.Repos[0] + + // Use a different repoDir (simulating CWD in a different project's repo) + differentRepoDir := t.TempDir() + + // Mock verifies the correct repo dir is passed + var calledWithDir string + gitBranch := func(repoDir string) (string, error) { + calledWithDir = repoDir + return "feature/other-project", nil + } + + stdout, err := execStatus(homeDir, differentRepoDir, proj.Name, gitBranch, mockNow(now)) + + require.NoError(t, err) + assert.Equal(t, expectedRepoDir, calledWithDir, "should query the project's repo, not CWD") + assert.Contains(t, stdout, "feature/other-project") +} + +func TestStatusCrossProjectCWDMatchesProjectRepo(t *testing.T) { + homeDir, proj := setupStatusTest(t) + + require.NoError(t, project.SetSchedules(homeDir, proj.ID, weekdaySchedule(9, 0, 17, 0))) + + // Saturday so we get "not a working day" and skip schedule logic + now := time.Date(2025, 6, 14, 10, 0, 0, 0, time.UTC) + + // Read config to get the assigned repo path + cfg, err := project.ReadConfig(homeDir) + require.NoError(t, err) + cfgEntry := project.FindProjectByID(cfg, proj.ID) + require.NotNil(t, cfgEntry) + require.NotEmpty(t, cfgEntry.Repos) + assignedRepo := cfgEntry.Repos[0] + + // CWD matches the project's repo — should use CWD, not just Repos[0] + var calledWithDir string + gitBranch := func(repoDir string) (string, error) { + calledWithDir = repoDir + return "main", nil + } + + _, err = execStatus(homeDir, assignedRepo, proj.Name, gitBranch, mockNow(now)) + + require.NoError(t, err) + assert.Equal(t, assignedRepo, calledWithDir, "should use CWD when it matches a project repo") +} + +func TestStatusNoReposNoBranch(t *testing.T) { + homeDir := t.TempDir() + + // Create project without assigning any repo + proj, err := project.CreateProject(homeDir, "No Repos Project") + require.NoError(t, err) + + require.NoError(t, project.SetSchedules(homeDir, proj.ID, weekdaySchedule(9, 0, 17, 0))) + + // Saturday so we get "not a working day" and skip schedule logic + now := time.Date(2025, 6, 14, 10, 0, 0, 0, time.UTC) + + branchCalled := false + gitBranch := func(string) (string, error) { + branchCalled = true + return "main", nil + } + + stdout, err := execStatus(homeDir, "", proj.Name, gitBranch, mockNow(now)) + + require.NoError(t, err) + assert.False(t, branchCalled, "should not call gitBranchFunc when no repos assigned") + assert.NotContains(t, stdout, "Branch:") + assert.Contains(t, stdout, "No Repos Project") +} + func TestStatusRegisteredAsSubcommand(t *testing.T) { root := newRootCmd() names := make([]string, len(root.Commands())) From f7c37f95af992767cfb1a37558cabbbc679a5acb Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Sat, 14 Mar 2026 13:48:49 +0100 Subject: [PATCH 8/8] fix: remove unused pdf report --- hourgit-2026-month-03.pdf | Bin 7909 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100755 hourgit-2026-month-03.pdf diff --git a/hourgit-2026-month-03.pdf b/hourgit-2026-month-03.pdf deleted file mode 100755 index bc72877fe0c4c5ab7cbfd5a9705629fa6d6433d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7909 zcmds6TW_OA6!tT};=HVsNE;aD3Q@FDb8(xt>86RLt(4t|F^p{@8&be+wm-h@8Ndd6 zQ_1nBS=lH7&kQreckXk}o9pxbk?%5x0fB6I@0^@?gU=5JylXu+4&HN-P=aPl4f!my*FEZ1DqX|$#Ws8rvD&UnER8k=WmoQLF zx}3ud1XV8Oi}n<5J7QD4UrEj8yx(4GUS#G|yeHgctV^D^aFwO8{@l`Nhd~PNyCekF zq6{Y60t?q!!=oa)sKCi$Lr8_ z1*O$6$)N*t$+?C5*it687N)HyHX0^;bRvz0&2*Bk2;$Cw5!81TGNwhNCLqFnQ7a}~ zE*mE8qgpYHQz^(6CYu0XFmIt*J7_N&9Yf)ybyNm;Md-rJG0b$76);RvoqvWR8K*}} z<=bJzf?~)YfNUXiR7A1P%Z&}oSDZcmuP9Q44CCld6v^JgU~Y;%3nM!)cRh?0in=}q zN_gf2)Vrmg?ONQ3=7b99pjk5JnMc=7e6w|6wDrW5h^n@bLZH=XE#xS9JO=%sqltm2 zzGEIF&!#ZSa?nL#%0=kE%chdyd7GedTw?dhx}DrvpJyZGkS;@(ilfZ&^BwFj=RgJ#K?9d~_PNmsRoat~7`%nc-TU@kd_D)nSWnv1|@k(L?hrv3F9 z>Gibx#=7jk$jm1)(j1SxL7jdXWTl?2`N3EIz-Q*ZJ49K5CK!$c(7{G|ClPIHSWTI# zsb=|YHLI-iIXpE-jKpM`wMx z@%l%n*)-m66I%$ZD}7ij%(Lj$lyK)^RS~>_c|;JGGb|*YHqW!>d5PqzFrJ*?kBy^9 zOED}%Zl2h-B)G98@qT+hyf;w^xS?&HWz#Ib#Sq>!JuO?*Dx!EEnH&!O{-@XPUH8uM z4n4o;_S7M+g2l`-$lh|{t)&rdWH>4v5VQz#IcF{$+YT$X6()Za6SPpUH>)m`EpE`mp)MdxW5gSbI+qhP1a=XFZ>xw5;`U{>2^&j~wlLWv&rr tj)iFy(BvAE5VWtsdAZ)GO#?W_*|4aFDtxC3g*0dbMe0K{0ndc&fNe2