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..a3eff3c 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ 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 - - [Other](#other) — version + - [Other](#other) — version, watch +- [Precise Mode](#precise-mode) - [Configuration](#configuration) - [Data Storage](#data-storage) - [Roadmap](#roadmap) @@ -117,14 +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 ] [--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) | +| `-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` @@ -300,22 +302,25 @@ 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 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` Create a new project. ```bash -hourgit project add +hourgit project add [--mode ] ``` -No flags. +| Flag | Default | Description | +|------|---------|-------------| +| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | #### `hourgit project assign` @@ -330,6 +335,34 @@ 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 ] [--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 | + +> `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 myproject --idle-threshold 15 +hourgit project edit --name newname --project myproject +hourgit project edit myproject # interactive mode +``` + #### `hourgit project list` List all projects and their repositories. @@ -505,7 +538,7 @@ hourgit completion generate fish | source ### Other -Commands: `version` · `update` +Commands: `version` · `update` · `watch` #### `hourgit version` @@ -527,6 +560,52 @@ 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. + +### 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. + +### 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 +630,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/hourgit-2026-month-03.pdf b/hourgit-2026-month-03.pdf deleted file mode 100755 index bc72877..0000000 Binary files a/hourgit-2026-month-03.pdf and /dev/null differ diff --git a/internal/cli/init.go b/internal/cli/init.go index e2d47d2..b61ffb0 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,10 +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", 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 { @@ -42,8 +44,9 @@ 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() @@ -63,11 +66,19 @@ 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, appendHook, 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, appendHook bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error { + if err := validateMode(mode); err != nil { + 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") @@ -84,11 +95,11 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge 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 @@ -135,6 +146,19 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge _, _ = 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 + } + 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))) + } + } + if err := project.AssignProject(homeDir, dir, result.Entry); err != nil { return err } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 5185ac3..ccacb71 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, 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, 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") @@ -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,81 @@ 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 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 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() + + 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 +452,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/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/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.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_add.go b/internal/cli/project_add.go index 6684b5d..fcdda59 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,52 @@ var projectAddCmd = LeafCommand{ Use: "add PROJECT", Short: "Create a new project", Args: cobra.ExactArgs(1), + StrFlags: []StringFlag{ + {Name: "mode", Shorthand: "m", 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") + + 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 string) error { +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) 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 + } + 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)))) return nil } diff --git a/internal/cli/project_add_test.go b/internal/cli/project_add_test.go index 5a9e03d..8d53d7c 100644 --- a/internal/cli/project_add_test.go +++ b/internal/cli/project_add_test.go @@ -10,18 +10,18 @@ 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) + 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 (") @@ -41,15 +41,54 @@ 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") } +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/project_edit.go b/internal/cli/project_edit.go new file mode 100644 index 0000000..981e168 --- /dev/null +++ b/internal/cli/project_edit.go @@ -0,0 +1,250 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "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"}, + {Name: "idle-threshold", Shorthand: "t", Usage: "idle threshold in minutes (precise mode only)"}, + }, + 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") + idleThresholdFlag, _ := cmd.Flags().GetString("idle-threshold") + yes, _ := cmd.Flags().GetBool("yes") + + var idleThreshold int + if idleThresholdFlag != "" { + v, err := strconv.Atoi(idleThresholdFlag) + if err != nil { + return fmt.Errorf("invalid --idle-threshold value %q: must be a number", idleThresholdFlag) + } + if v <= 0 { + return fmt.Errorf("invalid --idle-threshold value %q: must be greater than 0", idleThresholdFlag) + } + idleThreshold = v + } + + // 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, idleThreshold, binPath, pk) + }, +}.Build() + +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 + } + + // Resolve project + entry, err := resolveEditProject(homeDir, repoDir, identifier) + if err != nil { + return err + } + + newName := nameFlag + newMode := modeFlag + newIdleThreshold := idleThreshold + + // Interactive mode: prompt for values if no flags provided + if nameFlag == "" && modeFlag == "" && idleThreshold == 0 { + newName, newMode, newIdleThreshold, 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 + + // 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 + } + + // 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)))) + } + + // 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 +} + +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, idleThreshold int, err error) { + name, err = pk.PromptWithDefault("Project name", entry.Name) + if err != nil { + return "", "", 0, 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 "", "", 0, err + } + mode = modes[idx] + + // 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 new file mode 100644 index 0000000..2f777f6 --- /dev/null +++ b/internal/cli/project_edit_test.go @@ -0,0 +1,453 @@ +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, idleThreshold int) (string, error) { + stdout := new(bytes.Buffer) + cmd := projectEditCmd + cmd.SetOut(stdout) + + pk := PromptKit{ + Confirm: AlwaysYes(), + } + + err := runProjectEdit(cmd, homeDir, repoDir, identifier, nameFlag, modeFlag, idleThreshold, "/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", "", 0) + + 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", "", 0) + + 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", "", 0) + + 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", "", 0) + + 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", 0) + + 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", 0) + + 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", 0) + + 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", 0) + + assert.NoError(t, err) + assert.Contains(t, stdout, "no changes") +} + +func TestProjectEditNotFound(t *testing.T) { + home := t.TempDir() + + _, err := execProjectEdit(home, "", "nonexistent", "New Name", "", 0) + + 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", 0) + + 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", "", 0) + + 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", "", 0) + + 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", "", 0) + + 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) + + promptCalls := 0 + pk := PromptKit{ + Confirm: AlwaysYes(), + PromptWithDefault: func(prompt, defaultValue string) (string, error) { + 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) + return 1, 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(), "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 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)) + for i, cmd := range commands { + names[i] = cmd.Name() + } + assert.Contains(t, names, "edit") +} 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/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/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..12bbc6a 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" ) @@ -28,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 } @@ -42,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) @@ -61,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 @@ -171,6 +189,16 @@ func runStatus( _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Tracking:"), Warning("inactive (no scheduled hours remaining)")) } + // Watcher state (only when precise mode is enabled) + 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/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())) diff --git a/internal/cli/watch.go b/internal/cli/watch.go new file mode 100644 index 0000000..3f7d469 --- /dev/null +++ b/internal/cli/watch.go @@ -0,0 +1,31 @@ +package cli + +import ( + "os" + + "github.com/Flyrell/hourgit/internal/watch" + "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)", + RunE: func(cmd *cobra.Command, args []string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + return runWatch(homeDir, defaultDaemonRunner) + }, +}.Build() + +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/cli/watcher_check.go b/internal/cli/watcher_check.go new file mode 100644 index 0000000..d74b1d6 --- /dev/null +++ b/internal/cli/watcher_check.go @@ -0,0 +1,90 @@ +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 + isTTY func() bool +} + +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, + isTTY: func() bool { return isatty.IsTerminal(os.Stdout.Fd()) }, + } +} + +// 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 !deps.isTTY() { + 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..1ee6435 --- /dev/null +++ b/internal/cli/watcher_check_test.go @@ -0,0 +1,139 @@ +package cli + +import ( + "testing" + + "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" +) + +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() + + // 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{ + { + 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 + }, + isTTY: func() bool { return true }, + } + + 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) + assert.False(t, confirmCalled, "should not prompt when no precise projects") +} + +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) + assert.False(t, confirmCalled, "should not prompt when daemon is running") +} + +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) + 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/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..f6bb0bf 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,120 @@ 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 +} + +// 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 357f0bd..c0c2f86 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -347,6 +347,233 @@ 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 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/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..b9ea270 100644 --- a/internal/timetrack/timetrack.go +++ b/internal/timetrack/timetrack.go @@ -67,6 +67,12 @@ type DetailedReportData struct { ScheduledDays map[int]bool // day-of-month -> true if day has scheduled working hours } +// 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 @@ -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..d3fdb59 --- /dev/null +++ b/internal/watch/daemon.go @@ -0,0 +1,334 @@ +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 + patterns map[string][]string // repo path -> cached gitignore patterns + 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), + patterns: make(map[string][]string), + } +} + +// 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) + delete(d.patterns, 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 + } + + 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 { + return nil // skip inaccessible + } + if !info.IsDir() { + return nil + } + if ShouldIgnoreWithPatterns(dc.Repo, path, patterns) { + 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 + d.patterns[dc.Repo] = patterns + + 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, patterns []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 ShouldIgnoreWithPatterns(repoDir, event.Name, patterns) { + 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 + } + + // 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.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 new file mode 100644 index 0000000..f342371 --- /dev/null +++ b/internal/watch/daemon_test.go @@ -0,0 +1,162 @@ +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 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{} + + // 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..1b20d3a --- /dev/null +++ b/internal/watch/ensure.go @@ -0,0 +1,39 @@ +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() && !sm.IsRunning() { + 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..919b702 --- /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. 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 { + return true + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, p := range parts { + if p == ".git" { + return true + } + } + + 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) + 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 _, part := range parts { + matched, _ := filepath.Match(pattern, part) + if matched { + return true + } + } + return false +} diff --git a/internal/watch/gitignore_test.go b/internal/watch/gitignore_test.go new file mode 100644 index 0000000..785c12e --- /dev/null +++ b/internal/watch/gitignore_test.go @@ -0,0 +1,81 @@ +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 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")) +} + +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..f730a85 --- /dev/null +++ b/internal/watch/service_darwin.go @@ -0,0 +1,100 @@ +//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 { + return &launchdManager{ + homeDir: homeDir, + plistPath: filepath.Join(homeDir, "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 | +|------|---------|-------------| +| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) | + ## `hourgit project assign` Assign the current repository to a project. @@ -23,6 +27,22 @@ 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 ] [--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 | + ## `hourgit project list` List all projects and their repositories. diff --git a/web/docs/commands/time-tracking.md b/web/docs/commands/time-tracking.md index 367c9bd..0691b6c 100644 --- a/web/docs/commands/time-tracking.md +++ b/web/docs/commands/time-tracking.md @@ -7,14 +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 ] [--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) | +| `-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` @@ -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..03b65ab 100644 --- a/web/docs/commands/utility.md +++ b/web/docs/commands/utility.md @@ -25,3 +25,22 @@ 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. + +## 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 | 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