Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .task/checksum/docs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
f998a0f05834950ec6719dfc2c4a6c95
fcf9aafe3e5b2b316a1117c324ae5b2f
99 changes: 90 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ Manual logging is supported for non-code work (research, analysis, meetings) via
- [Quick Start](#quick-start)
- [Commands](#commands)
- [Time Tracking](#time-tracking) — init, log, edit, remove, sync, report, history, status
- [Project Management](#project-management) — project add/assign/list/remove
- [Project Management](#project-management) — project add/assign/edit/list/remove
- [Schedule Configuration](#schedule-configuration) — config get/set/reset/report
- [Default Schedule](#default-schedule) — defaults get/set/reset/report
- [Shell Completions](#shell-completions) — completion install/generate
- [Other](#other) — version
- [Other](#other) — version, watch
- [Precise Mode](#precise-mode)
- [Configuration](#configuration)
- [Data Storage](#data-storage)
- [Roadmap](#roadmap)
Expand Down Expand Up @@ -117,14 +118,15 @@ Commands: `init` · `log` · `edit` · `remove` · `sync` · `report` · `histor
Initialize Hourgit in the current git repository by installing a post-checkout hook.

```bash
hourgit init [--project <name>] [--force] [--merge] [--yes]
hourgit init [--project <name>] [--mode <mode>] [--force] [--append] [--yes]
```

| Flag | Default | Description |
|------|---------|-------------|
| `-p`, `--project` | auto-detect | Assign repository to a project by name or ID (creates if needed) |
| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) |
| `-f`, `--force` | `false` | Overwrite existing post-checkout hook |
| `-m`, `--merge` | `false` | Append to existing post-checkout hook |
| `-a`, `--append` | `false` | Append to existing post-checkout hook |
| `-y`, `--yes` | `false` | Skip confirmation prompt |

#### `hourgit log`
Expand Down Expand Up @@ -300,22 +302,25 @@ hourgit status [--project <name>]
- Time logged today and remaining scheduled hours
- Today's schedule windows
- Tracking state (active/inactive based on current time vs schedule)
- Watcher state (when precise mode is enabled: active/stopped)

### Project Management

Group repositories into projects for organized time tracking.

Commands: `project add` · `project assign` · `project list` · `project remove`
Commands: `project add` · `project assign` · `project edit` · `project list` · `project remove`

#### `hourgit project add`

Create a new project.

```bash
hourgit project add <name>
hourgit project add <name> [--mode <mode>]
```

No flags.
| Flag | Default | Description |
|------|---------|-------------|
| `-m`, `--mode` | `standard` | Tracking mode: `standard` or `precise` (enables filesystem watcher for idle detection) |

#### `hourgit project assign`

Expand All @@ -330,6 +335,34 @@ hourgit project assign <name> [--force] [--yes]
| `-f`, `--force` | `false` | Reassign repository to a different project |
| `-y`, `--yes` | `false` | Skip confirmation prompt |

#### `hourgit project edit`

Edit an existing project's name or tracking mode. When edit flags are provided, only those changes are applied directly. Without flags, an interactive editor prompts for both name and mode.

```bash
hourgit project edit [PROJECT] [--name <new_name>] [--mode <mode>] [--idle-threshold <minutes>] [--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) |
| `-p`, `--project` | auto-detect | Project name or ID (alternative to positional argument) |
| `-y`, `--yes` | `false` | Skip confirmation prompt |

> `PROJECT` is an optional positional argument (project name or ID). Falls back to `--project` flag or the current repository's project.

**Examples**

```bash
hourgit project edit myproject --name newname
hourgit project edit myproject --mode precise
hourgit project edit myproject --idle-threshold 15
hourgit project edit --name newname --project myproject
hourgit project edit myproject # interactive mode
```

#### `hourgit project list`

List all projects and their repositories.
Expand Down Expand Up @@ -505,7 +538,7 @@ hourgit completion generate fish | source

### Other

Commands: `version` · `update`
Commands: `version` · `update` · `watch`

#### `hourgit version`

Expand All @@ -527,6 +560,52 @@ hourgit update

No flags.

#### `hourgit watch`

Run the filesystem watcher daemon in the foreground. The daemon monitors file changes in repositories with precise mode enabled and writes activity entries to detect idle gaps. Normally managed automatically as an OS service — use this command for debugging or manual operation.

```bash
hourgit watch
```

No flags.

### Global Flags

These flags are available on all commands.

| Flag | Description |
|------|-------------|
| `--skip-updates` | Skip the automatic update check |
| `--skip-watcher` | Skip the file watcher health check |

## Precise Mode

By default, Hourgit attributes all time between branch checkouts (within your schedule) as work. **Precise mode** adds filesystem-level idle detection: a background daemon watches your repository for file changes and records when you stop and resume working.

### How it works

1. A background daemon watches file changes in your repository (excluding `.git/` and `.gitignore` patterns).
2. After a configurable idle threshold (default: 10 minutes) with no file changes, the daemon records an `activity_stop` entry.
3. When file changes resume, the daemon records an `activity_start` entry.
4. At report time, these idle gaps are trimmed from checkout sessions, giving you more accurate time attribution.

### Enabling precise mode

```bash
# During init
hourgit init --mode precise

# When adding a project
hourgit project add myproject --mode precise
```

When precise mode is enabled, Hourgit automatically installs a user-level OS service (launchd on macOS, systemd on Linux, Task Scheduler on Windows) to run the watcher daemon. No `sudo` required.

### Health checks

Hourgit checks whether the watcher daemon is running on every command. If it's stopped, you'll be prompted to restart it. The `status` command shows the current watcher state when precise mode is enabled.

## Configuration

Hourgit uses a schedule system to define working hours. The factory default is **Monday-Friday, 9 AM - 5 PM**.
Expand All @@ -551,7 +630,9 @@ Every project starts with a copy of the defaults. You can then customize a proje
|------|---------|
| `~/.hourgit/config.json` | Global config — defaults, projects (id, name, slug, repos, schedules) |
| `REPO/.git/.hourgit` | Per-repo project assignment (project name + project ID) |
| `~/.hourgit/<slug>/<hash>` | Per-project entries (one JSON file per entry — log, checkout, or submit marker) |
| `~/.hourgit/<slug>/<hash>` | Per-project entries (one JSON file per entry — log, checkout, commit, submit, activity_stop, activity_start) |
| `~/.hourgit/watch.pid` | PID file for the filesystem watcher daemon (precise mode) |
| `~/.hourgit/watch.state` | Watcher state file — last activity timestamps per repo (precise mode) |

## Roadmap

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/f-amaral/go-async v0.3.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg=
github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
Expand Down
Binary file removed hourgit-2026-month-03.pdf
Binary file not shown.
40 changes: 32 additions & 8 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/Flyrell/hourgit/internal/project"
"github.com/Flyrell/hourgit/internal/watch"
"github.com/spf13/cobra"
)

Expand All @@ -20,7 +21,7 @@ func hookScript(binPath, version string) string {
# Skip if old and new HEAD are the same SHA (e.g. pull, fetch)
[ "$1" = "$2" ] && exit 0

%s sync --skip-updates 2>/dev/null || true
%s sync --skip-updates --skip-watcher 2>/dev/null || true
`, project.HookMarker, version, binPath)
}

Expand All @@ -29,10 +30,11 @@ var initCmd = LeafCommand{
Short: "Initialize hourgit in a git repository",
StrFlags: []StringFlag{
{Name: "project", Shorthand: "p", Usage: "assign repository to a project by name or ID (creates if needed)"},
{Name: "mode", Shorthand: "m", Usage: "tracking mode: standard or precise (default: standard)"},
},
BoolFlags: []BoolFlag{
{Name: "force", Shorthand: "f", Usage: "overwrite existing post-checkout hook"},
{Name: "merge", Shorthand: "m", Usage: "append to existing post-checkout hook"},
{Name: "append", Shorthand: "a", Usage: "append to existing post-checkout hook"},
{Name: "yes", Shorthand: "y", Usage: "skip confirmation prompt"},
},
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -42,8 +44,9 @@ var initCmd = LeafCommand{
}

projectName, _ := cmd.Flags().GetString("project")
modeFlag, _ := cmd.Flags().GetString("mode")
force, _ := cmd.Flags().GetBool("force")
merge, _ := cmd.Flags().GetBool("merge")
appendHook, _ := cmd.Flags().GetBool("append")
yes, _ := cmd.Flags().GetBool("yes")

homeDir, err := os.UserHomeDir()
Expand All @@ -63,11 +66,19 @@ var initCmd = LeafCommand{
confirm := ResolveConfirmFunc(yes)
selectFn := ResolveSelectFunc(yes)

return runInit(cmd, dir, homeDir, projectName, force, merge, binPath, confirm, selectFn)
return runInit(cmd, dir, homeDir, projectName, modeFlag, force, appendHook, binPath, confirm, selectFn)
},
}.Build()

func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error {
func runInit(cmd *cobra.Command, dir, homeDir, projectName, mode string, force, appendHook bool, binPath string, confirm ConfirmFunc, selectFn SelectFunc) error {
if err := validateMode(mode); err != nil {
return err
}

if mode == "precise" && projectName == "" {
return fmt.Errorf("--mode precise requires --project")
}

gitDir := filepath.Join(dir, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return fmt.Errorf("not a git repository")
Expand All @@ -84,11 +95,11 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge
return fmt.Errorf("hourgit is already initialized")
}

if !force && !merge {
return fmt.Errorf("post-checkout hook already exists (use --force to overwrite or --merge to append)")
if !force && !appendHook {
return fmt.Errorf("post-checkout hook already exists (use --force to overwrite or --append to append)")
}

if merge {
if appendHook {
merged := content + "\n" + hook
if err := os.WriteFile(hookPath, []byte(merged), 0755); err != nil {
return err
Expand Down Expand Up @@ -135,6 +146,19 @@ func runInit(cmd *cobra.Command, dir, homeDir, projectName string, force, merge
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", Text(fmt.Sprintf("project '%s' created (%s)", Primary(result.Entry.Name), Silent(result.Entry.ID))))
}

if mode == "precise" {
if err := project.SetPreciseMode(homeDir, result.Entry.ID, true); err != nil {
return err
}
if err := project.SetIdleThreshold(homeDir, result.Entry.ID, project.DefaultIdleThresholdMinutes); err != nil {
return err
}
if err := watch.EnsureWatcherService(homeDir, binPath); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n",
Warning(fmt.Sprintf("warning: could not configure watcher service: %s", err)))
}
}

if err := project.AssignProject(homeDir, dir, result.Entry); err != nil {
return err
}
Expand Down
Loading
Loading