diff --git a/.gitignore b/.gitignore index f22e357..71c8a28 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ vendor/ # Generated docs web/docs/*.html +!web/docs/_template.html web/docs/commands/*.html # Agents diff --git a/.task/checksum/docs b/.task/checksum/docs index 47c73bb..db37458 100644 --- a/.task/checksum/docs +++ b/.task/checksum/docs @@ -1 +1 @@ -1a9df1fa4242917b6b61be3f7a0b62e +b47ffd14ec64cbc5fff8aa93bffea04d diff --git a/README.md b/README.md index cd0b429..0389cc8 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ hourgit version ## Table of Contents - [Commands](#commands) - - [Time Tracking](#time-tracking) — init, log, edit, remove, checkout, report, history + - [Time Tracking](#time-tracking) — init, log, edit, remove, sync, report, history - [Project Management](#project-management) — project add/assign/list/remove - [Schedule Configuration](#schedule-configuration) — config get/set/reset/report - [Default Schedule](#default-schedule) — defaults get/set/reset/report @@ -106,7 +106,7 @@ hourgit version Core commands for recording, viewing, and managing your time entries. -Commands: `init` · `log` · `edit` · `remove` · `checkout` · `report` · `history` +Commands: `init` · `log` · `edit` · `remove` · `sync` · `report` · `history` #### `hourgit init` @@ -204,18 +204,16 @@ hourgit remove [--project ] [--yes] > Works with both log and checkout entries (unlike `edit`, which only supports log entries). Shows entry details and asks for confirmation before deleting. If the entry is not found in the current repo's project, all projects are searched. -#### `hourgit checkout` +#### `hourgit sync` -Record a branch checkout event. Called internally by the post-checkout git hook to track branch transitions. +Sync branch checkouts from git reflog. Called automatically by the post-checkout hook, or run manually to backfill history. ```bash -hourgit checkout --prev --next [--project ] +hourgit sync [--project ] ``` | Flag | Default | Description | |------|---------|-------------| -| `--prev` | — | Previous branch name (required) | -| `--next` | — | Next branch name (required) | | `--project` | auto-detect | Project name or ID | #### `hourgit report` @@ -480,6 +478,8 @@ hourgit completion generate fish | source ### Other +Commands: `version` · `update` + #### `hourgit version` Print version information. @@ -490,6 +490,16 @@ hourgit version No flags. +#### `hourgit update` + +Check for and install updates. Always checks the latest version from GitHub, bypassing the cache TTL used by the automatic update check. + +```bash +hourgit update +``` + +No flags. + ## Configuration Hourgit uses a schedule system to define working hours. The factory default is **Monday-Friday, 9 AM - 5 PM**. diff --git a/internal/cli/checkout.go b/internal/cli/checkout.go deleted file mode 100644 index 28f162e..0000000 --- a/internal/cli/checkout.go +++ /dev/null @@ -1,73 +0,0 @@ -package cli - -import ( - "fmt" - "time" - - "github.com/Flyrell/hourgit/internal/entry" - "github.com/Flyrell/hourgit/internal/hashutil" - "github.com/spf13/cobra" -) - -var checkoutCmd = LeafCommand{ - Use: "checkout", - Short: "Record a branch checkout (used by the post-checkout hook)", - StrFlags: []StringFlag{ - {Name: "prev", Usage: "previous branch name"}, - {Name: "next", Usage: "next branch name"}, - {Name: "project", Usage: "project name or ID (auto-detected from repo if omitted)"}, - }, - RunE: func(cmd *cobra.Command, args []string) error { - homeDir, repoDir, err := getContextPaths() - if err != nil { - return err - } - - projectFlag, _ := cmd.Flags().GetString("project") - prevFlag, _ := cmd.Flags().GetString("prev") - nextFlag, _ := cmd.Flags().GetString("next") - - return runCheckout(cmd, homeDir, repoDir, projectFlag, prevFlag, nextFlag, time.Now) - }, -}.Build() - -func runCheckout( - cmd *cobra.Command, - homeDir, repoDir, projectFlag, prev, next string, - nowFn func() time.Time, -) error { - if prev == "" { - return fmt.Errorf("--prev is required") - } - if next == "" { - return fmt.Errorf("--next is required") - } - if prev == next { - return nil // silent no-op, known benign case from hook - } - - proj, err := ResolveProjectContext(homeDir, repoDir, projectFlag) - if err != nil { - return err - } - - e := entry.CheckoutEntry{ - ID: hashutil.GenerateID("checkout"), - Timestamp: nowFn().UTC(), - Previous: prev, - Next: next, - } - - if err := entry.WriteCheckoutEntry(homeDir, proj.Slug, e); err != nil { - return err - } - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "checkout %s → %s for project '%s' (%s)\n", - Primary(prev), - Primary(next), - Primary(proj.Name), - Silent(e.ID), - ) - - return nil -} diff --git a/internal/cli/checkout_test.go b/internal/cli/checkout_test.go deleted file mode 100644 index daac009..0000000 --- a/internal/cli/checkout_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package cli - -import ( - "bytes" - "os" - "path/filepath" - "testing" - "time" - - "github.com/Flyrell/hourgit/internal/entry" - "github.com/Flyrell/hourgit/internal/project" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupCheckoutTest(t *testing.T) (homeDir string, repoDir string, proj *project.ProjectEntry) { - t.Helper() - homeDir = t.TempDir() - repoDir = t.TempDir() - - require.NoError(t, os.MkdirAll(filepath.Join(repoDir, ".git"), 0755)) - - proj, err := project.CreateProject(homeDir, "Checkout Test") - require.NoError(t, err) - require.NoError(t, project.AssignProject(homeDir, repoDir, proj)) - - cfg, err := project.ReadConfig(homeDir) - require.NoError(t, err) - proj = project.FindProjectByID(cfg, proj.ID) - - return homeDir, repoDir, proj -} - -func execCheckout(homeDir, repoDir, projectFlag, prev, next string) (string, error) { - stdout := new(bytes.Buffer) - cmd := checkoutCmd - cmd.SetOut(stdout) - - err := runCheckout(cmd, homeDir, repoDir, projectFlag, prev, next, fixedNow) - return stdout.String(), err -} - -func TestCheckoutBasic(t *testing.T) { - homeDir, repoDir, proj := setupCheckoutTest(t) - - stdout, err := execCheckout(homeDir, repoDir, "", "main", "feature-x") - - require.NoError(t, err) - assert.Contains(t, stdout, "checkout") - assert.Contains(t, stdout, "main") - assert.Contains(t, stdout, "feature-x") - assert.Contains(t, stdout, "Checkout Test") - - entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) - require.NoError(t, err) - assert.Len(t, entries, 1) - assert.Equal(t, "main", entries[0].Previous) - assert.Equal(t, "feature-x", entries[0].Next) -} - -func TestCheckoutByProjectFlag(t *testing.T) { - homeDir, _, proj := setupCheckoutTest(t) - - stdout, err := execCheckout(homeDir, "", proj.Name, "main", "develop") - - require.NoError(t, err) - assert.Contains(t, stdout, "Checkout Test") - - entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) - require.NoError(t, err) - assert.Len(t, entries, 1) -} - -func TestCheckoutMissingPrev(t *testing.T) { - homeDir, repoDir, _ := setupCheckoutTest(t) - - _, err := execCheckout(homeDir, repoDir, "", "", "feature-x") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "--prev is required") -} - -func TestCheckoutMissingNext(t *testing.T) { - homeDir, repoDir, _ := setupCheckoutTest(t) - - _, err := execCheckout(homeDir, repoDir, "", "main", "") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "--next is required") -} - -func TestCheckoutNoProject(t *testing.T) { - homeDir := t.TempDir() - - _, err := execCheckout(homeDir, "", "", "main", "feature-x") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "no project found") -} - -func TestCheckoutTimestamp(t *testing.T) { - homeDir, repoDir, proj := setupCheckoutTest(t) - - _, err := execCheckout(homeDir, repoDir, "", "main", "feature-x") - require.NoError(t, err) - - entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) - require.NoError(t, err) - require.Len(t, entries, 1) - - expected := time.Date(2025, 6, 15, 14, 0, 0, 0, time.UTC) - assert.Equal(t, expected, entries[0].Timestamp) -} - -func TestCheckoutSameBranch(t *testing.T) { - homeDir, repoDir, proj := setupCheckoutTest(t) - - stdout, err := execCheckout(homeDir, repoDir, "", "main", "main") - - require.NoError(t, err) - assert.Empty(t, stdout) // silent no-op - - entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) - require.NoError(t, err) - assert.Len(t, entries, 0) // no entry written -} - -func TestCheckoutRegisteredAsSubcommand(t *testing.T) { - root := newRootCmd() - names := make([]string, len(root.Commands())) - for i, cmd := range root.Commands() { - names[i] = cmd.Name() - } - assert.Contains(t, names, "checkout") -} diff --git a/internal/cli/init.go b/internal/cli/init.go index e91b63e..0833ef7 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -17,16 +17,10 @@ func hookScript(binPath, version string) string { # Only act on branch checkouts (flag=1), skip file checkouts (flag=0) [ "$3" = "0" ] && exit 0 -# Skip if old and new HEAD are the same SHA (e.g. pull, fetch, rebase) +# Skip if old and new HEAD are the same SHA (e.g. pull, fetch) [ "$1" = "$2" ] && exit 0 -# Resolve branch names from SHA refs provided by git ($1=prev HEAD, $2=new HEAD) -PREV=$(git name-rev --name-only --refs='refs/heads/*' "$1" 2>/dev/null) -NEXT=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) - -if [ -n "$PREV" ] && [ -n "$NEXT" ] && [ "$PREV" != "$NEXT" ]; then - %s checkout --prev "$PREV" --next "$NEXT" 2>/dev/null || true -fi +%s sync --skip-updates 2>/dev/null || true `, project.HookMarker, version, binPath) } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index db414a9..5185ac3 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -65,7 +65,7 @@ func TestInitInGitRepo(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(content), project.HookMarker) assert.Contains(t, string(content), "#!/bin/sh") - assert.Contains(t, string(content), `checkout --prev "$PREV" --next "$NEXT"`) + assert.Contains(t, string(content), "sync") assert.Contains(t, string(content), `[ "$3" = "0" ] && exit 0`) info, err := os.Stat(hookPath) @@ -377,11 +377,14 @@ func TestHookScript(t *testing.T) { assert.Contains(t, script, "#!/bin/sh") assert.Contains(t, script, project.HookMarker) assert.Contains(t, script, "(version: 1.2.3)") - assert.Contains(t, script, `/usr/local/bin/hourgit checkout --prev "$PREV" --next "$NEXT"`) + assert.Contains(t, script, `/usr/local/bin/hourgit sync --skip-updates`) assert.Contains(t, script, `[ "$3" = "0" ] && exit 0`) assert.Contains(t, script, `[ "$1" = "$2" ] && exit 0`) - assert.Contains(t, script, `git name-rev --name-only --refs='refs/heads/*'`) - assert.Contains(t, script, `git rev-parse --abbrev-ref HEAD`) + assert.NotContains(t, script, `checkout --prev`) + assert.NotContains(t, script, `git name-rev`) + assert.NotContains(t, script, `git symbolic-ref`) + assert.NotContains(t, script, `git rev-parse --git-dir`) + assert.NotContains(t, script, `rebase-merge`) } func TestInitRegistered(t *testing.T) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 1f4dd6a..b487a55 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,7 +17,7 @@ func newRootCmd() *cobra.Command { logCmd, editCmd, removeCmd, - checkoutCmd, + syncCmd, reportCmd, historyCmd, versionCmd, @@ -25,12 +25,18 @@ func newRootCmd() *cobra.Command { configCmd, defaultsCmd, completionCmd, + updateCmd, }, }.Build() cmd.SilenceUsage = true cmd.SilenceErrors = true cmd.CompletionOptions.DisableDefaultCmd = true cmd.SetHelpFunc(colorizedHelpFunc()) + cmd.PersistentFlags().Bool("skip-updates", false, "skip the automatic update check") + cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + checkForUpdate(cmd, defaultUpdateDeps()) + return nil + } return cmd } diff --git a/internal/cli/sync.go b/internal/cli/sync.go new file mode 100644 index 0000000..d97ca0b --- /dev/null +++ b/internal/cli/sync.go @@ -0,0 +1,165 @@ +package cli + +import ( + "fmt" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/hashutil" + "github.com/Flyrell/hourgit/internal/project" + "github.com/Flyrell/hourgit/internal/reflog" + "github.com/spf13/cobra" +) + +// GitReflogFunc executes git reflog and returns its output. +// The since parameter is non-nil when LastSync is available. +type GitReflogFunc func(repoDir string, since *time.Time) (string, error) + +// defaultGitReflog runs git reflog in the given repo directory. +func defaultGitReflog(repoDir string, since *time.Time) (string, error) { + args := []string{"-C", repoDir, "reflog", "--date=iso"} + if since != nil { + args = append(args, fmt.Sprintf("--since=%s", since.Format("2006-01-02 15:04:05"))) + } + out, err := exec.Command("git", args...).Output() + if err != nil { + return "", err + } + return string(out), nil +} + +var syncCmd = LeafCommand{ + Use: "sync", + Short: "Sync branch checkouts from git reflog", + StrFlags: []StringFlag{ + {Name: "project", Usage: "project name or ID (auto-detected from repo if omitted)"}, + }, + RunE: func(cmd *cobra.Command, args []string) error { + homeDir, repoDir, err := getContextPaths() + if err != nil { + return err + } + + projectFlag, _ := cmd.Flags().GetString("project") + + return runSync(cmd, homeDir, repoDir, projectFlag, defaultGitReflog) + }, +}.Build() + +// commitHashPattern matches full or abbreviated commit hashes (7-40 hex chars). +var commitHashPattern = regexp.MustCompile(`^[0-9a-f]{7,40}$`) + +func looksLikeCommitHash(name string) bool { + return commitHashPattern.MatchString(name) +} + +func runSync( + cmd *cobra.Command, + homeDir, repoDir, projectFlag string, + gitReflog GitReflogFunc, +) error { + proj, err := ResolveProjectContext(homeDir, repoDir, projectFlag) + if err != nil { + return err + } + + // Read repo config to get LastSync + repoCfg, err := project.ReadRepoConfig(repoDir) + if err != nil { + return err + } + + var lastSync *time.Time + if repoCfg != nil { + lastSync = repoCfg.LastSync + } + + // Get reflog output + output, err := gitReflog(repoDir, lastSync) + if err != nil { + return fmt.Errorf("failed to read git reflog: %w", err) + } + + // Parse reflog + records := reflog.ParseReflog(output) + + // Build known IDs set from existing checkout entries + existingEntries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + if err != nil { + return err + } + knownIDs := make(map[string]bool, len(existingEntries)) + for _, e := range existingEntries { + knownIDs[e.ID] = true + } + + // Process records oldest-first + var created int + var newestTimestamp time.Time + for i := len(records) - 1; i >= 0; i-- { + rec := records[i] + + // Skip detached HEAD (branch name looks like a commit hash) + if looksLikeCommitHash(rec.Previous) || looksLikeCommitHash(rec.Next) { + continue + } + + // Skip remote refs + if strings.Contains(rec.Previous, "remotes/") || strings.Contains(rec.Next, "remotes/") { + continue + } + + // Skip same-branch + if rec.Previous == rec.Next { + continue + } + + // Generate deterministic ID + seed := rec.CommitRef + rec.Timestamp.Format(time.RFC3339) + rec.Previous + rec.Next + id := hashutil.GenerateIDFromSeed(seed) + + // Skip already-synced entries (dedup by ID) + if knownIDs[id] { + continue + } + + e := entry.CheckoutEntry{ + ID: id, + Timestamp: rec.Timestamp, + Previous: rec.Previous, + Next: rec.Next, + CommitRef: rec.CommitRef, + } + + if err := entry.WriteCheckoutEntry(homeDir, proj.Slug, e); err != nil { + return err + } + + knownIDs[id] = true + created++ + + if rec.Timestamp.After(newestTimestamp) { + newestTimestamp = rec.Timestamp + } + } + + // Update LastSync to the newest processed record's timestamp + if created > 0 && repoCfg != nil && !newestTimestamp.IsZero() { + repoCfg.LastSync = &newestTimestamp + if err := project.WriteRepoConfig(repoDir, repoCfg); err != nil { + return err + } + } + + if created == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), Text("already up to date")) + } else { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", + Text(fmt.Sprintf("synced %d checkout(s) for project '%s'", created, Primary(proj.Name)))) + } + + return nil +} diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go new file mode 100644 index 0000000..49eb27f --- /dev/null +++ b/internal/cli/sync_test.go @@ -0,0 +1,323 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/project" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupSyncTest(t *testing.T) (homeDir string, repoDir string, proj *project.ProjectEntry) { + t.Helper() + homeDir = t.TempDir() + repoDir = t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, ".git"), 0755)) + + proj, err := project.CreateProject(homeDir, "Sync Test") + require.NoError(t, err) + require.NoError(t, project.AssignProject(homeDir, repoDir, proj)) + + cfg, err := project.ReadConfig(homeDir) + require.NoError(t, err) + proj = project.FindProjectByID(cfg, proj.ID) + + return homeDir, repoDir, proj +} + +func fakeReflog(output string) GitReflogFunc { + return func(repoDir string, since *time.Time) (string, error) { + return output, nil + } +} + +func fakeReflogWithSinceCheck(output string, sinceCalled *bool) GitReflogFunc { + return func(repoDir string, since *time.Time) (string, error) { + if since != nil { + *sinceCalled = true + } + return output, nil + } +} + +func execSync(homeDir, repoDir, projectFlag string, gitReflog GitReflogFunc) (string, error) { + stdout := new(bytes.Buffer) + cmd := syncCmd + cmd.SetOut(stdout) + + err := runSync(cmd, homeDir, repoDir, projectFlag, gitReflog) + return stdout.String(), err +} + +func TestSyncBasic(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "synced 1 checkout(s)") + assert.Contains(t, stdout, "Sync Test") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "main", entries[0].Previous) + assert.Equal(t, "feature-x", entries[0].Next) + assert.Equal(t, "abc1234", entries[0].CommitRef) +} + +func TestSyncDeduplication(t *testing.T) { + homeDir, repoDir, _ := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + // First sync + _, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + require.NoError(t, err) + + // Second sync with same data + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + require.NoError(t, err) + assert.Contains(t, stdout, "already up to date") +} + +func TestSyncIdempotentIDs(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + _, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + require.NoError(t, err) + + entries1, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + require.Len(t, entries1, 1) + id1 := entries1[0].ID + + // Delete and re-sync — should produce the same ID + require.NoError(t, entry.DeleteEntry(homeDir, proj.Slug, id1)) + + _, err = execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + require.NoError(t, err) + + entries2, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + require.Len(t, entries2, 1) + assert.Equal(t, id1, entries2[0].ID) +} + +func TestSyncSkipsDetachedHead(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from a1b2c3d to feature-x +def5678 HEAD@{2025-06-15 14:00:00 +0000}: checkout: moving from main to a1b2c3d` + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "already up to date") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 0) +} + +func TestSyncSkipsRemoteRefs(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from remotes/origin/main to feature-x` + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "already up to date") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 0) +} + +func TestSyncSkipsSameBranch(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to main` + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "already up to date") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 0) +} + +func TestSyncEmptyReflog(t *testing.T) { + homeDir, repoDir, _ := setupSyncTest(t) + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog("")) + + require.NoError(t, err) + assert.Contains(t, stdout, "already up to date") +} + +func TestSyncBranchNamesWithSlashes(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from feature/ENG-641/item to release/v2.0` + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "synced 1 checkout(s)") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 1) + assert.Equal(t, "feature/ENG-641/item", entries[0].Previous) + assert.Equal(t, "release/v2.0", entries[0].Next) +} + +func TestSyncTimestampFromReflog(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + _, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + require.NoError(t, err) + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + require.Len(t, entries, 1) + + expected := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC) + assert.Equal(t, expected, entries[0].Timestamp) +} + +func TestSyncSinceOptimization(t *testing.T) { + homeDir, repoDir, _ := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + // First sync — no --since + sinceCalled := false + _, err := execSync(homeDir, repoDir, "", fakeReflogWithSinceCheck(reflogOutput, &sinceCalled)) + require.NoError(t, err) + assert.False(t, sinceCalled, "first sync should not pass --since") + + // Second sync — should pass --since + newReflog := `def5678 HEAD@{2025-06-15 16:00:00 +0000}: checkout: moving from feature-x to develop` + sinceCalled = false + _, err = execSync(homeDir, repoDir, "", fakeReflogWithSinceCheck(newReflog, &sinceCalled)) + require.NoError(t, err) + assert.True(t, sinceCalled, "subsequent sync should pass --since") +} + +func TestSyncLastSyncUpdated(t *testing.T) { + homeDir, repoDir, _ := setupSyncTest(t) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + _, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + require.NoError(t, err) + + repoCfg, err := project.ReadRepoConfig(repoDir) + require.NoError(t, err) + require.NotNil(t, repoCfg.LastSync) + + expected := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC) + assert.Equal(t, expected, *repoCfg.LastSync) +} + +func TestSyncByProjectFlag(t *testing.T) { + homeDir, _, proj := setupSyncTest(t) + + // Use a different repoDir without .git/.hourgit — rely on --project flag + repoDir2 := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoDir2, ".git"), 0755)) + // Write repo config manually so ReadRepoConfig succeeds + require.NoError(t, project.WriteRepoConfig(repoDir2, &project.RepoConfig{Project: proj.Name, ProjectID: proj.ID})) + + reflogOutput := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + stdout, err := execSync(homeDir, repoDir2, proj.Name, fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "Sync Test") +} + +func TestSyncNoProject(t *testing.T) { + homeDir := t.TempDir() + + _, err := execSync(homeDir, "", "", fakeReflog("")) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no project found") +} + +func TestSyncMultipleCheckouts(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + reflogOutput := fmt.Sprintf( + "%s\n%s\n%s", + `abc1234 HEAD@{2025-06-15 16:00:00 +0000}: checkout: moving from develop to main`, + `def5678 HEAD@{2025-06-15 15:00:00 +0000}: checkout: moving from feature-x to develop`, + `aaa9012 HEAD@{2025-06-15 14:00:00 +0000}: checkout: moving from main to feature-x`, + ) + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "synced 3 checkout(s)") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 3) +} + +func TestSyncSameCommitRefDifferentDirections(t *testing.T) { + homeDir, repoDir, proj := setupSyncTest(t) + + // Both checkouts share the same commit ref (HEAD didn't move), + // which happens when switching A→B and back B→A without new commits. + reflogOutput := fmt.Sprintf( + "%s\n%s", + `abc1234 HEAD@{2025-06-15 15:00:00 +0000}: checkout: moving from feature-x to main`, + `abc1234 HEAD@{2025-06-15 14:00:00 +0000}: checkout: moving from main to feature-x`, + ) + + stdout, err := execSync(homeDir, repoDir, "", fakeReflog(reflogOutput)) + + require.NoError(t, err) + assert.Contains(t, stdout, "synced 2 checkout(s)") + + entries, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + require.NoError(t, err) + assert.Len(t, entries, 2) + + // Verify both directions were recorded + nexts := map[string]bool{} + for _, e := range entries { + nexts[e.Next] = true + } + assert.True(t, nexts["main"], "checkout to main should be recorded") + assert.True(t, nexts["feature-x"], "checkout to feature-x should be recorded") +} + +func TestSyncRegisteredAsSubcommand(t *testing.T) { + root := newRootCmd() + names := make([]string, len(root.Commands())) + for i, cmd := range root.Commands() { + names[i] = cmd.Name() + } + assert.Contains(t, names, "sync") +} diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..900829b --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,216 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +const updateCheckTTL = 8 * time.Hour + +// updateDeps bundles all side-effects for testability. +type updateDeps struct { + now func() time.Time + isTTY func() bool + fetchVersion func() (string, error) + readConfig func(homeDir string) (*project.Config, error) + writeConfig func(homeDir string, cfg *project.Config) error + confirm func(prompt string) (bool, error) + runInstall func() error + restartSelf func() error + homeDir func() (string, error) +} + +func defaultUpdateDeps() updateDeps { + return updateDeps{ + now: time.Now, + isTTY: func() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) }, + fetchVersion: fetchLatestVersion, + readConfig: project.ReadConfig, + writeConfig: project.WriteConfig, + confirm: NewConfirmFunc(), + runInstall: runSelfInstall, + restartSelf: restartProcess, + homeDir: os.UserHomeDir, + } +} + +func checkForUpdate(cmd *cobra.Command, deps updateDeps) { + // Guard: skip if --skip-updates + skipUpdates, _ := cmd.Flags().GetBool("skip-updates") + if skipUpdates { + return + } + + // Guard: skip if dev build + if appVersion == "dev" { + return + } + + // Guard: skip if not a TTY + if !deps.isTTY() { + return + } + + // Resolve home directory for global config + homeDir, err := deps.homeDir() + if err != nil { + return + } + + // Read global config (includes update cache) + cfg, err := deps.readConfig(homeDir) + if err != nil { + return + } + + // Check if cache is fresh + now := deps.now() + if cfg.LastUpdateCheck != nil && now.Sub(*cfg.LastUpdateCheck) < updateCheckTTL { + // Cache is fresh — check if the cached version is newer + if cfg.LatestVersion == "" || compareVersions(appVersion, cfg.LatestVersion) >= 0 { + return + } + // Cached version is newer, show prompt + promptUpdate(cmd, deps, homeDir, cfg) + return + } + + // Cache is stale or missing — fetch latest version + latest, err := deps.fetchVersion() + if err != nil { + return + } + + // Update cache + cfg.LastUpdateCheck = &now + cfg.LatestVersion = latest + _ = deps.writeConfig(homeDir, cfg) + + // Compare versions + if compareVersions(appVersion, latest) >= 0 { + return + } + + promptUpdate(cmd, deps, homeDir, cfg) +} + +func promptUpdate(cmd *cobra.Command, deps updateDeps, homeDir string, cfg *project.Config) { + w := cmd.OutOrStdout() + _, _ = fmt.Fprintf(w, "\n%s\n", Warning(fmt.Sprintf("A new version of hourgit is available: %s → %s", appVersion, Primary(cfg.LatestVersion)))) + + install, err := deps.confirm("Install update now?") + if err != nil || !install { + return + } + + _, _ = fmt.Fprintf(w, "%s\n", Text("Installing update...")) + + if err := deps.runInstall(); err != nil { + _, _ = fmt.Fprintf(w, "%s\n\n", Error(fmt.Sprintf("Update failed: %s", err))) + return + } + + _, _ = fmt.Fprintf(w, "%s\n\n", Text("Update installed. Restarting...")) + + // Clear cached version so the restarted process doesn't re-prompt + now := deps.now() + cfg.LastUpdateCheck = &now + cfg.LatestVersion = "" + _ = deps.writeConfig(homeDir, cfg) + + if err := deps.restartSelf(); err != nil { + _, _ = fmt.Fprintf(w, "%s\n\n", Error(fmt.Sprintf("Restart failed: %s", err))) + } +} + +// compareVersions compares two semver strings. Returns -1 if a < b, 0 if equal, 1 if a > b. +func compareVersions(a, b string) int { + a = strings.TrimPrefix(a, "v") + b = strings.TrimPrefix(b, "v") + + aParts := strings.SplitN(a, ".", 3) + bParts := strings.SplitN(b, ".", 3) + + for i := 0; i < 3; i++ { + av, bv := 0, 0 + if i < len(aParts) { + av, _ = strconv.Atoi(aParts[i]) + } + if i < len(bParts) { + bv, _ = strconv.Atoi(bParts[i]) + } + if av < bv { + return -1 + } + if av > bv { + return 1 + } + } + return 0 +} + +// fetchLatestVersion queries GitHub API for the latest release tag. +func fetchLatestVersion() (string, error) { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.github.com/repos/Flyrell/hourgit/releases/latest") + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(body, &release); err != nil { + return "", err + } + + if release.TagName == "" { + return "", fmt.Errorf("empty tag_name in GitHub response") + } + + return release.TagName, nil +} + +// runSelfInstall runs the install script to update the binary. +func runSelfInstall() error { + cmd := exec.Command("bash", "-c", "curl -fsSL https://hourgit.com/install.sh | bash") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// restartProcess replaces the current process with a fresh invocation. +func restartProcess() error { + exe, err := os.Executable() + if err != nil { + return err + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return err + } + return syscall.Exec(exe, os.Args, os.Environ()) +} diff --git a/internal/cli/update_cmd.go b/internal/cli/update_cmd.go new file mode 100644 index 0000000..b19dd5a --- /dev/null +++ b/internal/cli/update_cmd.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var updateCmd = LeafCommand{ + Use: "update", + Short: "Check for and install updates", + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(cmd, defaultUpdateDeps()) + }, +}.Build() + +func runUpdate(cmd *cobra.Command, deps updateDeps) error { + w := cmd.OutOrStdout() + + if appVersion == "dev" { + _, _ = fmt.Fprintf(w, "%s\n", Text("Cannot update a dev build")) + return nil + } + + _, _ = fmt.Fprintf(w, "%s\n", Text("Checking for updates...")) + + latest, err := deps.fetchVersion() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + // Update cache so auto-check doesn't re-fetch unnecessarily + homeDir, homeErr := deps.homeDir() + if homeErr == nil { + cfg, cfgErr := deps.readConfig(homeDir) + if cfgErr == nil { + now := deps.now() + cfg.LastUpdateCheck = &now + cfg.LatestVersion = latest + _ = deps.writeConfig(homeDir, cfg) + + // If newer version available, reuse promptUpdate flow + if compareVersions(appVersion, latest) < 0 { + promptUpdate(cmd, deps, homeDir, cfg) + return nil + } + } + } + + if compareVersions(appVersion, latest) >= 0 { + _, _ = fmt.Fprintf(w, "%s\n", Text(fmt.Sprintf("hourgit is up to date (%s)", appVersion))) + return nil + } + + return nil +} diff --git a/internal/cli/update_cmd_test.go b/internal/cli/update_cmd_test.go new file mode 100644 index 0000000..712ba60 --- /dev/null +++ b/internal/cli/update_cmd_test.go @@ -0,0 +1,160 @@ +package cli + +import ( + "bytes" + "fmt" + "testing" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupUpdateCmdTest(t *testing.T) (string, updateDeps) { + t.Helper() + return setupUpdateTest(t) +} + +func execUpdateCmd(t *testing.T, version string, deps updateDeps) (string, error) { + t.Helper() + old := appVersion + appVersion = version + t.Cleanup(func() { appVersion = old }) + + buf := new(bytes.Buffer) + cmd := newRootCmd() + cmd.SetOut(buf) + err := runUpdate(cmd, deps) + return buf.String(), err +} + +func TestUpdateCommandDevBuild(t *testing.T) { + _, deps := setupUpdateCmdTest(t) + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v1.0.0", nil + } + + output, err := execUpdateCmd(t, "dev", deps) + + assert.NoError(t, err) + assert.Contains(t, output, "Cannot update a dev build") + assert.False(t, fetchCalled) +} + +func TestUpdateCommandAlreadyUpToDate(t *testing.T) { + _, deps := setupUpdateCmdTest(t) + deps.fetchVersion = func() (string, error) { return "v1.0.0", nil } + + output, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.NoError(t, err) + assert.Contains(t, output, "up to date") + assert.Contains(t, output, "v1.0.0") +} + +func TestUpdateCommandNewerVersionAvailable(t *testing.T) { + _, deps := setupUpdateCmdTest(t) + deps.fetchVersion = func() (string, error) { return "v2.0.0", nil } + + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil // skip install + } + + output, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.NoError(t, err) + assert.Contains(t, output, "Checking for updates") + assert.Contains(t, output, "new version") + assert.True(t, confirmCalled) +} + +func TestUpdateCommandFetchError(t *testing.T) { + _, deps := setupUpdateCmdTest(t) + deps.fetchVersion = func() (string, error) { return "", fmt.Errorf("network timeout") } + + _, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to check for updates") + assert.Contains(t, err.Error(), "network timeout") +} + +func TestUpdateCommandUpdatesCacheAfterFetch(t *testing.T) { + homeDir, deps := setupUpdateCmdTest(t) + deps.fetchVersion = func() (string, error) { return "v1.0.0", nil } + + _, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.NoError(t, err) + + cfg, cfgErr := project.ReadConfig(homeDir) + require.NoError(t, cfgErr) + assert.Equal(t, "v1.0.0", cfg.LatestVersion) + assert.NotNil(t, cfg.LastUpdateCheck) +} + +func TestUpdateCommandInstallFlow(t *testing.T) { + _, deps := setupUpdateCmdTest(t) + deps.fetchVersion = func() (string, error) { return "v2.0.0", nil } + deps.confirm = func(_ string) (bool, error) { return true, nil } + + installCalled := false + deps.runInstall = func() error { + installCalled = true + return nil + } + + restartCalled := false + deps.restartSelf = func() error { + restartCalled = true + return nil + } + + output, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.NoError(t, err) + assert.True(t, installCalled) + assert.True(t, restartCalled) + assert.Contains(t, output, "Installing update") + assert.Contains(t, output, "Restarting") +} + +func TestUpdateCommandHomeDirError(t *testing.T) { + _, deps := setupUpdateCmdTest(t) + deps.fetchVersion = func() (string, error) { return "v1.0.0", nil } + deps.homeDir = func() (string, error) { return "", fmt.Errorf("no home") } + + output, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.NoError(t, err) + assert.Contains(t, output, "up to date") +} + +func TestUpdateCommandBypassesCacheTTL(t *testing.T) { + homeDir, deps := setupUpdateCmdTest(t) + + // Write a fresh cache saying we're up to date + now := deps.now() + require.NoError(t, project.WriteConfig(homeDir, &project.Config{ + LastUpdateCheck: &now, + LatestVersion: "v1.0.0", + })) + + // fetchVersion returns a newer version — should be called despite fresh cache + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v2.0.0", nil + } + deps.confirm = func(_ string) (bool, error) { return false, nil } + + output, err := execUpdateCmd(t, "v1.0.0", deps) + + assert.NoError(t, err) + assert.True(t, fetchCalled, "update command should always fetch, bypassing cache TTL") + assert.Contains(t, output, "new version") +} diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go new file mode 100644 index 0000000..df5a5a4 --- /dev/null +++ b/internal/cli/update_test.go @@ -0,0 +1,296 @@ +package cli + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/project" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupUpdateTest(t *testing.T) (homeDir string, deps updateDeps) { + t.Helper() + home := t.TempDir() + + now := time.Date(2026, 2, 26, 12, 0, 0, 0, time.UTC) + + deps = updateDeps{ + now: func() time.Time { return now }, + isTTY: func() bool { return true }, + fetchVersion: func() (string, error) { return "v1.0.0", nil }, + readConfig: project.ReadConfig, + writeConfig: project.WriteConfig, + confirm: func(_ string) (bool, error) { return false, nil }, + runInstall: func() error { return nil }, + restartSelf: func() error { return nil }, + homeDir: func() (string, error) { return home, nil }, + } + + return home, deps +} + +func execUpdateCheck(t *testing.T, version string, deps updateDeps, extraArgs ...string) string { + t.Helper() + old := appVersion + appVersion = version + t.Cleanup(func() { appVersion = old }) + + buf := new(bytes.Buffer) + cmd := newRootCmd() + cmd.SetOut(buf) + cmd.SetArgs(append([]string{"version"}, extraArgs...)) + checkForUpdate(cmd, deps) + return buf.String() +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.0.0", "1.0.0", 0}, + {"v1.0.0", "v1.0.0", 0}, + {"1.0.0", "v1.0.0", 0}, + {"0.9.0", "1.0.0", -1}, + {"1.0.0", "0.9.0", 1}, + {"1.0.0", "1.0.1", -1}, + {"1.0.1", "1.0.0", 1}, + {"1.1.0", "1.0.9", 1}, + {"2.0.0", "1.9.9", 1}, + {"0.1.0", "0.0.9", 1}, + {"v0.1.0", "0.2.0", -1}, + } + + for _, tt := range tests { + t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { + assert.Equal(t, tt.want, compareVersions(tt.a, tt.b)) + }) + } +} + +func TestUpdateSkipsDevVersion(t *testing.T) { + _, deps := setupUpdateTest(t) + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v1.0.0", nil + } + + output := execUpdateCheck(t, "dev", deps) + + assert.Empty(t, output) + assert.False(t, fetchCalled) +} + +func TestUpdateSkipsSkipUpdatesFlag(t *testing.T) { + _, deps := setupUpdateTest(t) + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v1.0.0", nil + } + + old := appVersion + appVersion = "0.1.0" + t.Cleanup(func() { appVersion = old }) + + buf := new(bytes.Buffer) + cmd := newRootCmd() + cmd.SetOut(buf) + cmd.SetArgs([]string{"version", "--skip-updates"}) + _ = cmd.ParseFlags([]string{"--skip-updates"}) + checkForUpdate(cmd, deps) + + assert.Empty(t, buf.String()) + assert.False(t, fetchCalled) +} + +func TestUpdateSkipsNonTTY(t *testing.T) { + _, deps := setupUpdateTest(t) + deps.isTTY = func() bool { return false } + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v1.0.0", nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.Empty(t, output) + assert.False(t, fetchCalled) +} + +func TestUpdateFreshCacheSkipsFetch(t *testing.T) { + homeDir, deps := setupUpdateTest(t) + + now := deps.now() + recent := now.Add(-1 * time.Hour) + require.NoError(t, project.WriteConfig(homeDir, &project.Config{ + LastUpdateCheck: &recent, + LatestVersion: "0.1.0", // same as current + })) + + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v1.0.0", nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.Empty(t, output) + assert.False(t, fetchCalled) +} + +func TestUpdateFreshCacheNewerVersionPromptsUpdate(t *testing.T) { + homeDir, deps := setupUpdateTest(t) + + now := deps.now() + recent := now.Add(-1 * time.Hour) + require.NoError(t, project.WriteConfig(homeDir, &project.Config{ + LastUpdateCheck: &recent, + LatestVersion: "2.0.0", + })) + + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil // skip + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.Contains(t, output, "new version") + assert.True(t, confirmCalled) +} + +func TestUpdateStaleCacheFetchesAndPrompts(t *testing.T) { + homeDir, deps := setupUpdateTest(t) + + now := deps.now() + stale := now.Add(-9 * time.Hour) + require.NoError(t, project.WriteConfig(homeDir, &project.Config{ + LastUpdateCheck: &stale, + LatestVersion: "0.1.0", + })) + + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v2.0.0", nil + } + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.True(t, fetchCalled) + assert.True(t, confirmCalled) + assert.Contains(t, output, "new version") + + // Verify cache was updated in global config + cfg, err := project.ReadConfig(homeDir) + require.NoError(t, err) + assert.Equal(t, "v2.0.0", cfg.LatestVersion) + assert.NotNil(t, cfg.LastUpdateCheck) +} + +func TestUpdateNoCacheFetchesAndSkipsWhenSameVersion(t *testing.T) { + _, deps := setupUpdateTest(t) + + // No config file at all — ReadConfig returns fresh config with defaults + deps.fetchVersion = func() (string, error) { return "v0.1.0", nil } + confirmCalled := false + deps.confirm = func(_ string) (bool, error) { + confirmCalled = true + return false, nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.Empty(t, output) + assert.False(t, confirmCalled) +} + +func TestUpdateInstallFlowSuccess(t *testing.T) { + _, deps := setupUpdateTest(t) + + deps.fetchVersion = func() (string, error) { return "v2.0.0", nil } + deps.confirm = func(_ string) (bool, error) { return true, nil } + + installCalled := false + deps.runInstall = func() error { + installCalled = true + return nil + } + + restartCalled := false + deps.restartSelf = func() error { + restartCalled = true + return nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.True(t, installCalled) + assert.True(t, restartCalled) + assert.Contains(t, output, "Installing update") + assert.Contains(t, output, "Restarting") +} + +func TestUpdateInstallClearsCachedVersion(t *testing.T) { + homeDir, deps := setupUpdateTest(t) + + deps.fetchVersion = func() (string, error) { return "v2.0.0", nil } + deps.confirm = func(_ string) (bool, error) { return true, nil } + deps.runInstall = func() error { return nil } + deps.restartSelf = func() error { return nil } + + _ = execUpdateCheck(t, "0.1.0", deps) + + // Cache should be cleared after install + cfg, err := project.ReadConfig(homeDir) + require.NoError(t, err) + assert.Empty(t, cfg.LatestVersion) + assert.NotNil(t, cfg.LastUpdateCheck) +} + +func TestUpdateInstallFlowFailure(t *testing.T) { + _, deps := setupUpdateTest(t) + + deps.fetchVersion = func() (string, error) { return "v2.0.0", nil } + deps.confirm = func(_ string) (bool, error) { return true, nil } + deps.runInstall = func() error { return fmt.Errorf("download failed") } + + restartCalled := false + deps.restartSelf = func() error { + restartCalled = true + return nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.False(t, restartCalled) + assert.Contains(t, output, "Update failed") +} + +func TestUpdateHomeDirErrorSkips(t *testing.T) { + _, deps := setupUpdateTest(t) + deps.homeDir = func() (string, error) { return "", fmt.Errorf("no home") } + fetchCalled := false + deps.fetchVersion = func() (string, error) { + fetchCalled = true + return "v1.0.0", nil + } + + output := execUpdateCheck(t, "0.1.0", deps) + + assert.Empty(t, output) + assert.False(t, fetchCalled) +} diff --git a/internal/entry/checkout.go b/internal/entry/checkout.go index d1e54ac..7dcad21 100644 --- a/internal/entry/checkout.go +++ b/internal/entry/checkout.go @@ -9,4 +9,5 @@ type CheckoutEntry struct { Timestamp time.Time `json:"timestamp"` Previous string `json:"previous"` Next string `json:"next"` + CommitRef string `json:"commit_ref,omitempty"` } diff --git a/internal/project/project.go b/internal/project/project.go index 9778857..b5cc496 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/Flyrell/hourgit/internal/hashutil" "github.com/Flyrell/hourgit/internal/schedule" @@ -26,8 +27,9 @@ func SetVersion(v string) { // RepoConfig is the per-repo marker stored in .git/.hourgit. type RepoConfig struct { - Project string `json:"project"` - ProjectID string `json:"project_id,omitempty"` + Project string `json:"project"` + ProjectID string `json:"project_id,omitempty"` + LastSync *time.Time `json:"last_sync,omitempty"` } // ProjectEntry represents a single project in the global registry. @@ -41,9 +43,11 @@ type ProjectEntry struct { // Config holds the global hourgit configuration including projects and defaults. type Config struct { - Version string `json:"version"` - Defaults []schedule.ScheduleEntry `json:"defaults"` - Projects []ProjectEntry `json:"projects"` + Version string `json:"version"` + Defaults []schedule.ScheduleEntry `json:"defaults"` + Projects []ProjectEntry `json:"projects"` + LastUpdateCheck *time.Time `json:"last_update_check,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` } // HourgitDir returns the global hourgit config directory. diff --git a/internal/reflog/reflog.go b/internal/reflog/reflog.go new file mode 100644 index 0000000..1f990b9 --- /dev/null +++ b/internal/reflog/reflog.go @@ -0,0 +1,59 @@ +package reflog + +import ( + "regexp" + "strings" + "time" +) + +// CheckoutRecord represents a single checkout event parsed from git reflog output. +type CheckoutRecord struct { + CommitRef string + Timestamp time.Time + Previous string + Next string +} + +// reflogLinePattern matches git reflog lines with --date=iso format. +// Example: "abc1234 HEAD@{2025-06-15 14:30:00 +0200}: checkout: moving from main to feature-x" +var reflogLinePattern = regexp.MustCompile( + `^([0-9a-f]+)\s+HEAD@\{(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{4})\}:\s+checkout:\s+moving from (\S+) to (\S+)$`, +) + +// ParseReflog parses git reflog output and returns checkout records. +// Only "checkout: moving from X to Y" lines are matched; all other lines are skipped. +// Records are returned in reflog order (newest first). +func ParseReflog(output string) []CheckoutRecord { + var records []CheckoutRecord + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + matches := reflogLinePattern.FindStringSubmatch(line) + if matches == nil { + continue + } + + commitRef := matches[1] + timestampStr := matches[2] + prev := matches[3] + next := matches[4] + + ts, err := time.Parse("2006-01-02 15:04:05 -0700", timestampStr) + if err != nil { + continue + } + + records = append(records, CheckoutRecord{ + CommitRef: commitRef, + Timestamp: ts.UTC(), + Previous: prev, + Next: next, + }) + } + + return records +} diff --git a/internal/reflog/reflog_test.go b/internal/reflog/reflog_test.go new file mode 100644 index 0000000..5d0aae5 --- /dev/null +++ b/internal/reflog/reflog_test.go @@ -0,0 +1,84 @@ +package reflog + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParseReflogStandardCheckout(t *testing.T) { + input := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: checkout: moving from main to feature-x` + + records := ParseReflog(input) + + assert.Len(t, records, 1) + assert.Equal(t, "abc1234", records[0].CommitRef) + assert.Equal(t, time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC), records[0].Timestamp) + assert.Equal(t, "main", records[0].Previous) + assert.Equal(t, "feature-x", records[0].Next) +} + +func TestParseReflogBranchNamesWithSlashes(t *testing.T) { + input := `def5678 HEAD@{2025-06-15 10:00:00 +0200}: checkout: moving from feature/ENG-641/item to release/v2.0` + + records := ParseReflog(input) + + assert.Len(t, records, 1) + assert.Equal(t, "feature/ENG-641/item", records[0].Previous) + assert.Equal(t, "release/v2.0", records[0].Next) +} + +func TestParseReflogSkipsNonCheckoutLines(t *testing.T) { + input := `abc1234 HEAD@{2025-06-15 14:30:00 +0000}: commit: fix bug +def5678 HEAD@{2025-06-15 14:00:00 +0000}: checkout: moving from main to develop +ghi9012 HEAD@{2025-06-15 13:30:00 +0000}: rebase: checkout feature +jkl3456 HEAD@{2025-06-15 13:00:00 +0000}: pull: merge +mno7890 HEAD@{2025-06-15 12:00:00 +0000}: cherry-pick: fix` + + records := ParseReflog(input) + + assert.Len(t, records, 1) + assert.Equal(t, "def5678", records[0].CommitRef) + assert.Equal(t, "main", records[0].Previous) + assert.Equal(t, "develop", records[0].Next) +} + +func TestParseReflogEmptyInput(t *testing.T) { + records := ParseReflog("") + assert.Empty(t, records) +} + +func TestParseReflogMalformedTimestamp(t *testing.T) { + input := `abc1234 HEAD@{not-a-date}: checkout: moving from main to feature` + + records := ParseReflog(input) + + assert.Empty(t, records) +} + +func TestParseReflogMultipleCheckouts(t *testing.T) { + input := `abc1234 HEAD@{2025-06-15 16:00:00 +0000}: checkout: moving from develop to main +def5678 HEAD@{2025-06-15 14:00:00 +0000}: checkout: moving from main to develop` + + records := ParseReflog(input) + + assert.Len(t, records, 2) + // Newest first (reflog order) + assert.Equal(t, "abc1234", records[0].CommitRef) + assert.Equal(t, "develop", records[0].Previous) + assert.Equal(t, "main", records[0].Next) + assert.Equal(t, "def5678", records[1].CommitRef) + assert.Equal(t, "main", records[1].Previous) + assert.Equal(t, "develop", records[1].Next) +} + +func TestParseReflogTimezoneConversion(t *testing.T) { + // +0200 means 14:30 local = 12:30 UTC + input := `abc1234 HEAD@{2025-06-15 14:30:00 +0200}: checkout: moving from main to feature` + + records := ParseReflog(input) + + assert.Len(t, records, 1) + assert.Equal(t, time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC), records[0].Timestamp) +} diff --git a/web/docs/_docs.css b/web/docs/_docs.css index 291adf7..724bb10 100644 --- a/web/docs/_docs.css +++ b/web/docs/_docs.css @@ -274,6 +274,97 @@ body { .markdown-body tr:hover td { background: rgba(255, 255, 255, 0.02); } +/* ── Consent Banner ──────────────────────────────────────────────── */ + +.consent-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 200; + padding: 16px 24px; + background: var(--bg-sidebar); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--border-strong); + transform: translateY(100%); + animation: consent-slide-up 0.4s ease forwards; +} + +.consent-banner.hidden { + animation: consent-slide-down 0.3s ease forwards; + pointer-events: none; +} + +@keyframes consent-slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes consent-slide-down { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(100%); opacity: 0; } +} + +.consent-banner-inner { + max-width: 900px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.consent-text { + color: var(--text-dim); + font-size: 0.875rem; + line-height: 1.5; + flex: 1; +} + +.consent-text strong { + color: var(--text); +} + +.consent-actions { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.consent-btn { + font-family: var(--font-body); + font-size: 0.85rem; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.consent-btn--reject { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text-dim); +} + +.consent-btn--reject:hover { + border-color: var(--text-dim); + color: var(--text); +} + +.consent-btn--accept { + background: var(--accent); + border: 1px solid var(--accent); + color: #0d0d0d; +} + +.consent-btn--accept:hover { + background: var(--accent-light); + border-color: var(--accent-light); +} + /* ── Responsive ───────────────────────────────────────────────────── */ @media (max-width: 768px) { .sidebar { @@ -297,4 +388,19 @@ body { padding: 24px 20px 48px; max-width: 100%; } + + .consent-banner-inner { + flex-direction: column; + text-align: center; + gap: 14px; + } + + .consent-text { + font-size: 0.8rem; + } + + .consent-actions { + width: 100%; + justify-content: center; + } } diff --git a/web/docs/_sidebar.md b/web/docs/_sidebar.md index 7859c3a..42308f8 100644 --- a/web/docs/_sidebar.md +++ b/web/docs/_sidebar.md @@ -11,6 +11,7 @@ - [Schedule Configuration](commands/schedule.md) - [Default Schedule](commands/defaults.md) - [Shell Completions](commands/shell-completions.md) + - [Utility](commands/utility.md) - **Reference** - [Configuration](configuration.md) diff --git a/web/docs/_template.html b/web/docs/_template.html new file mode 100644 index 0000000..d711653 --- /dev/null +++ b/web/docs/_template.html @@ -0,0 +1,113 @@ + + + + + + + + + + {{.Title}} — Hourgit Docs + + + + + + + + + + + +
+
+ {{.Content}} +
+
+ + + + + + + + diff --git a/web/docs/commands/time-tracking.md b/web/docs/commands/time-tracking.md index 267f8ab..9fbf94f 100644 --- a/web/docs/commands/time-tracking.md +++ b/web/docs/commands/time-tracking.md @@ -98,18 +98,16 @@ hourgit remove [--project ] [--yes] > Works with both log and checkout entries. Shows entry details and asks for confirmation before deleting. -## `hourgit checkout` +## `hourgit sync` -Record a branch checkout event. Called internally by the post-checkout git hook. +Sync branch checkouts from git reflog. Called automatically by the post-checkout hook, or run manually to backfill history. ```bash -hourgit checkout --prev --next [--project ] +hourgit sync [--project ] ``` | Flag | Default | Description | |------|---------|-------------| -| `--prev` | — | Previous branch name (required) | -| `--next` | — | Next branch name (required) | | `--project` | auto-detect | Project name or ID | ## `hourgit report` diff --git a/web/docs/commands/utility.md b/web/docs/commands/utility.md new file mode 100644 index 0000000..37d76fd --- /dev/null +++ b/web/docs/commands/utility.md @@ -0,0 +1,27 @@ +# Utility + +General-purpose commands for checking your Hourgit version and keeping it up to date. + +## `hourgit version` + +Print the current Hourgit version. + +```bash +hourgit version +``` + +## `hourgit update` + +Check for a newer version of Hourgit and install it. + +```bash +hourgit update +``` + +This command always fetches the latest version from GitHub, bypassing the cached update check. If a newer version is available, you'll be prompted to install it. + +> **Note:** Dev builds (`version = "dev"`) skip the update check automatically. + +### Auto-update vs manual update + +Hourgit also checks for updates automatically when you run any interactive command (with an 8-hour cache). The `update` command is for when you want to check right now, regardless of when the last check happened.