diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f868e52 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,125 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Watcher daemon e2e tests running real `hourgit watch` inside Docker (basic start/stop, multiple sessions, graceful shutdown, crash recovery, multi-repo, gitignore filtering) +- `HOURGIT_IDLE_THRESHOLD` env var override for daemon idle threshold (enables short thresholds in e2e tests) + +### Changed + +- **BREAKING**: Idle threshold config changed from minutes to seconds (`idle_threshold_minutes` → `idle_threshold_seconds`, default: 600s) + +## [0.8.2] - 2026-03-16 + +### Changed + +- Bump GitHub Actions versions for Node.js 20 deprecation + +## [0.8.1] - 2026-03-15 + +### Changed + +- Use "(no task)" label for task-less log entries in reports +- Move log, edit, remove under `log` group command +- Move schedule commands under `project`/`defaults` groups +- Remove duplicate resolveProjectFromRepo in favor of ResolveProjectContext + +## [0.8.0] - 2026-03-15 + +### Added + +- Precise mode with filesystem watcher daemon for idle gap detection +- Project edit command (`project edit`) for renaming and mode changes +- `--idle-threshold` flag on project edit +- Commit tracking from git reflog +- PDF export with commit messages (`--detail full`) +- From/to time range display in report detail panel +- From/to time fields in edit, log, and report overlays +- Allow all three `--from`/`--to`/`--duration` flags in edit when consistent + +### Changed + +- Rename `--merge` to `--append` on init, add `-m` shorthand for `--mode` +- Centralize day budget computation +- Replace proportional scaling with targeted segment carving for log deduction + +### Fixed + +- Crash recovery hash collision and service manager bugs +- Status `--project` shows wrong branch when run from different repo +- Detail panel time range display + +## [0.7.0] - 2026-03-02 + +### Added + +- Flag shortcuts for common commands +- macOS binary code signing + +## [0.6.0] - 2026-02-27 + +### Added + +- `status` command showing current tracking state + +### Fixed + +- Hanging update check + +## [0.5.0] - 2026-02-27 + +### Added + +- Project removal (`project remove`) +- PDF report export (`report --export pdf`) + +## [0.4.0] - 2026-02-26 + +### Added + +- Checkout sync from git reflog (`sync` command) +- Update check and `update` command + +### Fixed + +- Commit deduplication logic + +## [0.3.0] - 2026-02-25 + +### Added + +- Website with documentation +- Installer script (`curl | bash`) + +## [0.2.0] - 2026-02-25 + +### Added + +- Interactive report with branch deduplication + +## [0.1.0] - 2026-02-25 + +### Added + +- Initial release: `init`, `log add`, `report`, `history` commands +- Project management and schedule configuration +- Shell completion generation + +[Unreleased]: https://github.com/Flyrell/hourgit/compare/v0.8.2...HEAD +[0.8.2]: https://github.com/Flyrell/hourgit/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/Flyrell/hourgit/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/Flyrell/hourgit/compare/v0.7.3...v0.8.0 +[0.7.0]: https://github.com/Flyrell/hourgit/compare/v0.6.1...v0.7.0 +[0.6.0]: https://github.com/Flyrell/hourgit/compare/v0.5.3...v0.6.0 +[0.5.0]: https://github.com/Flyrell/hourgit/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/Flyrell/hourgit/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/Flyrell/hourgit/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/Flyrell/hourgit/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/Flyrell/hourgit/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 58ed375..a468b89 100644 --- a/README.md +++ b/README.md @@ -341,14 +341,14 @@ hourgit project assign [PROJECT] [--project ] [--force] [--yes] Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode. ```bash -hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-threshold ] [--project ] [--yes] +hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-threshold ] [--project ] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-n`, `--name` | — | New project name | | `-m`, `--mode` | — | New tracking mode: `standard` or `precise` | -| `-t`, `--idle-threshold` | — | Idle threshold in minutes (precise mode only) | +| `-t`, `--idle-threshold` | — | Idle threshold in seconds (precise mode only) | | `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-y`, `--yes` | `false` | Skip confirmation prompt | @@ -359,7 +359,7 @@ hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-thres ```bash hourgit project edit myproject --name newname hourgit project edit myproject --mode precise -hourgit project edit myproject --idle-threshold 15 +hourgit project edit myproject --idle-threshold 900 hourgit project edit --name newname --project myproject hourgit project edit myproject # interactive mode ``` @@ -588,7 +588,7 @@ By default, Hourgit attributes all time between branch checkouts (within your sc ### 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. +2. After a configurable idle threshold (default: 600 seconds / 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. diff --git a/Taskfile.yml b/Taskfile.yml index d75b50e..7212515 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -37,6 +37,13 @@ tasks: desc: Run all tests cmd: docker run --rm -v "$PWD":/app -w /app golang:1.26-alpine go test -v -count=1 ./... + test:e2e: + desc: Run end-to-end tests with real git repos + cmd: >- + docker run --rm -v "$PWD":/app -w /app + golang:1.26-alpine + sh -c "apk add --no-cache git && go test -v -count=1 -timeout=300s ./tests/e2e/..." + lint: desc: Run golangci-lint (format + static analysis) cmd: docker run --rm -v "$PWD":/app -w /app golangci/golangci-lint:v2.10.1-alpine golangci-lint run ./... diff --git a/internal/cli/init.go b/internal/cli/init.go index b61ffb0..296280b 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -150,7 +150,7 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, 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 { + if err := project.SetIdleThreshold(homeDir, result.Entry.ID, project.DefaultIdleThresholdSeconds); err != nil { return err } if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index ccacb71..29d5dbb 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -374,7 +374,7 @@ func TestInitWithModePrecise(t *testing.T) { 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) + assert.Equal(t, project.DefaultIdleThresholdSeconds, cfg.Projects[0].IdleThresholdSeconds) } func TestInitWithModePreciseExistingProject(t *testing.T) { @@ -400,7 +400,7 @@ func TestInitWithModePreciseExistingProject(t *testing.T) { 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) + assert.Equal(t, project.DefaultIdleThresholdSeconds, cfg.Projects[0].IdleThresholdSeconds) } func TestInitModePreciseRequiresProject(t *testing.T) { diff --git a/internal/cli/project_add.go b/internal/cli/project_add.go index fcdda59..f86de79 100644 --- a/internal/cli/project_add.go +++ b/internal/cli/project_add.go @@ -51,7 +51,7 @@ func runProjectAdd(cmd *cobra.Command, homeDir, name, mode, binPath string) erro if err := project.SetPreciseMode(homeDir, entry.ID, true); err != nil { return err } - if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdMinutes); err != nil { + if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdSeconds); err != nil { return err } if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { diff --git a/internal/cli/project_add_test.go b/internal/cli/project_add_test.go index 8d53d7c..65b1a04 100644 --- a/internal/cli/project_add_test.go +++ b/internal/cli/project_add_test.go @@ -62,7 +62,7 @@ func TestProjectAddModePrecise(t *testing.T) { 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) + assert.Equal(t, project.DefaultIdleThresholdSeconds, cfg.Projects[0].IdleThresholdSeconds) } func TestProjectAddModeStandard(t *testing.T) { @@ -77,7 +77,7 @@ func TestProjectAddModeStandard(t *testing.T) { 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) + assert.Equal(t, 0, cfg.Projects[0].IdleThresholdSeconds) } func TestProjectAddModeInvalid(t *testing.T) { diff --git a/internal/cli/project_edit.go b/internal/cli/project_edit.go index 981e168..936a53c 100644 --- a/internal/cli/project_edit.go +++ b/internal/cli/project_edit.go @@ -22,7 +22,7 @@ var projectEditCmd = LeafCommand{ {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)"}, + {Name: "idle-threshold", Shorthand: "t", Usage: "idle threshold in seconds (precise mode only)"}, }, RunE: func(cmd *cobra.Command, args []string) error { homeDir, err := os.UserHomeDir() @@ -146,7 +146,7 @@ func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, if err := project.SetPreciseMode(homeDir, entry.ID, true); err != nil { return err } - if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdMinutes); err != nil { + if err := project.SetIdleThreshold(homeDir, entry.ID, project.DefaultIdleThresholdSeconds); err != nil { return err } if err := watch.EnsureWatcherService(homeDir, binPath); err != nil { @@ -171,7 +171,7 @@ func runProjectEdit(cmd *cobra.Command, homeDir, repoDir, identifier, nameFlag, 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))))) + Silent(fmt.Sprintf("%ds", currentThreshold)), Primary(fmt.Sprintf("%ds", newIdleThreshold))))) } return nil @@ -231,11 +231,11 @@ func promptProjectEdit(entry *project.ProjectEntry, pk PromptKit) (name, mode st // Prompt for idle threshold if mode is/becomes precise if mode == "precise" { - currentThreshold := entry.IdleThresholdMinutes + currentThreshold := entry.IdleThresholdSeconds if currentThreshold <= 0 { - currentThreshold = project.DefaultIdleThresholdMinutes + currentThreshold = project.DefaultIdleThresholdSeconds } - thresholdStr, err := pk.PromptWithDefault("Idle threshold (minutes)", strconv.Itoa(currentThreshold)) + thresholdStr, err := pk.PromptWithDefault("Idle threshold in seconds (e.g. 600 = 10min)", strconv.Itoa(currentThreshold)) if err != nil { return "", "", 0, err } diff --git a/internal/cli/project_edit_test.go b/internal/cli/project_edit_test.go index 2f777f6..095f9a1 100644 --- a/internal/cli/project_edit_test.go +++ b/internal/cli/project_edit_test.go @@ -126,7 +126,7 @@ func TestProjectEditModeStandardToPrecise(t *testing.T) { 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) + assert.Equal(t, project.DefaultIdleThresholdSeconds, cfg.Projects[0].IdleThresholdSeconds) } func TestProjectEditModePreciseToStandard(t *testing.T) { @@ -292,18 +292,18 @@ func TestProjectEditIdleThresholdHappyPath(t *testing.T) { 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)) + require.NoError(t, project.SetIdleThreshold(home, entry.ID, project.DefaultIdleThresholdSeconds)) - stdout, err := execProjectEdit(home, "", "My Project", "", "", 15) + stdout, err := execProjectEdit(home, "", "My Project", "", "", 900) assert.NoError(t, err) assert.Contains(t, stdout, "idle threshold") - assert.Contains(t, stdout, "10m") - assert.Contains(t, stdout, "15m") + assert.Contains(t, stdout, "600s") + assert.Contains(t, stdout, "900s") cfg, err := project.ReadConfig(home) require.NoError(t, err) - assert.Equal(t, 15, cfg.Projects[0].IdleThresholdMinutes) + assert.Equal(t, 900, cfg.Projects[0].IdleThresholdSeconds) } func TestProjectEditIdleThresholdOnStandardProject(t *testing.T) { @@ -339,18 +339,18 @@ func TestProjectEditIdleThresholdWithModeChange(t *testing.T) { require.NoError(t, err) // Switch to precise and set idle threshold in one command - stdout, err := execProjectEdit(home, "", "My Project", "", "precise", 20) + stdout, err := execProjectEdit(home, "", "My Project", "", "precise", 1200) 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") + assert.Contains(t, stdout, "600s") + assert.Contains(t, stdout, "1200s") cfg, err := project.ReadConfig(home) require.NoError(t, err) assert.True(t, cfg.Projects[0].Precise) - assert.Equal(t, 20, cfg.Projects[0].IdleThresholdMinutes) + assert.Equal(t, 1200, cfg.Projects[0].IdleThresholdSeconds) } func TestProjectEditIdleThresholdWithModeChangeToStandard(t *testing.T) { @@ -406,7 +406,7 @@ func TestProjectEditInteractivePreciseIdleThreshold(t *testing.T) { 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)) + require.NoError(t, project.SetIdleThreshold(home, entry.ID, 600)) stdout := new(bytes.Buffer) cmd := projectEditCmd @@ -420,9 +420,9 @@ func TestProjectEditInteractivePreciseIdleThreshold(t *testing.T) { if promptCalls == 1 { return defaultValue, nil // keep name } - // Idle threshold prompt — change to 20 - assert.Equal(t, "10", defaultValue) - return "20", nil + // Idle threshold prompt — change to 1200 + assert.Equal(t, "600", defaultValue) + return "1200", nil }, Select: func(title string, options []string) (int, error) { // "precise" is first (current mode), keep it @@ -435,12 +435,12 @@ func TestProjectEditInteractivePreciseIdleThreshold(t *testing.T) { 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") + assert.Contains(t, stdout.String(), "600s") + assert.Contains(t, stdout.String(), "1200s") cfg, err := project.ReadConfig(home) require.NoError(t, err) - assert.Equal(t, 20, cfg.Projects[0].IdleThresholdMinutes) + assert.Equal(t, 1200, cfg.Projects[0].IdleThresholdSeconds) } func TestProjectEditRegisteredAsSubcommand(t *testing.T) { diff --git a/internal/entry/activity.go b/internal/entry/activity.go index 349b076..7cd3c5d 100644 --- a/internal/entry/activity.go +++ b/internal/entry/activity.go @@ -2,7 +2,7 @@ package entry import "time" -// ActivityStopEntry is written after idle_threshold_minutes of no file changes. +// ActivityStopEntry is written after idle_threshold_seconds of no file changes. // Timestamp records the last observed file change, not when the debounce fired. type ActivityStopEntry struct { ID string `json:"id"` diff --git a/internal/project/project.go b/internal/project/project.go index f6bb0bf..ccd37ff 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -40,7 +40,7 @@ type ProjectEntry struct { Repos []string `json:"repos"` Schedules []schedule.ScheduleEntry `json:"schedules,omitempty"` Precise bool `json:"precise,omitempty"` - IdleThresholdMinutes int `json:"idle_threshold_minutes,omitempty"` + IdleThresholdSeconds int `json:"idle_threshold_seconds,omitempty"` } // Config holds the global hourgit configuration including projects and defaults. @@ -369,8 +369,8 @@ func ResetSchedules(homeDir, projectID string) error { return SetSchedules(homeDir, projectID, defaults) } -// DefaultIdleThresholdMinutes is the default idle threshold for precise mode. -const DefaultIdleThresholdMinutes = 10 +// DefaultIdleThresholdSeconds is the default idle threshold for precise mode (600s = 10 minutes). +const DefaultIdleThresholdSeconds = 600 // GetPreciseMode returns whether precise mode is enabled for a project. func GetPreciseMode(cfg *Config, projectID string) bool { @@ -395,18 +395,18 @@ func SetPreciseMode(homeDir, projectID string, precise bool) error { return WriteConfig(homeDir, cfg) } -// GetIdleThreshold returns the idle threshold in minutes for a project. -// Returns DefaultIdleThresholdMinutes if not set. +// GetIdleThreshold returns the idle threshold in seconds for a project. +// Returns DefaultIdleThresholdSeconds if not set. func GetIdleThreshold(cfg *Config, projectID string) int { entry := FindProjectByID(cfg, projectID) - if entry == nil || entry.IdleThresholdMinutes <= 0 { - return DefaultIdleThresholdMinutes + if entry == nil || entry.IdleThresholdSeconds <= 0 { + return DefaultIdleThresholdSeconds } - return entry.IdleThresholdMinutes + return entry.IdleThresholdSeconds } -// SetIdleThreshold sets the idle threshold in minutes for a project. -func SetIdleThreshold(homeDir, projectID string, minutes int) error { +// SetIdleThreshold sets the idle threshold in seconds for a project. +func SetIdleThreshold(homeDir, projectID string, seconds int) error { cfg, err := ReadConfig(homeDir) if err != nil { return err @@ -415,7 +415,7 @@ func SetIdleThreshold(homeDir, projectID string, minutes int) error { if entry == nil { return fmt.Errorf("project '%s' not found", projectID) } - entry.IdleThresholdMinutes = minutes + entry.IdleThresholdSeconds = seconds return WriteConfig(homeDir, cfg) } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index c0c2f86..13356b1 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -383,15 +383,15 @@ func TestIdleThresholdGetSet(t *testing.T) { cfg, err := ReadConfig(home) require.NoError(t, err) - // Default returns DefaultIdleThresholdMinutes - assert.Equal(t, DefaultIdleThresholdMinutes, GetIdleThreshold(cfg, entry.ID)) + // Default returns DefaultIdleThresholdSeconds + assert.Equal(t, DefaultIdleThresholdSeconds, GetIdleThreshold(cfg, entry.ID)) // Set custom value - require.NoError(t, SetIdleThreshold(home, entry.ID, 15)) + require.NoError(t, SetIdleThreshold(home, entry.ID, 900)) cfg, err = ReadConfig(home) require.NoError(t, err) - assert.Equal(t, 15, GetIdleThreshold(cfg, entry.ID)) + assert.Equal(t, 900, GetIdleThreshold(cfg, entry.ID)) } func TestPreciseModeSetNotFound(t *testing.T) { @@ -417,7 +417,7 @@ func TestGetPreciseModeNotFound(t *testing.T) { func TestGetIdleThresholdNotFound(t *testing.T) { cfg := &Config{} - assert.Equal(t, DefaultIdleThresholdMinutes, GetIdleThreshold(cfg, "nonexistent")) + assert.Equal(t, DefaultIdleThresholdSeconds, GetIdleThreshold(cfg, "nonexistent")) } func TestAnyPreciseProject(t *testing.T) { @@ -439,7 +439,7 @@ func TestPreciseModeJSONRoundTrip(t *testing.T) { original := &Config{ Defaults: schedule.DefaultSchedules(), Projects: []ProjectEntry{ - {ID: "abc1234", Name: "Test", Slug: "test", Repos: []string{}, Precise: true, IdleThresholdMinutes: 15}, + {ID: "abc1234", Name: "Test", Slug: "test", Repos: []string{}, Precise: true, IdleThresholdSeconds: 900}, }, } @@ -448,7 +448,7 @@ func TestPreciseModeJSONRoundTrip(t *testing.T) { loaded, err := ReadConfig(home) require.NoError(t, err) assert.True(t, loaded.Projects[0].Precise) - assert.Equal(t, 15, loaded.Projects[0].IdleThresholdMinutes) + assert.Equal(t, 900, loaded.Projects[0].IdleThresholdSeconds) } func TestPreciseModeBackwardCompat(t *testing.T) { @@ -468,7 +468,7 @@ func TestPreciseModeBackwardCompat(t *testing.T) { require.NoError(t, err) // Defaults should be fine assert.False(t, loaded.Projects[0].Precise) - assert.Equal(t, 0, loaded.Projects[0].IdleThresholdMinutes) + assert.Equal(t, 0, loaded.Projects[0].IdleThresholdSeconds) } func TestRenameProjectHappyPath(t *testing.T) { diff --git a/internal/timetrack/segment.go b/internal/timetrack/segment.go index f2edf27..00565c4 100644 --- a/internal/timetrack/segment.go +++ b/internal/timetrack/segment.go @@ -12,45 +12,74 @@ import ( 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) + repo string // repo this gap belongs to (empty = applies to all) } // buildIdleGaps pairs activity_stop and activity_start entries into idle gaps. +// Stops and starts are grouped by repo and paired within each group. // 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) - }) + // Group stops by repo + stopsByRepo := make(map[string][]entry.ActivityStopEntry) + for _, s := range stops { + stopsByRepo[s.Repo] = append(stopsByRepo[s.Repo], s) + } - 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) - }) + // Group starts by repo + startsByRepo := make(map[string][]entry.ActivityStartEntry) + for _, s := range starts { + startsByRepo[s.Repo] = append(startsByRepo[s.Repo], s) + } + + // Collect repo keys from stops only — a start without a preceding stop + // cannot form a gap, so repos with only starts are correctly skipped. + repos := make(map[string]bool) + for r := range stopsByRepo { + repos[r] = true + } - // 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++ + for repo := range repos { + repoStops := stopsByRepo[repo] + repoStarts := startsByRepo[repo] + if len(repoStarts) == 0 { + continue } - if startIdx < len(sortedStarts) { - gaps = append(gaps, idleGap{ - stop: stop.Timestamp, - start: sortedStarts[startIdx].Timestamp, - }) - startIdx++ + + // Sort stops and starts by timestamp + sort.Slice(repoStops, func(i, j int) bool { + return repoStops[i].Timestamp.Before(repoStops[j].Timestamp) + }) + sort.Slice(repoStarts, func(i, j int) bool { + return repoStarts[i].Timestamp.Before(repoStarts[j].Timestamp) + }) + + // Pair each stop with the next start that comes after it + startIdx := 0 + for _, stop := range repoStops { + for startIdx < len(repoStarts) && !repoStarts[startIdx].Timestamp.After(stop.Timestamp) { + startIdx++ + } + if startIdx < len(repoStarts) { + gaps = append(gaps, idleGap{ + stop: stop.Timestamp, + start: repoStarts[startIdx].Timestamp, + repo: repo, + }) + startIdx++ + } } } + + // Sort gaps chronologically by stop time + sort.Slice(gaps, func(i, j int) bool { + return gaps[i].stop.Before(gaps[j].stop) + }) return gaps } // trimSegmentsByIdleGaps removes idle periods from checkout segments. -// For each segment, idle gaps that overlap are used to split or trim the segment. +// For each segment, only idle gaps matching the segment's repo are applied. func trimSegmentsByIdleGaps(segments []sessionSegment, stops []entry.ActivityStopEntry, starts []entry.ActivityStartEntry) []sessionSegment { if len(stops) == 0 || len(starts) == 0 { return segments @@ -63,12 +92,31 @@ func trimSegmentsByIdleGaps(segments []sessionSegment, stops []entry.ActivitySto var result []sessionSegment for _, seg := range segments { - trimmed := applyGapsToSegment(seg, gaps) + filtered := filterGapsByRepo(gaps, seg.repo) + trimmed := applyGapsToSegment(seg, filtered) result = append(result, trimmed...) } return result } +// filterGapsByRepo returns gaps that apply to the given segment repo. +// Rules: +// - Gap with empty repo → applies to all segments (backward compat / log deduction) +// - Segment with empty repo → affected by all gaps (conservative fallback) +// - Otherwise → gap applies only if repos match +func filterGapsByRepo(gaps []idleGap, segRepo string) []idleGap { + if segRepo == "" { + return gaps + } + var filtered []idleGap + for _, g := range gaps { + if g.repo == "" || g.repo == segRepo { + filtered = append(filtered, g) + } + } + return filtered +} + // applyGapsToSegment applies all overlapping idle gaps to a single segment, // potentially splitting it into multiple sub-segments. func applyGapsToSegment(seg sessionSegment, gaps []idleGap) []sessionSegment { @@ -134,12 +182,53 @@ type sessionSegment struct { message string // commit message, empty for uncommitted trailing segment } +// buildSyntheticCheckouts creates synthetic checkout entries from commits to +// detect repo switches. When consecutive commits are on different repos, a +// synthetic checkout is placed at the midpoint to split the timeline. +func buildSyntheticCheckouts(commits []entry.CommitEntry) []entry.CheckoutEntry { + if len(commits) == 0 { + return nil + } + + sorted := make([]entry.CommitEntry, len(commits)) + copy(sorted, commits) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Timestamp.Before(sorted[j].Timestamp) + }) + + var synthetic []entry.CheckoutEntry + for i := 1; i < len(sorted); i++ { + prev := sorted[i-1] + curr := sorted[i] + if prev.Repo == "" || curr.Repo == "" { + continue + } + if prev.Repo == curr.Repo { + continue + } + // Different repos — create synthetic checkout at midpoint + mid := prev.Timestamp.Add(curr.Timestamp.Sub(prev.Timestamp) / 2) + synthetic = append(synthetic, entry.CheckoutEntry{ + ID: "synthetic-" + curr.ID, + Type: "checkout", + Timestamp: mid, + Previous: prev.Branch, + Next: curr.Branch, + Repo: curr.Repo, + }) + } + return synthetic +} + // buildCheckoutSegments splits checkout sessions by commits to produce // finer-grained time segments. Each commit creates a segment from the previous // boundary to the commit timestamp. Time is attributed backwards from the commit // — work before a commit is attributed to that commit. Trailing time after the // last commit becomes an unnamed segment (uncommitted work). // +// Synthetic checkouts are injected from commit-based repo switches so that +// commits in different repos are never orphaned. +// // When no commits exist within a session, the entire session becomes one segment. func buildCheckoutSegments( checkouts []entry.CheckoutEntry, @@ -149,18 +238,24 @@ func buildCheckoutSegments( ) []sessionSegment { loc := now.Location() + // Merge synthetic checkouts from commit-based repo switches. + // Allocate a new slice to avoid mutating the caller's backing array. + synthetic := buildSyntheticCheckouts(commits) + sorted := make([]entry.CheckoutEntry, 0, len(checkouts)+len(synthetic)) + sorted = append(sorted, checkouts...) + sorted = append(sorted, synthetic...) + // Sort checkouts chronologically - sorted := make([]entry.CheckoutEntry, len(checkouts)) - copy(sorted, checkouts) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Timestamp.Before(sorted[j].Timestamp) }) - // Deduplicate: skip consecutive checkouts to the same branch + // Deduplicate: skip consecutive checkouts to the same branch+repo if len(sorted) > 0 { deduped := []entry.CheckoutEntry{sorted[0]} for i := 1; i < len(sorted); i++ { - if cleanBranchName(sorted[i].Next) != cleanBranchName(sorted[i-1].Next) { + if cleanBranchName(sorted[i].Next) != cleanBranchName(sorted[i-1].Next) || + sorted[i].Repo != sorted[i-1].Repo { deduped = append(deduped, sorted[i]) } } @@ -182,6 +277,7 @@ func buildCheckoutSegments( if lastBeforeIdx >= 0 { pairs = append(pairs, checkoutRange{ branch: cleanBranchName(sorted[lastBeforeIdx].Next), + repo: sorted[lastBeforeIdx].Repo, from: monthStart, }) } @@ -190,6 +286,7 @@ func buildCheckoutSegments( if c.Timestamp.After(monthStart) && !c.Timestamp.After(monthEnd) { pairs = append(pairs, checkoutRange{ branch: cleanBranchName(c.Next), + repo: c.Repo, from: c.Timestamp, }) } @@ -224,13 +321,14 @@ func buildCheckoutSegments( continue } - // Find commits within this session's time range on the same branch + // Find commits within this session's time range on the same branch+repo var sessionCommits []entry.CommitEntry for _, c := range sortedCommits { if c.Timestamp.Before(p.from) || !c.Timestamp.Before(p.to) { continue } - if cleanBranchName(c.Branch) == p.branch { + if cleanBranchName(c.Branch) == p.branch && + (c.Repo == "" || p.repo == "" || c.Repo == p.repo) { sessionCommits = append(sessionCommits, c) } } @@ -239,6 +337,7 @@ func buildCheckoutSegments( // No commits — single segment for the whole session segments = append(segments, sessionSegment{ branch: p.branch, + repo: p.repo, from: p.from, to: p.to, }) @@ -250,9 +349,13 @@ func buildCheckoutSegments( for _, c := range sessionCommits { commitTime := c.Timestamp.Truncate(time.Minute) if commitTime.After(boundary) { + repo := c.Repo + if repo == "" { + repo = p.repo + } segments = append(segments, sessionSegment{ branch: p.branch, - repo: c.Repo, + repo: repo, from: boundary, to: commitTime, message: c.Message, @@ -265,6 +368,7 @@ func buildCheckoutSegments( if boundary.Before(p.to) { segments = append(segments, sessionSegment{ branch: p.branch, + repo: p.repo, from: boundary, to: p.to, }) diff --git a/internal/timetrack/segment_test.go b/internal/timetrack/segment_test.go index 19d1af3..b8808fc 100644 --- a/internal/timetrack/segment_test.go +++ b/internal/timetrack/segment_test.go @@ -397,3 +397,488 @@ func TestTrimSegmentsByIdleGaps_NoOverlap(t *testing.T) { assert.Len(t, result, 1) assert.Equal(t, segments[0], result[0]) } + +// --- Synthetic checkout tests --- + +func TestBuildSyntheticCheckouts_NoCommits(t *testing.T) { + result := buildSyntheticCheckouts(nil) + assert.Nil(t, result) +} + +func TestBuildSyntheticCheckouts_SingleRepo(t *testing.T) { + commits := []entry.CommitEntry{ + {ID: "c1", Timestamp: t9am, Branch: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: t10am, Branch: "main", Repo: "/repoA"}, + {ID: "c3", Timestamp: t11am, Branch: "feat", Repo: "/repoA"}, + } + result := buildSyntheticCheckouts(commits) + assert.Nil(t, result) +} + +func TestBuildSyntheticCheckouts_MultiRepo(t *testing.T) { + commits := []entry.CommitEntry{ + {ID: "c1", Timestamp: t9am, Branch: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: t11am, Branch: "feat", Repo: "/repoB"}, + } + result := buildSyntheticCheckouts(commits) + assert.Len(t, result, 1) + + // Midpoint of 9:00 and 11:00 = 10:00 + assert.Equal(t, t10am, result[0].Timestamp) + assert.Equal(t, "main", result[0].Previous) + assert.Equal(t, "feat", result[0].Next) + assert.Equal(t, "/repoB", result[0].Repo) + assert.Equal(t, "checkout", result[0].Type) +} + +func TestBuildSyntheticCheckouts_ConsecutiveSameRepo(t *testing.T) { + commits := []entry.CommitEntry{ + {ID: "c1", Timestamp: t9am, Branch: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: t10am, Branch: "feat", Repo: "/repoA"}, + {ID: "c3", Timestamp: t11am, Branch: "main", Repo: "/repoA"}, + } + result := buildSyntheticCheckouts(commits) + assert.Nil(t, result) +} + +func TestBuildSyntheticCheckouts_EmptyRepoSkipped(t *testing.T) { + commits := []entry.CommitEntry{ + {ID: "c1", Timestamp: t9am, Branch: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: t10am, Branch: "feat", Repo: ""}, + {ID: "c3", Timestamp: t11am, Branch: "main", Repo: "/repoB"}, + } + // c1→c2: c2 has empty repo, skip + // c2→c3: c2 has empty repo, skip + result := buildSyntheticCheckouts(commits) + assert.Nil(t, result) +} + +// --- Repo-aware deduplication tests --- + +func TestBuildCheckoutSegments_SameBranchDifferentRepos(t *testing.T) { + year, month := 2025, time.January + daysInMonth := 31 + + // Two checkouts to "main" but in different repos — should NOT be deduplicated + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoB"}, + {ID: "c3", Timestamp: time.Date(2025, 1, 2, 15, 0, 0, 0, time.UTC), Next: "feat", Repo: "/repoA"}, + } + + segments := buildCheckoutSegments(checkouts, nil, year, month, daysInMonth, afterMonth(year, month)) + + // Should have 3 segments: main@repoA, main@repoB, feat@repoA + assert.Equal(t, 3, len(segments)) + assert.Equal(t, "main", segments[0].branch) + assert.Equal(t, "/repoA", segments[0].repo) + assert.Equal(t, "main", segments[1].branch) + assert.Equal(t, "/repoB", segments[1].repo) + assert.Equal(t, "feat", segments[2].branch) + assert.Equal(t, "/repoA", segments[2].repo) +} + +// --- Repo-aware commit matching tests --- + +func TestBuildCheckoutSegments_CommitMatchesByRepo(t *testing.T) { + year, month := 2025, time.January + daysInMonth := 31 + + // Two overlapping sessions on "main" in different repos + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoB"}, + } + + // Commit on main in repoB — should only match the repoB session + commits := []entry.CommitEntry{ + {ID: "cm1", Timestamp: time.Date(2025, 1, 2, 13, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoB", Message: "fix in repoB"}, + } + + segments := buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, afterMonth(year, month)) + + // repoA session: 9:00-12:00, no commits → single segment + repoASegs := filterSegmentsByRepo(segments, "/repoA") + assert.Equal(t, 1, len(repoASegs)) + assert.Equal(t, "", repoASegs[0].message) // no commit message + + // repoB session: 12:00-end, split by commit at 13:00 + repoBSegs := filterSegmentsByRepo(segments, "/repoB") + assert.Equal(t, 2, len(repoBSegs)) + assert.Equal(t, "fix in repoB", repoBSegs[0].message) + assert.Equal(t, "", repoBSegs[1].message) // trailing +} + +// --- Repo-aware idle gap tests --- + +func TestTrimSegmentsByIdleGaps_DifferentRepoNotApplied(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", repo: "/repoA", from: t9am, to: t12pm, message: "work"}, + } + + // Idle gap from repoB — should NOT affect repoA segment + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t10am, Repo: "/repoB"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repoB"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 1) + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t12pm, result[0].to) +} + +func TestTrimSegmentsByIdleGaps_SameRepoApplied(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", repo: "/repoA", from: t9am, to: t12pm, message: "work"}, + } + + // Idle gap from repoA — SHOULD affect repoA segment + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t10am, Repo: "/repoA"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repoA"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 2) + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t10am, result[0].to) + assert.Equal(t, t11am, result[1].from) + assert.Equal(t, t12pm, result[1].to) +} + +func TestTrimSegmentsByIdleGaps_MultiRepoMixed(t *testing.T) { + segments := []sessionSegment{ + {branch: "main", repo: "/repoA", from: t9am, to: t12pm, message: "workA"}, + {branch: "feat", repo: "/repoB", from: t9am, to: t12pm, message: "workB"}, + } + + // repoA idle gap 10:00-11:00, repoB idle gap 9:30-10:30 + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t10am, Repo: "/repoA"}, + {ID: "s2", Timestamp: t930, Repo: "/repoB"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repoA"}, + {ID: "a2", Timestamp: t1030, Repo: "/repoB"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + + // repoA: [9:00-10:00, 11:00-12:00] + repoAResult := filterSegmentsByRepo(result, "/repoA") + assert.Len(t, repoAResult, 2) + assert.Equal(t, t9am, repoAResult[0].from) + assert.Equal(t, t10am, repoAResult[0].to) + assert.Equal(t, t11am, repoAResult[1].from) + assert.Equal(t, t12pm, repoAResult[1].to) + + // repoB: [9:00-9:30, 10:30-12:00] + repoBResult := filterSegmentsByRepo(result, "/repoB") + assert.Len(t, repoBResult, 2) + assert.Equal(t, t9am, repoBResult[0].from) + assert.Equal(t, t930, repoBResult[0].to) + assert.Equal(t, t1030, repoBResult[1].from) + assert.Equal(t, t12pm, repoBResult[1].to) +} + +func TestTrimSegmentsByIdleGaps_EmptyRepoBackwardCompat(t *testing.T) { + // Segment with empty repo should be affected by ALL gaps + segments := []sessionSegment{ + {branch: "main", repo: "", from: t9am, to: t12pm, message: "work"}, + } + + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t10am, Repo: "/repoA"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t11am, Repo: "/repoA"}, + } + + result := trimSegmentsByIdleGaps(segments, stops, starts) + assert.Len(t, result, 2) + assert.Equal(t, t9am, result[0].from) + assert.Equal(t, t10am, result[0].to) + assert.Equal(t, t11am, result[1].from) + assert.Equal(t, t12pm, result[1].to) +} + +func TestBuildIdleGaps_PairsPerRepo(t *testing.T) { + // Stops and starts from different repos should be paired independently + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t9am, Repo: "/repoA"}, + {ID: "s2", Timestamp: t930, Repo: "/repoB"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t10am, Repo: "/repoA"}, + {ID: "a2", Timestamp: t1030, Repo: "/repoB"}, + } + + gaps := buildIdleGaps(stops, starts) + assert.Len(t, gaps, 2) + + // Both repos should have their own gap + repoAGaps := filterGapsByRepoExact(gaps, "/repoA") + assert.Len(t, repoAGaps, 1) + assert.Equal(t, t9am, repoAGaps[0].stop) + assert.Equal(t, t10am, repoAGaps[0].start) + + repoBGaps := filterGapsByRepoExact(gaps, "/repoB") + assert.Len(t, repoBGaps, 1) + assert.Equal(t, t930, repoBGaps[0].stop) + assert.Equal(t, t1030, repoBGaps[0].start) +} + +func TestBuildIdleGaps_CrossRepoPairingPrevented(t *testing.T) { + // Stop from repoA should NOT pair with start from repoB + stops := []entry.ActivityStopEntry{ + {ID: "s1", Timestamp: t9am, Repo: "/repoA"}, + } + starts := []entry.ActivityStartEntry{ + {ID: "a1", Timestamp: t10am, Repo: "/repoB"}, + } + + gaps := buildIdleGaps(stops, starts) + // repoA has stop but no start in its repo → no gap + assert.Len(t, gaps, 0) +} + +func TestFilterGapsByRepo(t *testing.T) { + gaps := []idleGap{ + {stop: t9am, start: t10am, repo: "/repoA"}, + {stop: t10am, start: t11am, repo: "/repoB"}, + {stop: t11am, start: t12pm, repo: ""}, // empty repo = universal + } + + // Filter for repoA: should get repoA gap + empty-repo gap + filtered := filterGapsByRepo(gaps, "/repoA") + assert.Len(t, filtered, 2) + assert.Equal(t, "/repoA", filtered[0].repo) + assert.Equal(t, "", filtered[1].repo) + + // Filter for repoB: should get repoB gap + empty-repo gap + filtered = filterGapsByRepo(gaps, "/repoB") + assert.Len(t, filtered, 2) + assert.Equal(t, "/repoB", filtered[0].repo) + assert.Equal(t, "", filtered[1].repo) + + // Filter with empty repo: should get ALL gaps + filtered = filterGapsByRepo(gaps, "") + assert.Len(t, filtered, 3) +} + +// --- Integration tests --- + +func TestBuildCheckoutSegments_MultiRepoWithCommits(t *testing.T) { + year, month := 2025, time.January + daysInMonth := 31 + + // Single checkout to main@repoA, then commits alternate repos + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + } + + commits := []entry.CommitEntry{ + {ID: "cm1", Timestamp: time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA", Message: "commit in A"}, + {ID: "cm2", Timestamp: time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), Branch: "feat", Repo: "/repoB", Message: "commit in B"}, + {ID: "cm3", Timestamp: time.Date(2025, 1, 2, 14, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA", Message: "back to A"}, + } + + now := time.Date(2025, 1, 2, 16, 0, 0, 0, time.UTC) + segments := buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, now) + + // Synthetic checkouts injected at midpoints: + // cm1@repoA(10:00) → cm2@repoB(12:00): midpoint=11:00, checkout to feat@repoB + // cm2@repoB(12:00) → cm3@repoA(14:00): midpoint=13:00, checkout to main@repoA + // + // Timeline: + // main@repoA 9:00-11:00 (commit at 10:00 splits: [9:00-10:00 "commit in A", 10:00-11:00 trailing]) + // feat@repoB 11:00-13:00 (commit at 12:00 splits: [11:00-12:00 "commit in B", 12:00-13:00 trailing]) + // main@repoA 13:00-16:00 (commit at 14:00 splits: [13:00-14:00 "back to A", 14:00-16:00 trailing]) + + repoASegs := filterSegmentsByRepo(segments, "/repoA") + repoBSegs := filterSegmentsByRepo(segments, "/repoB") + + // repoA: 4 segments [9:00-10:00, 10:00-11:00, 13:00-14:00, 14:00-16:00] + assert.Equal(t, 4, len(repoASegs), "repoA segments") + assert.Equal(t, "commit in A", repoASegs[0].message) + assert.Equal(t, time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), repoASegs[0].from) + assert.Equal(t, time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC), repoASegs[0].to) + assert.Equal(t, "", repoASegs[1].message) // trailing + assert.Equal(t, time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC), repoASegs[1].from) + assert.Equal(t, time.Date(2025, 1, 2, 11, 0, 0, 0, time.UTC), repoASegs[1].to) + assert.Equal(t, "back to A", repoASegs[2].message) + assert.Equal(t, time.Date(2025, 1, 2, 13, 0, 0, 0, time.UTC), repoASegs[2].from) + assert.Equal(t, time.Date(2025, 1, 2, 14, 0, 0, 0, time.UTC), repoASegs[2].to) + assert.Equal(t, "", repoASegs[3].message) // trailing + assert.Equal(t, time.Date(2025, 1, 2, 14, 0, 0, 0, time.UTC), repoASegs[3].from) + assert.Equal(t, time.Date(2025, 1, 2, 16, 0, 0, 0, time.UTC), repoASegs[3].to) + + // repoB: 2 segments [11:00-12:00, 12:00-13:00] + assert.Equal(t, 2, len(repoBSegs), "repoB segments") + assert.Equal(t, "feat", repoBSegs[0].branch) + assert.Equal(t, "commit in B", repoBSegs[0].message) + assert.Equal(t, time.Date(2025, 1, 2, 11, 0, 0, 0, time.UTC), repoBSegs[0].from) + assert.Equal(t, time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), repoBSegs[0].to) + assert.Equal(t, "", repoBSegs[1].message) // trailing + assert.Equal(t, time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), repoBSegs[1].from) + assert.Equal(t, time.Date(2025, 1, 2, 13, 0, 0, 0, time.UTC), repoBSegs[1].to) + + // Verify no time double-counting: total = 9:00-16:00 = 420 minutes + totalMins := 0 + for _, s := range segments { + totalMins += int(s.to.Sub(s.from).Minutes()) + } + assert.Equal(t, 420, totalMins) +} + +func TestBuildCheckoutSegments_CommitRepoFallbackToCheckoutRange(t *testing.T) { + year, month := 2025, time.January + daysInMonth := 31 + + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + {ID: "c2", Timestamp: time.Date(2025, 1, 2, 15, 0, 0, 0, time.UTC), Next: "feat", Repo: "/repoA"}, + } + + // Commit with empty repo — should inherit /repoA from the checkout range + commits := []entry.CommitEntry{ + {ID: "cm1", Timestamp: time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), Branch: "main", Repo: "", Message: "legacy commit"}, + } + + segments := buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, afterMonth(year, month)) + + mainSegs := filterSegments(segments, "main") + assert.Equal(t, 2, len(mainSegs)) + + // Commit segment should inherit repo from checkout range + assert.Equal(t, "/repoA", mainSegs[0].repo) + assert.Equal(t, "legacy commit", mainSegs[0].message) + + // Trailing segment should also have checkout range's repo + assert.Equal(t, "/repoA", mainSegs[1].repo) +} + +func TestBuildReport_MultiRepoNoDoubleCounting(t *testing.T) { + year, month := 2025, time.January + days := []schedule.DaySchedule{workday(year, month, 2)} // 9-17 = 480 min + + // Single checkout, commits alternate between two repos + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + } + + commits := []entry.CommitEntry{ + {ID: "cm1", Timestamp: time.Date(2025, 1, 2, 11, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA", Message: "work in A"}, + {ID: "cm2", Timestamp: time.Date(2025, 1, 2, 13, 0, 0, 0, time.UTC), Branch: "feat", Repo: "/repoB", Message: "work in B"}, + {ID: "cm3", Timestamp: time.Date(2025, 1, 2, 15, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA", Message: "more A"}, + } + + now := afterMonth(year, month) + report := BuildReport(checkouts, nil, commits, days, year, month, now, nil) + + // Total across all rows should equal schedule capacity (480 min), not exceed it + totalMins := 0 + for _, row := range report.Rows { + totalMins += row.TotalMinutes + } + assert.Equal(t, 480, totalMins, "total time should equal schedule capacity without double-counting") + + // Should have two rows: main (repoA time) and feat (repoB time) + assert.Equal(t, 2, len(report.Rows)) + + // Synthetic checkouts: midpoint at 12:00, midpoint at 14:00 + // main@repoA: 9:00-12:00 (180 min) + 14:00-17:00 (180 min) = 360 min + // feat@repoB: 12:00-14:00 = 120 min + rowMain := findRow(report, "main") + rowFeat := findRow(report, "feat") + assert.NotNil(t, rowMain) + assert.NotNil(t, rowFeat) + assert.Equal(t, 360, rowMain.TotalMinutes) + assert.Equal(t, 120, rowFeat.TotalMinutes) +} + +func TestBuildDetailedReport_MultiRepoWithCommits(t *testing.T) { + year, month := 2025, time.January + from := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(year, month, 31, 0, 0, 0, 0, time.UTC) + days := []schedule.DaySchedule{workday(year, month, 2)} // 9-17 = 480 min + + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + } + + commits := []entry.CommitEntry{ + {ID: "cm1", Timestamp: time.Date(2025, 1, 2, 11, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA", Message: "work in A"}, + {ID: "cm2", Timestamp: time.Date(2025, 1, 2, 13, 0, 0, 0, time.UTC), Branch: "feat", Repo: "/repoB", Message: "work in B"}, + {ID: "cm3", Timestamp: time.Date(2025, 1, 2, 15, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA", Message: "more A"}, + } + + report := BuildDetailedReport(checkouts, nil, commits, days, from, to, afterMonth(year, month)) + + // Total across all rows should equal 480 min + totalMins := 0 + for _, row := range report.Rows { + totalMins += row.TotalMinutes + } + assert.Equal(t, 480, totalMins, "total time should equal schedule capacity") + + // Should have entries for both main and feat + assert.Equal(t, 2, len(report.Rows)) + + // Verify per-row distribution matches synthetic checkout midpoints + rowMain := findDetailedRow(report, "main") + rowFeat := findDetailedRow(report, "feat") + assert.NotNil(t, rowMain) + assert.NotNil(t, rowFeat) + assert.Equal(t, 360, rowMain.TotalMinutes) + assert.Equal(t, 120, rowFeat.TotalMinutes) +} + +func TestBuildCheckoutSegments_DoesNotMutateCallerSlice(t *testing.T) { + year, month := 2025, time.January + daysInMonth := 31 + + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 0, 0, time.UTC), Next: "main", Repo: "/repoA"}, + } + commits := []entry.CommitEntry{ + {ID: "cm1", Timestamp: time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC), Branch: "main", Repo: "/repoA"}, + {ID: "cm2", Timestamp: time.Date(2025, 1, 2, 12, 0, 0, 0, time.UTC), Branch: "feat", Repo: "/repoB"}, + } + + origLen := len(checkouts) + origFirst := checkouts[0] + + buildCheckoutSegments(checkouts, commits, year, month, daysInMonth, afterMonth(year, month)) + + // Caller's slice must not be mutated by synthetic checkout injection + assert.Equal(t, origLen, len(checkouts)) + assert.Equal(t, origFirst, checkouts[0]) +} + +// --- Test helpers --- + +func filterSegmentsByRepo(segments []sessionSegment, repo string) []sessionSegment { + var result []sessionSegment + for _, s := range segments { + if s.repo == repo { + result = append(result, s) + } + } + return result +} + +func filterGapsByRepoExact(gaps []idleGap, repo string) []idleGap { + var result []idleGap + for _, g := range gaps { + if g.repo == repo { + result = append(result, g) + } + } + return result +} diff --git a/internal/timetrack/timetrack.go b/internal/timetrack/timetrack.go index 8012256..bbc25b7 100644 --- a/internal/timetrack/timetrack.go +++ b/internal/timetrack/timetrack.go @@ -184,6 +184,8 @@ func buildLogBucket(logs []entry.Entry, year int, month time.Month) (map[string] // buildCheckoutBucket computes per-branch, per-day minutes from checkout entries // clipped to schedule windows. Schedule window times are interpreted in the // timezone of `now` (the user's local timezone). +// NOTE: Does not inject synthetic checkouts from commits. For multi-repo-aware +// attribution, use buildCheckoutSegments → buildSegmentBucket instead. func buildCheckoutBucket( checkouts []entry.CheckoutEntry, year int, month time.Month, daysInMonth int, @@ -197,11 +199,12 @@ func buildCheckoutBucket( return sorted[i].Timestamp.Before(sorted[j].Timestamp) }) - // Deduplicate: skip consecutive checkouts to the same branch + // Deduplicate: skip consecutive checkouts to the same branch+repo if len(sorted) > 0 { deduped := []entry.CheckoutEntry{sorted[0]} for i := 1; i < len(sorted); i++ { - if cleanBranchName(sorted[i].Next) != cleanBranchName(sorted[i-1].Next) { + if cleanBranchName(sorted[i].Next) != cleanBranchName(sorted[i-1].Next) || + sorted[i].Repo != sorted[i-1].Repo { deduped = append(deduped, sorted[i]) } } @@ -222,6 +225,7 @@ func buildCheckoutBucket( if lastBeforeIdx >= 0 { pairs = append(pairs, checkoutRange{ branch: cleanBranchName(sorted[lastBeforeIdx].Next), + repo: sorted[lastBeforeIdx].Repo, from: monthStart, }) } @@ -230,6 +234,7 @@ func buildCheckoutBucket( if c.Timestamp.After(monthStart) && !c.Timestamp.After(monthEnd) { pairs = append(pairs, checkoutRange{ branch: cleanBranchName(c.Next), + repo: c.Repo, from: c.Timestamp, }) } @@ -487,6 +492,7 @@ func BuildDetailedReport( type checkoutRange struct { branch string + repo string from time.Time to time.Time } diff --git a/internal/watch/daemon.go b/internal/watch/daemon.go index d3fdb59..81c245d 100644 --- a/internal/watch/daemon.go +++ b/internal/watch/daemon.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "sync" "syscall" "time" @@ -154,15 +155,21 @@ func (d *Daemon) reloadConfig() error { if !p.Precise { continue } - threshold := p.IdleThresholdMinutes + threshold := p.IdleThresholdSeconds if threshold <= 0 { - threshold = project.DefaultIdleThresholdMinutes + threshold = project.DefaultIdleThresholdSeconds + } + // Allow override via env var for e2e testing with short thresholds + if override := os.Getenv("HOURGIT_IDLE_THRESHOLD"); override != "" { + if v, err := strconv.Atoi(override); err == nil && v > 0 { + threshold = v + } } for _, repo := range p.Repos { wanted[repo] = DaemonConfig{ Repo: repo, Slug: p.Slug, - Threshold: time.Duration(threshold) * time.Minute, + Threshold: time.Duration(threshold) * time.Second, } } } diff --git a/internal/watch/daemon_test.go b/internal/watch/daemon_test.go index f342371..507df23 100644 --- a/internal/watch/daemon_test.go +++ b/internal/watch/daemon_test.go @@ -24,7 +24,7 @@ func setupDaemonTest(t *testing.T) string { Slug: "test", Repos: []string{"/some/repo"}, Precise: true, - IdleThresholdMinutes: 5, + IdleThresholdSeconds: 300, }, }, } @@ -45,6 +45,25 @@ func TestDaemonReloadConfig(t *testing.T) { _ = err } +func TestDaemonReloadConfigEnvOverride(t *testing.T) { + home := setupDaemonTest(t) + writer := &mockEntryWriter{} + d := NewDaemon(home, writer) + d.state = NewWatchState() + + // Set env var override to 5 seconds + t.Setenv("HOURGIT_IDLE_THRESHOLD", "5") + + _ = d.reloadConfig() + + // The debouncer should have the overridden threshold (5s, not config's 300s) + d.mu.Lock() + defer d.mu.Unlock() + for _, db := range d.debouncers { + assert.Equal(t, 5*time.Second, db.threshold, "env var should override config threshold") + } +} + func TestDaemonRecoverFromCrash(t *testing.T) { home := setupDaemonTest(t) writer := &mockEntryWriter{} diff --git a/tests/e2e/helpers_test.go b/tests/e2e/helpers_test.go new file mode 100644 index 0000000..13bc5fa --- /dev/null +++ b/tests/e2e/helpers_test.go @@ -0,0 +1,334 @@ +package e2e + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/hashutil" + "github.com/Flyrell/hourgit/internal/project" + "github.com/stretchr/testify/require" +) + +var fileCounter atomic.Int64 + +// TestEnv provides an isolated sandbox for e2e tests. +// Each TestEnv has its own HOME directory and set of git repos. +type TestEnv struct { + T *testing.T + HomeDir string + Repos map[string]*TestRepo +} + +// TestRepo represents a real git repository in a temp directory. +type TestRepo struct { + Dir string + Name string + SHAs []string // collected commit SHAs for use in simulated reflog + Branch string // default branch name (master or main depending on git version) +} + +// NewTestEnv creates an isolated test environment with its own HOME dir. +func NewTestEnv(t *testing.T) *TestEnv { + t.Helper() + if skipE2E { + t.Skip("e2e tests require git") + } + homeDir := t.TempDir() + return &TestEnv{ + T: t, + HomeDir: homeDir, + Repos: make(map[string]*TestRepo), + } +} + +// AddRepo creates a new real git repository with multiple commits +// to provide valid SHAs for simulated reflog entries. +func (env *TestEnv) AddRepo(name string) *TestRepo { + env.T.Helper() + dir := env.T.TempDir() + + env.git(dir, "init") + env.git(dir, "config", "user.name", "E2E Test") + env.git(dir, "config", "user.email", "e2e@test.com") + + repo := &TestRepo{Dir: dir, Name: name} + env.Repos[name] = repo + + // Create multiple commits to build a pool of valid SHAs. + // Git reflog validates that SHAs reference real objects, so simulated + // reflog entries must use SHAs from actual commits. + // + // Each ReflogBuilder operation (Checkout/Commit) consumes 2 SHAs + // (oldSHA + newSHA), so 20 commits = 20 SHAs supports up to 10 reflog + // entries per repo. If the index exceeds the pool size, SHAs wrap via + // modulo (see ReflogBuilder.nextSHA). + for i := 0; i < 20; i++ { + n := fileCounter.Add(1) + filePath := filepath.Join(dir, fmt.Sprintf("seed-%d.txt", n)) + require.NoError(env.T, os.WriteFile(filePath, []byte(fmt.Sprintf("seed %d\n", n)), 0644)) + env.git(dir, "add", filepath.Base(filePath)) + env.git(dir, "commit", "-m", fmt.Sprintf("seed commit %d", i)) + sha := strings.TrimSpace(env.git(dir, "rev-parse", "HEAD")) + repo.SHAs = append(repo.SHAs, sha) + } + + // Detect the default branch name (master vs main) + repo.Branch = strings.TrimSpace(env.git(dir, "rev-parse", "--abbrev-ref", "HEAD")) + + // Clear the reflog so seed commits don't pollute sync results. + // The SHAs are still valid git objects — they just won't appear in git reflog output. + headLog := filepath.Join(dir, ".git", "logs", "HEAD") + require.NoError(env.T, os.Truncate(headLog, 0)) + + return repo +} + +// Run executes the hourgit binary with the given args and HOME set to env.HomeDir. +// Returns stdout, stderr, and error. +func (env *TestEnv) Run(args ...string) (string, string, error) { + env.T.Helper() + return env.runCmd("", args...) +} + +// RunInRepo executes hourgit from within the given repo directory. +func (env *TestEnv) RunInRepo(repoName string, args ...string) (string, string, error) { + env.T.Helper() + repo, ok := env.Repos[repoName] + require.True(env.T, ok, "repo %q not found in test env", repoName) + return env.runCmd(repo.Dir, args...) +} + +// MustRunInRepo runs hourgit in a repo and fails the test if it errors. +func (env *TestEnv) MustRunInRepo(repoName string, args ...string) string { + env.T.Helper() + stdout, stderr, err := env.RunInRepo(repoName, args...) + require.NoError(env.T, err, "hourgit %v failed:\nstdout: %s\nstderr: %s", args, stdout, stderr) + return stdout +} + +// MustRun runs hourgit and fails the test if it errors. +func (env *TestEnv) MustRun(args ...string) string { + env.T.Helper() + stdout, stderr, err := env.Run(args...) + require.NoError(env.T, err, "hourgit %v failed:\nstdout: %s\nstderr: %s", args, stdout, stderr) + return stdout +} + +func (env *TestEnv) runCmd(dir string, args ...string) (string, string, error) { + env.T.Helper() + // Add --skip-updates and --skip-watcher to avoid interactive prompts and service checks + fullArgs := append([]string{"--skip-updates", "--skip-watcher"}, args...) + cmd := exec.Command(binaryPath, fullArgs...) + cmd.Env = append(filterHostEnv(), "HOME="+env.HomeDir) + if dir != "" { + cmd.Dir = dir + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +// GitInRepo runs a git command in the given repo. +func (env *TestEnv) GitInRepo(repoName string, gitArgs ...string) string { + env.T.Helper() + repo, ok := env.Repos[repoName] + require.True(env.T, ok, "repo %q not found in test env", repoName) + return env.git(repo.Dir, gitArgs...) +} + +func (env *TestEnv) git(dir string, args ...string) string { + env.T.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(filterHostEnv(), "HOME="+env.HomeDir) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + require.NoError(env.T, err, "git %v in %s failed:\nstdout: %s\nstderr: %s", args, dir, stdout.String(), stderr.String()) + return stdout.String() +} + +// GitCommit creates a unique file, stages, and commits it. +func (env *TestEnv) GitCommit(repoName, message string) { + env.T.Helper() + repo := env.Repos[repoName] + require.NotNil(env.T, repo, "repo %q not found", repoName) + + n := fileCounter.Add(1) + filePath := filepath.Join(repo.Dir, fmt.Sprintf("file-%d.txt", n)) + require.NoError(env.T, os.WriteFile(filePath, []byte(fmt.Sprintf("content %d\n", n)), 0644)) + + env.git(repo.Dir, "add", filepath.Base(filePath)) + env.git(repo.Dir, "commit", "-m", message) +} + +// GitCheckout checks out a branch in the given repo. +func (env *TestEnv) GitCheckout(repoName, branch string, create bool) { + env.T.Helper() + repo := env.Repos[repoName] + require.NotNil(env.T, repo, "repo %q not found", repoName) + + if create { + env.git(repo.Dir, "checkout", "-b", branch) + } else { + env.git(repo.Dir, "checkout", branch) + } +} + +// --- Entry readers --- + +// ReadCheckoutEntries reads all checkout entries for a project slug. +func (env *TestEnv) ReadCheckoutEntries(slug string) []entry.CheckoutEntry { + env.T.Helper() + entries, err := entry.ReadAllCheckoutEntries(env.HomeDir, slug) + require.NoError(env.T, err) + return entries +} + +// ReadCommitEntries reads all commit entries for a project slug. +func (env *TestEnv) ReadCommitEntries(slug string) []entry.CommitEntry { + env.T.Helper() + entries, err := entry.ReadAllCommitEntries(env.HomeDir, slug) + require.NoError(env.T, err) + return entries +} + +// ReadLogEntries reads all manual log entries for a project slug. +func (env *TestEnv) ReadLogEntries(slug string) []entry.Entry { + env.T.Helper() + entries, err := entry.ReadAllEntries(env.HomeDir, slug) + require.NoError(env.T, err) + return entries +} + +// ReadActivityStopEntries reads all activity stop entries for a project slug. +func (env *TestEnv) ReadActivityStopEntries(slug string) []entry.ActivityStopEntry { + env.T.Helper() + entries, err := entry.ReadAllActivityStopEntries(env.HomeDir, slug) + require.NoError(env.T, err) + return entries +} + +// ReadActivityStartEntries reads all activity start entries for a project slug. +func (env *TestEnv) ReadActivityStartEntries(slug string) []entry.ActivityStartEntry { + env.T.Helper() + entries, err := entry.ReadAllActivityStartEntries(env.HomeDir, slug) + require.NoError(env.T, err) + return entries +} + +// --- Config readers --- + +// ReadConfig reads the global hourgit config. +func (env *TestEnv) ReadConfig() *project.Config { + env.T.Helper() + cfg, err := project.ReadConfig(env.HomeDir) + require.NoError(env.T, err) + return cfg +} + +// ReadRepoConfig reads the per-repo hourgit config. +func (env *TestEnv) ReadRepoConfig(repoName string) *project.RepoConfig { + env.T.Helper() + repo := env.Repos[repoName] + require.NotNil(env.T, repo, "repo %q not found", repoName) + rc, err := project.ReadRepoConfig(repo.Dir) + require.NoError(env.T, err) + return rc +} + +// FindProject finds a project by name in the config. +func (env *TestEnv) FindProject(name string) *project.ProjectEntry { + env.T.Helper() + cfg := env.ReadConfig() + p := project.FindProject(cfg, name) + require.NotNil(env.T, p, "project %q not found in config", name) + return p +} + +// --- Activity entry writers (for precise mode testing) --- + +// WriteActivityStop writes an activity_stop entry directly to the project dir. +func (env *TestEnv) WriteActivityStop(slug string, timestamp time.Time, repo string) { + env.T.Helper() + e := entry.ActivityStopEntry{ + ID: generateTestID(), + Timestamp: timestamp, + Repo: repo, + } + err := entry.WriteActivityStopEntry(env.HomeDir, slug, e) + require.NoError(env.T, err) +} + +// WriteActivityStart writes an activity_start entry directly to the project dir. +func (env *TestEnv) WriteActivityStart(slug string, timestamp time.Time, repo string) { + env.T.Helper() + e := entry.ActivityStartEntry{ + ID: generateTestID(), + Timestamp: timestamp, + Repo: repo, + } + err := entry.WriteActivityStartEntry(env.HomeDir, slug, e) + require.NoError(env.T, err) +} + +// generateTestID creates a 7-char hex ID using the production hash scheme. +// Each call produces a unique ID via an incrementing counter. +func generateTestID() string { + n := fileCounter.Add(1) + return hashutil.GenerateIDFromSeed(fmt.Sprintf("e2e-activity-%d", n)) +} + +// filterHostEnv returns os.Environ() with HOME, XDG_CONFIG_HOME, and any +// HOURGIT_-prefixed variables removed, preventing the host environment from +// leaking into test subprocesses. +func filterHostEnv() []string { + var filtered []string + for _, e := range os.Environ() { + if strings.HasPrefix(e, "HOME=") || + strings.HasPrefix(e, "XDG_CONFIG_HOME=") || + strings.HasPrefix(e, "HOURGIT_") { + continue + } + filtered = append(filtered, e) + } + return filtered +} + +// --- Shared filter helpers --- + +func filterCheckoutsByRepo(checkouts []entry.CheckoutEntry, repoDir string) []entry.CheckoutEntry { + var filtered []entry.CheckoutEntry + for _, c := range checkouts { + if c.Repo == repoDir { + filtered = append(filtered, c) + } + } + return filtered +} + +func filterCommitsByRepo(commits []entry.CommitEntry, repoDir string) []entry.CommitEntry { + var filtered []entry.CommitEntry + for _, c := range commits { + if c.Repo == repoDir { + filtered = append(filtered, c) + } + } + return filtered +} + diff --git a/tests/e2e/main_test.go b/tests/e2e/main_test.go new file mode 100644 index 0000000..c1c7274 --- /dev/null +++ b/tests/e2e/main_test.go @@ -0,0 +1,42 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +var binaryPath string + +// skipE2E is set to true when prerequisites (git) are missing. +var skipE2E bool + +func TestMain(m *testing.M) { + // Check prerequisites — git must be available for e2e tests + if _, err := exec.LookPath("git"); err != nil { + fmt.Println("e2e: skipping — git not found in PATH") + skipE2E = true + os.Exit(m.Run()) + } + + tmp, err := os.MkdirTemp("", "hourgit-e2e-bin-*") + if err != nil { + panic("failed to create temp dir for binary: " + err.Error()) + } + defer func() { _ = os.RemoveAll(tmp) }() + + binaryPath = filepath.Join(tmp, "hourgit") + + // Build the binary from the project root + build := exec.Command("go", "build", "-ldflags", "-X main.version=e2e-test", "-o", binaryPath, "./cmd/hourgit") + build.Dir = filepath.Join("..", "..") + build.Stdout = os.Stdout + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + panic("failed to build hourgit binary: " + err.Error()) + } + + os.Exit(m.Run()) +} diff --git a/tests/e2e/pipeline1_test.go b/tests/e2e/pipeline1_test.go new file mode 100644 index 0000000..89403de --- /dev/null +++ b/tests/e2e/pipeline1_test.go @@ -0,0 +1,212 @@ +package e2e + +import ( + "strings" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/stringutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPipeline1_TwoRepos_TwoProjects tests two repos with separate projects: +// Repo A -> Project "Alpha" (precise tracking) +// Repo B -> Project "Beta" (normal tracking) +func TestPipeline1_TwoRepos_TwoProjects(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + + repoA := env.AddRepo("repo-a") + repoB := env.AddRepo("repo-b") + + // ===== PHASE 1: Simulated reflog ===== + + // Simulate 2 weeks of checkouts for Repo A (precise project) + baseTime := time.Date(2026, 3, 9, 9, 0, 0, 0, time.UTC) // Monday March 9 + + reflogA := NewReflogBuilder(repoA) + reflogA.Checkout(repoA.Branch, "feat-a", baseTime) + reflogA.Commit("feat-a: initial setup", baseTime.Add(30*time.Minute)) + reflogA.Commit("feat-a: add tests", baseTime.Add(2*time.Hour)) + reflogA.Checkout("feat-a", "feat-b", baseTime.Add(4*time.Hour)) + reflogA.Commit("feat-b: scaffold", baseTime.Add(5*time.Hour)) + // Week 2 + reflogA.Checkout("feat-b", repoA.Branch, baseTime.Add(7*24*time.Hour)) + reflogA.Commit(repoA.Branch+": merge cleanup", baseTime.Add(7*24*time.Hour+time.Hour)) + reflogA.WriteTo(t) + + // Simulate 2 weeks of checkouts for Repo B (normal project) + reflogB := NewReflogBuilder(repoB) + reflogB.Checkout(repoB.Branch, "bugfix-1", baseTime.Add(time.Hour)) + reflogB.Commit("bugfix-1: fix login issue", baseTime.Add(2*time.Hour)) + reflogB.Checkout("bugfix-1", repoB.Branch, baseTime.Add(3*time.Hour)) + reflogB.Checkout(repoB.Branch, "feat-x", baseTime.Add(24*time.Hour+9*time.Hour)) + reflogB.Commit("feat-x: start feature", baseTime.Add(24*time.Hour+10*time.Hour)) + reflogB.WriteTo(t) + + // Initialize projects + env.MustRunInRepo("repo-a", "init", "--project", "Alpha", "--mode", "precise", "--yes") + env.MustRunInRepo("repo-b", "init", "--project", "Beta", "--yes") + + // Write activity entries for precise mode on Repo A + // Idle gap: 30 min idle starting 1h into feat-a work + alphaSlug := stringutil.Slugify("Alpha") + env.WriteActivityStop(alphaSlug, baseTime.Add(time.Hour), repoA.Dir) + env.WriteActivityStart(alphaSlug, baseTime.Add(time.Hour+30*time.Minute), repoA.Dir) + + // Sync both repos + syncOutA := env.MustRunInRepo("repo-a", "sync") + syncOutB := env.MustRunInRepo("repo-b", "sync") + + // --- Phase 1 tests --- + + // Verify sync output mentions checkouts and commits + assert.Contains(t, syncOutA, "checkout") + assert.Contains(t, syncOutB, "checkout") + + // Verify checkout entries created for each project separately + alphaCheckouts := env.ReadCheckoutEntries(alphaSlug) + betaSlug := stringutil.Slugify("Beta") + betaCheckouts := env.ReadCheckoutEntries(betaSlug) + + assert.Len(t, alphaCheckouts, 3, "Alpha should have exactly 3 checkouts") + assert.Len(t, betaCheckouts, 3, "Beta should have exactly 3 checkouts") + + // Verify commit entries have correct branch attribution + alphaCommits := env.ReadCommitEntries(alphaSlug) + betaCommits := env.ReadCommitEntries(betaSlug) + + assert.Len(t, alphaCommits, 4, "Alpha should have exactly 4 commits") + assert.Len(t, betaCommits, 2, "Beta should have exactly 2 commits") + + // Check commit branch attribution for Alpha + for _, c := range alphaCommits { + if strings.Contains(c.Message, "feat-a") { + assert.Equal(t, "feat-a", c.Branch, "feat-a commit should be on feat-a branch") + } + if strings.Contains(c.Message, "feat-b") { + assert.Equal(t, "feat-b", c.Branch, "feat-b commit should be on feat-b branch") + } + } + + // Verify Repo A has precise mode, Repo B doesn't + alphaProj := env.FindProject("Alpha") + betaProj := env.FindProject("Beta") + assert.True(t, alphaProj.Precise, "Alpha should have precise tracking") + assert.False(t, betaProj.Precise, "Beta should not have precise tracking") + + // Verify status shows correct project/branch + statusA := env.MustRunInRepo("repo-a", "status") + assert.Contains(t, statusA, "Alpha") + + statusB := env.MustRunInRepo("repo-b", "status") + assert.Contains(t, statusB, "Beta") + + // Verify history shows entries per project (use --project flag for isolation) + historyA := env.MustRunInRepo("repo-a", "history", "--project", "Alpha") + assert.Contains(t, historyA, "feat-a") + assert.NotContains(t, historyA, "bugfix-1", "Alpha history should not contain Beta entries") + + historyB := env.MustRunInRepo("repo-b", "history", "--project", "Beta") + assert.Contains(t, historyB, "bugfix-1") + assert.NotContains(t, historyB, "feat-a", "Beta history should not contain Alpha entries") + + // Verify project list shows both projects + projectList := env.MustRun("project", "list") + assert.Contains(t, projectList, "Alpha") + assert.Contains(t, projectList, "Beta") + + // Verify activity entries exist for Alpha + actStops := env.ReadActivityStopEntries(alphaSlug) + actStarts := env.ReadActivityStartEntries(alphaSlug) + assert.Len(t, actStops, 1, "Alpha should have 1 activity stop") + assert.Len(t, actStarts, 1, "Alpha should have 1 activity start") + + // ===== PHASE 2: Real git changes ===== + + // Make real changes in Repo A + env.GitCheckout("repo-a", "feat-c", true) + env.GitCommit("repo-a", "feat-c: new feature") + + // Make real changes in Repo B + env.GitCheckout("repo-b", "feat-y", true) + env.GitCommit("repo-b", "feat-y: started work") + + // Sync again + syncOutA2 := env.MustRunInRepo("repo-a", "sync") + syncOutB2 := env.MustRunInRepo("repo-b", "sync") + + // Verify incremental sync picked up new entries + assert.Contains(t, syncOutA2, "checkout") + assert.Contains(t, syncOutB2, "checkout") + + // Verify LastSync was updated + repoConfigA := env.ReadRepoConfig("repo-a") + require.NotNil(t, repoConfigA) + assert.NotNil(t, repoConfigA.LastSync, "LastSync should be set after sync") + + repoConfigB := env.ReadRepoConfig("repo-b") + require.NotNil(t, repoConfigB) + assert.NotNil(t, repoConfigB.LastSync, "LastSync should be set after sync") + + // New checkout entries should exist alongside simulated ones + alphaCheckouts2 := env.ReadCheckoutEntries(alphaSlug) + betaCheckouts2 := env.ReadCheckoutEntries(betaSlug) + assert.Greater(t, len(alphaCheckouts2), len(alphaCheckouts), "Alpha should have more checkouts after phase 2") + assert.Greater(t, len(betaCheckouts2), len(betaCheckouts), "Beta should have more checkouts after phase 2") + + // Add manual log entries + logOutA := env.MustRunInRepo("repo-a", "log", "add", "--duration", "1h30m", "--task", "ALPHA-1", "--date", "2026-03-26", "--yes", "Manual research work") + assert.NotEmpty(t, logOutA) + + logOutB := env.MustRunInRepo("repo-b", "log", "add", "--duration", "45m", "--task", "BETA-1", "--date", "2026-03-26", "--yes", "Bug analysis") + assert.NotEmpty(t, logOutB) + + // Verify manual log entries created + alphaLogs := env.ReadLogEntries(alphaSlug) + betaLogs := env.ReadLogEntries(betaSlug) + assert.Len(t, alphaLogs, 1, "Alpha should have 1 manual log entry") + assert.Len(t, betaLogs, 1, "Beta should have 1 manual log entry") + + assert.Equal(t, 90, alphaLogs[0].Minutes, "Alpha log should be 90 minutes") + assert.Equal(t, "ALPHA-1", alphaLogs[0].Task) + assert.Equal(t, 45, betaLogs[0].Minutes, "Beta log should be 45 minutes") + assert.Equal(t, "BETA-1", betaLogs[0].Task) + + // History should show all entry types + historyA2 := env.MustRunInRepo("repo-a", "history") + assert.Contains(t, historyA2, "Manual research work") + + historyB2 := env.MustRunInRepo("repo-b", "history") + assert.Contains(t, historyB2, "Bug analysis") + + // Status should show new current branches + statusA2 := env.MustRunInRepo("repo-a", "status") + assert.Contains(t, statusA2, "feat-c") + + statusB2 := env.MustRunInRepo("repo-b", "status") + assert.Contains(t, statusB2, "feat-y") + + // Test log edit: change duration + alphaLogID := alphaLogs[0].ID + env.MustRunInRepo("repo-a", "log", "edit", alphaLogID, "--duration", "2h", "--yes") + + // Verify edit took effect + alphaLogsEdited := env.ReadLogEntries(alphaSlug) + require.Len(t, alphaLogsEdited, 1) + assert.Equal(t, 120, alphaLogsEdited[0].Minutes, "Edited entry should be 120 minutes") + assert.Equal(t, alphaLogID, alphaLogsEdited[0].ID, "Entry ID should be preserved after edit") + + // Test log remove: remove a checkout entry + require.NotEmpty(t, alphaCheckouts2) + removeTarget := alphaCheckouts2[0].ID + env.MustRunInRepo("repo-a", "log", "remove", removeTarget, "--yes") + + // Verify removal + alphaCheckouts3 := env.ReadCheckoutEntries(alphaSlug) + assert.Equal(t, len(alphaCheckouts2)-1, len(alphaCheckouts3), "Should have one fewer checkout after removal") + for _, c := range alphaCheckouts3 { + assert.NotEqual(t, removeTarget, c.ID, "Removed entry should not exist") + } +} diff --git a/tests/e2e/pipeline2_test.go b/tests/e2e/pipeline2_test.go new file mode 100644 index 0000000..4f26b4d --- /dev/null +++ b/tests/e2e/pipeline2_test.go @@ -0,0 +1,156 @@ +package e2e + +import ( + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/stringutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPipeline2_TwoRepos_OneProject_Precise tests two repos sharing a single +// project with precise tracking enabled. +// Repo A -> Project "Gamma" (precise) +// Repo B -> Project "Gamma" (precise) +func TestPipeline2_TwoRepos_OneProject_Precise(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + + repoA := env.AddRepo("repo-a") + repoB := env.AddRepo("repo-b") + + // ===== PHASE 1: Simulated reflog ===== + + // Interleave timestamps across repos for cross-repo chronology testing + baseTime := time.Date(2026, 3, 16, 9, 0, 0, 0, time.UTC) // Monday March 16 + + // Repo A: feat-1 and feat-2 work + reflogA := NewReflogBuilder(repoA) + reflogA.Checkout(repoA.Branch, "feat-1", baseTime) + reflogA.Commit("feat-1: initial work", baseTime.Add(30*time.Minute)) + reflogA.Commit("feat-1: add validation", baseTime.Add(2*time.Hour)) + reflogA.Checkout("feat-1", "feat-2", baseTime.Add(4*time.Hour)) + reflogA.Commit("feat-2: database schema", baseTime.Add(5*time.Hour)) + reflogA.WriteTo(t) + + // Repo B: feat-3 and feat-4 work (interleaved timestamps with Repo A) + reflogB := NewReflogBuilder(repoB) + reflogB.Checkout(repoB.Branch, "feat-3", baseTime.Add(time.Hour)) + reflogB.Commit("feat-3: API endpoint", baseTime.Add(90*time.Minute)) + reflogB.Checkout("feat-3", "feat-4", baseTime.Add(3*time.Hour)) + reflogB.Commit("feat-4: frontend component", baseTime.Add(3*time.Hour+30*time.Minute)) + reflogB.Commit("feat-4: add styles", baseTime.Add(4*time.Hour+30*time.Minute)) + reflogB.WriteTo(t) + + // Initialize Repo A with the project + env.MustRunInRepo("repo-a", "init", "--project", "Gamma", "--mode", "precise", "--yes") + + // Initialize Repo B and assign to same project. + // init --yes without --project only installs the post-checkout hook (no project + // is created). The --yes flag auto-accepts shell completion install. The actual + // project assignment happens in the next command. + env.MustRunInRepo("repo-b", "init", "--yes") + env.MustRunInRepo("repo-b", "project", "assign", "Gamma", "--force", "--yes") + + gammaSlug := stringutil.Slugify("Gamma") + + // Add activity entries for both repos (idle gaps) + // Repo A: 20 min idle gap during feat-1 work + env.WriteActivityStop(gammaSlug, baseTime.Add(time.Hour), repoA.Dir) + env.WriteActivityStart(gammaSlug, baseTime.Add(time.Hour+20*time.Minute), repoA.Dir) + + // Repo B: 15 min idle gap during feat-3 work + env.WriteActivityStop(gammaSlug, baseTime.Add(2*time.Hour), repoB.Dir) + env.WriteActivityStart(gammaSlug, baseTime.Add(2*time.Hour+15*time.Minute), repoB.Dir) + + // Sync both repos + env.MustRunInRepo("repo-a", "sync") + env.MustRunInRepo("repo-b", "sync") + + // --- Phase 1 tests --- + + // All entries should be in single project dir + checkouts := env.ReadCheckoutEntries(gammaSlug) + commits := env.ReadCommitEntries(gammaSlug) + assert.Len(t, checkouts, 4, "Gamma should have exactly 4 checkouts from both repos") + assert.Len(t, commits, 6, "Gamma should have exactly 6 commits from both repos") + + // Verify Repo field distinguishes repos + repoACheckouts := filterCheckoutsByRepo(checkouts, repoA.Dir) + repoBCheckouts := filterCheckoutsByRepo(checkouts, repoB.Dir) + assert.Len(t, repoACheckouts, 2, "Should have exactly 2 checkouts from repo-a") + assert.Len(t, repoBCheckouts, 2, "Should have exactly 2 checkouts from repo-b") + + repoACommits := filterCommitsByRepo(commits, repoA.Dir) + repoBCommits := filterCommitsByRepo(commits, repoB.Dir) + assert.Len(t, repoACommits, 3, "Should have exactly 3 commits from repo-a") + assert.Len(t, repoBCommits, 3, "Should have exactly 3 commits from repo-b") + + // History should show entries from both repos interleaved by time + history := env.MustRunInRepo("repo-a", "history", "--limit", "50") + assert.Contains(t, history, "feat-1") + assert.Contains(t, history, "feat-3") + + // Status from each repo should show different current branches + statusA := env.MustRunInRepo("repo-a", "status") + assert.Contains(t, statusA, "Gamma") + + statusB := env.MustRunInRepo("repo-b", "status") + assert.Contains(t, statusB, "Gamma") + + // Verify activity entries exist for both repos + actStops := env.ReadActivityStopEntries(gammaSlug) + actStarts := env.ReadActivityStartEntries(gammaSlug) + assert.Len(t, actStops, 2, "Should have activity stops for both repos") + assert.Len(t, actStarts, 2, "Should have activity starts for both repos") + + // Verify project is precise + gammaProj := env.FindProject("Gamma") + assert.True(t, gammaProj.Precise, "Gamma should have precise tracking") + assert.Len(t, gammaProj.Repos, 2, "Gamma should have 2 repos") + + // ===== PHASE 2: Real git changes ===== + + // Real changes in Repo A + env.GitCheckout("repo-a", "feat-5", true) + env.GitCommit("repo-a", "feat-5: new API") + + // Real changes in Repo B + env.GitCheckout("repo-b", "feat-6", true) + env.GitCommit("repo-b", "feat-6: dashboard") + + // Sync both + env.MustRunInRepo("repo-a", "sync") + env.MustRunInRepo("repo-b", "sync") + + // --- Phase 2 tests --- + + // Verify incremental sync per-repo (each has own LastSync) + repoConfigA := env.ReadRepoConfig("repo-a") + repoConfigB := env.ReadRepoConfig("repo-b") + require.NotNil(t, repoConfigA) + require.NotNil(t, repoConfigB) + assert.NotNil(t, repoConfigA.LastSync) + assert.NotNil(t, repoConfigB.LastSync) + + // More entries should exist now + checkouts2 := env.ReadCheckoutEntries(gammaSlug) + commits2 := env.ReadCommitEntries(gammaSlug) + assert.Greater(t, len(checkouts2), len(checkouts), "Should have more checkouts after phase 2") + assert.Greater(t, len(commits2), len(commits), "Should have more commits after phase 2") + + // Add manual log entry via --project flag (not from within a repo) + env.MustRun("log", "add", "--project", "Gamma", "--duration", "2h", "--task", "GAMMA-1", "--date", "2026-03-26", "--yes", "Cross-repo planning") + + // Verify manual log + logs := env.ReadLogEntries(gammaSlug) + assert.Len(t, logs, 1, "Should have 1 manual log") + assert.Equal(t, "GAMMA-1", logs[0].Task) + assert.Equal(t, 120, logs[0].Minutes) + assert.Equal(t, "Cross-repo planning", logs[0].Message) + + // History should show manual log + history2 := env.MustRunInRepo("repo-a", "history") + assert.Contains(t, history2, "Cross-repo planning") +} diff --git a/tests/e2e/pipeline3_test.go b/tests/e2e/pipeline3_test.go new file mode 100644 index 0000000..ba5d8f3 --- /dev/null +++ b/tests/e2e/pipeline3_test.go @@ -0,0 +1,181 @@ +package e2e + +import ( + "path/filepath" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/stringutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPipeline3_TwoRepos_OneProject_Normal tests two repos sharing a single +// project with standard (non-precise) tracking. +// Repo A -> Project "Delta" (standard) +// Repo B -> Project "Delta" (standard) +func TestPipeline3_TwoRepos_OneProject_Normal(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + + repoA := env.AddRepo("repo-a") + repoB := env.AddRepo("repo-b") + + // ===== PHASE 1: Simulated reflog ===== + + // Simulate 1 week of checkouts for both repos + baseTime := time.Date(2026, 3, 23, 9, 0, 0, 0, time.UTC) // Monday March 23 + + // Repo A: various branch work over the week + reflogA := NewReflogBuilder(repoA) + reflogA.Checkout(repoA.Branch, "feature-auth", baseTime) + reflogA.Commit("auth: add login form", baseTime.Add(time.Hour)) + reflogA.Commit("auth: add validation", baseTime.Add(2*time.Hour)) + reflogA.Checkout("feature-auth", "feature-api", baseTime.Add(4*time.Hour)) + reflogA.Commit("api: REST endpoints", baseTime.Add(5*time.Hour)) + // Day 2 + reflogA.Checkout("feature-api", repoA.Branch, baseTime.Add(24*time.Hour)) + reflogA.Commit("merge: version bump", baseTime.Add(24*time.Hour+30*time.Minute)) + // Day 3 + reflogA.Checkout(repoA.Branch, "feature-auth", baseTime.Add(2*24*time.Hour)) + reflogA.Commit("auth: fix tests", baseTime.Add(2*24*time.Hour+time.Hour)) + reflogA.WriteTo(t) + + // Repo B: different branches, same week + reflogB := NewReflogBuilder(repoB) + reflogB.Checkout(repoB.Branch, "feature-docs", baseTime.Add(2*time.Hour)) + reflogB.Commit("docs: update readme", baseTime.Add(3*time.Hour)) + // Day 2 + reflogB.Checkout("feature-docs", "feature-ci", baseTime.Add(24*time.Hour+time.Hour)) + reflogB.Commit("ci: add pipeline", baseTime.Add(24*time.Hour+2*time.Hour)) + reflogB.Commit("ci: add test stage", baseTime.Add(24*time.Hour+3*time.Hour)) + // Day 3 + reflogB.Checkout("feature-ci", repoB.Branch, baseTime.Add(2*24*time.Hour+2*time.Hour)) + reflogB.Commit("merge: ci integration", baseTime.Add(2*24*time.Hour+3*time.Hour)) + reflogB.WriteTo(t) + + // Initialize Repo A with the project + env.MustRunInRepo("repo-a", "init", "--project", "Delta", "--yes") + + // Initialize Repo B and assign to same project. + // init --yes without --project only installs the post-checkout hook (no project + // is created). The --yes flag auto-accepts shell completion install. The actual + // project assignment happens in the next command. + env.MustRunInRepo("repo-b", "init", "--yes") + env.MustRunInRepo("repo-b", "project", "assign", "Delta", "--force", "--yes") + + deltaSlug := stringutil.Slugify("Delta") + + // Sync both repos + env.MustRunInRepo("repo-a", "sync") + env.MustRunInRepo("repo-b", "sync") + + // --- Phase 1 tests --- + + // Verify entries from both repos in same project dir + checkouts := env.ReadCheckoutEntries(deltaSlug) + commits := env.ReadCommitEntries(deltaSlug) + assert.Len(t, checkouts, 7, "Delta should have exactly 7 checkouts from both repos") + assert.Len(t, commits, 9, "Delta should have exactly 9 commits from both repos") + + // Verify no activity entries (standard mode) + actStops := env.ReadActivityStopEntries(deltaSlug) + actStarts := env.ReadActivityStartEntries(deltaSlug) + assert.Empty(t, actStops, "Standard mode should have no activity stops") + assert.Empty(t, actStarts, "Standard mode should have no activity starts") + + // Verify project is NOT precise + deltaProj := env.FindProject("Delta") + assert.False(t, deltaProj.Precise, "Delta should not have precise tracking") + + // History shows cross-repo entries + history := env.MustRunInRepo("repo-a", "history", "--limit", "50") + assert.Contains(t, history, "feature-auth") + assert.Contains(t, history, "feature-docs") + + // Sync deduplication: re-sync should not create duplicates + env.MustRunInRepo("repo-a", "sync") + env.MustRunInRepo("repo-b", "sync") + + checkoutsAfterResync := env.ReadCheckoutEntries(deltaSlug) + commitsAfterResync := env.ReadCommitEntries(deltaSlug) + assert.Equal(t, len(checkouts), len(checkoutsAfterResync), "Re-sync should not duplicate checkouts") + assert.Equal(t, len(commits), len(commitsAfterResync), "Re-sync should not duplicate commits") + + // ===== PHASE 2: Real git changes ===== + + // Repo A: multiple checkouts and commits + env.GitCheckout("repo-a", "hotfix-1", true) + env.GitCommit("repo-a", "hotfix-1: critical fix") + env.GitCheckout("repo-a", repoA.Branch, false) + env.GitCommit("repo-a", "merge: hotfix applied") + + // Repo B: checkout to a branch that also exists in Repo A's reflog (test cross-repo naming) + env.GitCheckout("repo-b", "feature-auth", true) // same name as in repo-a's simulated reflog + env.GitCommit("repo-b", "auth: separate implementation") + + // Sync both + env.MustRunInRepo("repo-a", "sync") + env.MustRunInRepo("repo-b", "sync") + + // --- Phase 2 tests --- + + // Verify all new entries synced + checkouts2 := env.ReadCheckoutEntries(deltaSlug) + commits2 := env.ReadCommitEntries(deltaSlug) + assert.Greater(t, len(checkouts2), len(checkouts), "Should have more checkouts after phase 2") + assert.Greater(t, len(commits2), len(commits), "Should have more commits after phase 2") + + // Verify same branch name in different repos uses Repo field for distinction + authCommits := filterCommitsByBranch(commits2, "feature-auth") + require.NotEmpty(t, authCommits, "Should have feature-auth commits from both repos") + repoAAuth := filterCommitsByRepo(authCommits, repoA.Dir) + repoBAuth := filterCommitsByRepo(authCommits, repoB.Dir) + assert.NotEmpty(t, repoAAuth, "Should have feature-auth commits from repo-a") + assert.NotEmpty(t, repoBAuth, "Should have feature-auth commits from repo-b") + + // Test log CRUD operations + // Add + addOut := env.MustRunInRepo("repo-a", "log", "add", "--duration", "3h", "--task", "DELTA-1", "--date", "2026-03-26", "--yes", "Design meeting") + assert.NotEmpty(t, addOut) + + logs := env.ReadLogEntries(deltaSlug) + require.Len(t, logs, 1) + logID := logs[0].ID + assert.Equal(t, 180, logs[0].Minutes) + assert.Equal(t, "DELTA-1", logs[0].Task) + + // Edit + env.MustRunInRepo("repo-a", "log", "edit", logID, "--duration", "2h30m", "--message", "Design review meeting", "--yes") + logsEdited := env.ReadLogEntries(deltaSlug) + require.Len(t, logsEdited, 1) + assert.Equal(t, 150, logsEdited[0].Minutes) + assert.Equal(t, "Design review meeting", logsEdited[0].Message) + assert.Equal(t, logID, logsEdited[0].ID, "ID should be preserved") + + // Remove + env.MustRunInRepo("repo-a", "log", "remove", logID, "--yes") + logsRemoved := env.ReadLogEntries(deltaSlug) + assert.Empty(t, logsRemoved, "Log entry should be removed") + + // Test PDF export + pdfOut := env.MustRunInRepo("repo-a", "report", "--month", "3", "--year", "2026", "--export", "pdf") + assert.Contains(t, pdfOut, ".pdf") + + // Verify PDF file was created — simulated data covers March 2026 so a PDF must exist. + // The PDF is generated in the binary's working directory, which is repoA.Dir + // because RunInRepo sets cmd.Dir to the repo path. + pdfPath := filepath.Join(repoA.Dir, "delta-2026-month-03.pdf") + assert.FileExists(t, pdfPath, "PDF export should create file for March 2026") +} + +func filterCommitsByBranch(commits []entry.CommitEntry, branch string) []entry.CommitEntry { + var filtered []entry.CommitEntry + for _, c := range commits { + if c.Branch == branch { + filtered = append(filtered, c) + } + } + return filtered +} diff --git a/tests/e2e/pipeline4_test.go b/tests/e2e/pipeline4_test.go new file mode 100644 index 0000000..a2bebaf --- /dev/null +++ b/tests/e2e/pipeline4_test.go @@ -0,0 +1,206 @@ +package e2e + +import ( + "sort" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // idleThreshold is the idle threshold in seconds used for watcher e2e tests. + // Keep this short so tests complete quickly, but long enough to avoid flakiness. + idleThreshold = 2 + + // waitForIdle is the time to sleep to ensure the idle threshold fires. + // Must be > idleThreshold to guarantee the debounce timer completes. + waitForIdle = 4 * time.Second + + // entryTimeout is the maximum time to wait for activity entries to appear on disk. + entryTimeout = 15 * time.Second +) + +// setupWatcherEnv creates a test env with one precise project and one repo, +// then starts the watcher daemon. Returns the env, watcher manager, and project slug. +func setupWatcherEnv(t *testing.T, projectName string) (*TestEnv, *WatcherManager, string) { + t.Helper() + env := NewTestEnv(t) + env.AddRepo("repo") + + // Init project in precise mode + env.MustRunInRepo("repo", "init", "--project", projectName, "--mode", "precise", "--yes") + + p := env.FindProject(projectName) + require.True(t, p.Precise) + + wm := env.StartWatcher(idleThreshold) + return env, wm, p.Slug +} + +func TestPipeline4_WatcherBasicStartStop(t *testing.T) { + env, wm, slug := setupWatcherEnv(t, "Watcher Basic") + + // Burst of file changes + env.TouchFiles("repo", 3) + + // Wait for idle threshold to fire + time.Sleep(waitForIdle) + + // Should have exactly 1 start and 1 stop + starts, stops := env.WaitForActivityEntries(slug, 1, 1, entryTimeout) + + assert.Len(t, starts, 1) + assert.Len(t, stops, 1) + assert.True(t, starts[0].Timestamp.Before(stops[0].Timestamp), + "start should be before stop") + assert.Equal(t, env.Repos["repo"].Dir, starts[0].Repo) + assert.Equal(t, env.Repos["repo"].Dir, stops[0].Repo) + + wm.Stop() +} + +func TestPipeline4_WatcherMultipleSessions(t *testing.T) { + env, wm, slug := setupWatcherEnv(t, "Watcher Sessions") + + // Session 1 + env.TouchFiles("repo", 3) + time.Sleep(waitForIdle) + env.WaitForActivityEntries(slug, 1, 1, entryTimeout) + + // Session 2 + env.TouchFiles("repo", 3) + time.Sleep(waitForIdle) + starts, stops := env.WaitForActivityEntries(slug, 2, 2, entryTimeout) + + assert.Len(t, starts, 2) + assert.Len(t, stops, 2) + + // Sort by timestamp for reliable ordering + sort.Slice(starts, func(i, j int) bool { return starts[i].Timestamp.Before(starts[j].Timestamp) }) + sort.Slice(stops, func(i, j int) bool { return stops[i].Timestamp.Before(stops[j].Timestamp) }) + + // Verify chronological ordering: start1 < stop1 < start2 < stop2 + assert.True(t, starts[0].Timestamp.Before(stops[0].Timestamp)) + assert.True(t, stops[0].Timestamp.Before(starts[1].Timestamp)) + assert.True(t, starts[1].Timestamp.Before(stops[1].Timestamp)) + + wm.Stop() +} + +func TestPipeline4_WatcherGracefulShutdown(t *testing.T) { + env, wm, slug := setupWatcherEnv(t, "Watcher Shutdown") + + // Trigger activity but do NOT wait for idle + env.TouchFiles("repo", 3) + + // Brief pause to ensure the daemon has processed at least one event + time.Sleep(500 * time.Millisecond) + + // Graceful stop — daemon should write final activity_stop + wm.Stop() + + // Give filesystem a moment to flush + time.Sleep(500 * time.Millisecond) + + starts := env.ReadActivityStartEntries(slug) + stops := env.ReadActivityStopEntries(slug) + + assert.Len(t, starts, 1, "should have 1 activity_start from file changes") + assert.Len(t, stops, 1, "graceful shutdown should write activity_stop") +} + +func TestPipeline4_WatcherCrashRecovery(t *testing.T) { + env, wm, slug := setupWatcherEnv(t, "Watcher Recovery") + + // Trigger activity + env.TouchFiles("repo", 3) + + // Brief pause then crash (SIGKILL) + time.Sleep(500 * time.Millisecond) + wm.Kill() + + // After crash: should have 1 start but 0 stops (daemon didn't clean up) + time.Sleep(500 * time.Millisecond) + starts := env.ReadActivityStartEntries(slug) + stops := env.ReadActivityStopEntries(slug) + assert.Len(t, starts, 1, "should have 1 activity_start") + assert.Len(t, stops, 0, "crash should leave no activity_stop") + + // Start a new daemon — crash recovery should pair the unpaired start + wm2 := env.StartWatcher(idleThreshold) + + // Poll for recovery to complete + starts, stops = env.WaitForActivityEntries(slug, 1, 1, entryTimeout) + assert.Len(t, starts, 1, "still 1 activity_start") + assert.Len(t, stops, 1, "recovery should write retrospective activity_stop") + + wm2.Stop() +} + +func TestPipeline4_WatcherMultiRepo(t *testing.T) { + env := NewTestEnv(t) + env.AddRepo("repo-a") + env.AddRepo("repo-b") + + // Init project with first repo + env.MustRunInRepo("repo-a", "init", "--project", "Watcher Multi", "--mode", "precise", "--yes") + // Init second repo and assign to same project + env.MustRunInRepo("repo-b", "init", "--yes") + env.MustRunInRepo("repo-b", "project", "assign", "Watcher Multi", "--force", "--yes") + + p := env.FindProject("Watcher Multi") + require.True(t, p.Precise) + require.Len(t, p.Repos, 2) + slug := p.Slug + + wm := env.StartWatcher(idleThreshold) + + // Activity in repo-a + env.TouchFiles("repo-a", 3) + time.Sleep(waitForIdle) + env.WaitForActivityEntries(slug, 1, 1, entryTimeout) + + // Activity in repo-b + env.TouchFiles("repo-b", 3) + time.Sleep(waitForIdle) + starts, stops := env.WaitForActivityEntries(slug, 2, 2, entryTimeout) + + assert.Len(t, starts, 2) + assert.Len(t, stops, 2) + + // Verify repo field distinguishes the two repos + repos := map[string]bool{} + for _, s := range starts { + repos[s.Repo] = true + } + assert.True(t, repos[env.Repos["repo-a"].Dir], "should have start for repo-a") + assert.True(t, repos[env.Repos["repo-b"].Dir], "should have start for repo-b") + + wm.Stop() +} + +func TestPipeline4_WatcherIgnoresGitDir(t *testing.T) { + env, wm, slug := setupWatcherEnv(t, "Watcher Gitignore") + + // Write files inside .git/ — these should be ignored + env.TouchFile("repo", ".git/test-ignored.txt") + env.TouchFile("repo", ".git/refs/test.txt") + + // Wait longer than the idle threshold + time.Sleep(waitForIdle) + + starts := env.ReadActivityStartEntries(slug) + stops := env.ReadActivityStopEntries(slug) + + assert.Empty(t, starts, "changes in .git/ should not trigger activity_start") + assert.Empty(t, stops, "changes in .git/ should not trigger activity_stop") + + // Positive control: touch a non-.git file to prove the daemon is alive and watching + env.TouchFiles("repo", 3) + time.Sleep(waitForIdle) + env.WaitForActivityEntries(slug, 1, 1, entryTimeout) + + wm.Stop() +} diff --git a/tests/e2e/reflog_test.go b/tests/e2e/reflog_test.go new file mode 100644 index 0000000..0a98e6f --- /dev/null +++ b/tests/e2e/reflog_test.go @@ -0,0 +1,95 @@ +package e2e + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// ReflogBuilder constructs simulated git reflog entries that can be written +// directly to .git/logs/HEAD. Git validates that SHAs reference real objects, +// so the builder uses SHAs from the repo's pre-created seed commits. +type ReflogBuilder struct { + entries []rawReflogEntry + repo *TestRepo + shaIndex int // cycles through repo.SHAs +} + +type rawReflogEntry struct { + oldSHA string + newSHA string + timestamp time.Time + action string // e.g. "checkout: moving from master to feature" or "commit: add feature" +} + +// NewReflogBuilder creates a new builder for simulated reflog entries. +// The repo must have been created with AddRepo (which pre-creates seed commits with valid SHAs). +func NewReflogBuilder(repo *TestRepo) *ReflogBuilder { + return &ReflogBuilder{ + repo: repo, + shaIndex: 0, + } +} + +// nextSHA returns the next available real SHA from the repo's seed commits. +func (b *ReflogBuilder) nextSHA() string { + sha := b.repo.SHAs[b.shaIndex%len(b.repo.SHAs)] + b.shaIndex++ + return sha +} + +// Checkout adds a simulated branch checkout to the reflog. +func (b *ReflogBuilder) Checkout(from, to string, at time.Time) *ReflogBuilder { + oldSHA := b.nextSHA() + newSHA := b.nextSHA() + + b.entries = append(b.entries, rawReflogEntry{ + oldSHA: oldSHA, + newSHA: newSHA, + timestamp: at, + action: fmt.Sprintf("checkout: moving from %s to %s", from, to), + }) + return b +} + +// Commit adds a simulated commit entry to the reflog. +func (b *ReflogBuilder) Commit(message string, at time.Time) *ReflogBuilder { + oldSHA := b.nextSHA() + newSHA := b.nextSHA() + + b.entries = append(b.entries, rawReflogEntry{ + oldSHA: oldSHA, + newSHA: newSHA, + timestamp: at, + action: fmt.Sprintf("commit: %s", message), + }) + return b +} + +// WriteTo writes the simulated reflog entries to the repo's .git/logs/HEAD file. +// Entries are appended after any existing content (e.g., the seed commits from AddRepo). +func (b *ReflogBuilder) WriteTo(t *testing.T) { + t.Helper() + + logsDir := filepath.Join(b.repo.Dir, ".git", "logs") + require.NoError(t, os.MkdirAll(logsDir, 0755)) + + headLog := filepath.Join(logsDir, "HEAD") + + f, err := os.OpenFile(headLog, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + for _, e := range b.entries { + // Raw reflog format: + // old_sha new_sha Author unix_timestamp timezone\taction + line := fmt.Sprintf("%s %s E2E Test %d +0000\t%s\n", + e.oldSHA, e.newSHA, e.timestamp.Unix(), e.action) + _, err := f.WriteString(line) + require.NoError(t, err) + } +} diff --git a/tests/e2e/watcher_helpers_test.go b/tests/e2e/watcher_helpers_test.go new file mode 100644 index 0000000..b0e0b58 --- /dev/null +++ b/tests/e2e/watcher_helpers_test.go @@ -0,0 +1,148 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/watch" + "github.com/stretchr/testify/require" +) + +// WatcherManager manages a hourgit watch daemon process for e2e tests. +type WatcherManager struct { + env *TestEnv + cmd *exec.Cmd + stopped bool +} + +// StartWatcher starts the hourgit watch daemon as a background process. +// The daemon uses the test env's HOME dir and a short idle threshold. +func (env *TestEnv) StartWatcher(thresholdSeconds int) *WatcherManager { + env.T.Helper() + + cmd := exec.Command(binaryPath, "--skip-updates", "watch") + cmd.Env = append(filterHostEnv(), + "HOME="+env.HomeDir, + fmt.Sprintf("HOURGIT_IDLE_THRESHOLD=%d", thresholdSeconds), + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + require.NoError(env.T, cmd.Start(), "failed to start watcher daemon") + + wm := &WatcherManager{env: env, cmd: cmd} + + // Register cleanup to ensure process is killed if test fails + env.T.Cleanup(func() { + wm.Stop() + }) + + // Wait for PID file to appear (daemon is ready) + wm.waitForPID() + + return wm +} + +// Stop gracefully stops the watcher daemon via SIGTERM. +func (wm *WatcherManager) Stop() { + if wm.stopped { + return + } + wm.stopped = true + + if wm.cmd.Process == nil { + return + } + + // Send SIGTERM for graceful shutdown + _ = wm.cmd.Process.Signal(syscall.SIGTERM) + + // Wait with timeout + done := make(chan error, 1) + go func() { done <- wm.cmd.Wait() }() + + select { + case <-done: + case <-time.After(10 * time.Second): + // Force kill if graceful shutdown takes too long + _ = wm.cmd.Process.Kill() + <-done + } +} + +// Kill forcefully kills the watcher daemon (simulates crash). +// The PID file and unpaired activity entries will remain. +func (wm *WatcherManager) Kill() { + if wm.stopped { + return + } + wm.stopped = true + + if wm.cmd.Process == nil { + return + } + + _ = wm.cmd.Process.Kill() + _ = wm.cmd.Wait() +} + +// waitForPID polls until the daemon's PID file appears. +func (wm *WatcherManager) waitForPID() { + wm.env.T.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + running, _, err := watch.IsDaemonRunning(wm.env.HomeDir) + if err == nil && running { + return + } + time.Sleep(100 * time.Millisecond) + } + wm.env.T.Fatal("watcher daemon did not start within 10 seconds") +} + +// WaitForActivityEntries polls until the expected number of activity entries appear. +func (env *TestEnv) WaitForActivityEntries(slug string, wantStarts, wantStops int, timeout time.Duration) ([]entry.ActivityStartEntry, []entry.ActivityStopEntry) { + env.T.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + starts, _ := entry.ReadAllActivityStartEntries(env.HomeDir, slug) + stops, _ := entry.ReadAllActivityStopEntries(env.HomeDir, slug) + if len(starts) >= wantStarts && len(stops) >= wantStops { + return starts, stops + } + time.Sleep(200 * time.Millisecond) + } + + // Final read for error message + starts, _ := entry.ReadAllActivityStartEntries(env.HomeDir, slug) + stops, _ := entry.ReadAllActivityStopEntries(env.HomeDir, slug) + env.T.Fatalf("timed out waiting for activity entries: got %d starts (want %d), %d stops (want %d)", + len(starts), wantStarts, len(stops), wantStops) + return nil, nil +} + +// TouchFile creates or modifies a file in a repo to trigger fsnotify events. +func (env *TestEnv) TouchFile(repoName, filename string) { + env.T.Helper() + repo := env.Repos[repoName] + require.NotNil(env.T, repo, "repo %q not found", repoName) + + path := filepath.Join(repo.Dir, filename) + require.NoError(env.T, os.MkdirAll(filepath.Dir(path), 0755)) + n := fileCounter.Add(1) + require.NoError(env.T, os.WriteFile(path, []byte(fmt.Sprintf("touch %d\n", n)), 0644)) +} + +// TouchFiles creates multiple files in quick succession to simulate an activity burst. +func (env *TestEnv) TouchFiles(repoName string, count int) { + env.T.Helper() + for i := 0; i < count; i++ { + env.TouchFile(repoName, fmt.Sprintf("activity-%d.txt", fileCounter.Add(1))) + time.Sleep(50 * time.Millisecond) // small gap between writes + } +} diff --git a/web/docs/commands/project-management.md b/web/docs/commands/project-management.md index db8eea7..65027a4 100644 --- a/web/docs/commands/project-management.md +++ b/web/docs/commands/project-management.md @@ -33,14 +33,14 @@ hourgit project assign [PROJECT] [--project ] [--force] [--yes] Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode. ```bash -hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-threshold ] [--project ] [--yes] +hourgit project edit [PROJECT] [--name ] [--mode ] [--idle-threshold ] [--project ] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `-n`, `--name` | — | New project name | | `-m`, `--mode` | — | New tracking mode: `standard` or `precise` | -| `-t`, `--idle-threshold` | — | Idle threshold in minutes (precise mode only) | +| `-t`, `--idle-threshold` | — | Idle threshold in seconds (precise mode only) | | `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) | | `-y`, `--yes` | `false` | Skip confirmation prompt | diff --git a/web/docs/configuration.md b/web/docs/configuration.md index 5f8c073..d6b4c84 100644 --- a/web/docs/configuration.md +++ b/web/docs/configuration.md @@ -41,7 +41,7 @@ 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. +The idle threshold defaults to 600 seconds (10 minutes) — after 600 seconds 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