Skip to content
Open
125 changes: 125 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,14 @@ hourgit project assign [PROJECT] [--project <name>] [--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 <new_name>] [--mode <mode>] [--idle-threshold <minutes>] [--project <name>] [--yes]
hourgit project edit [PROJECT] [--name <new_name>] [--mode <mode>] [--idle-threshold <seconds>] [--project <name>] [--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 |

Expand All @@ -359,7 +359,7 @@ hourgit project edit [PROJECT] [--name <new_name>] [--mode <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
```
Expand Down Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/project_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/project_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
12 changes: 6 additions & 6 deletions internal/cli/project_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
34 changes: 17 additions & 17 deletions internal/cli/project_edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/entry/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading