Skip to content
Open
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,29 @@ GitReal stores settings in Git config:
git config --local gitreal.enabled true
git config --local gitreal.armed false
git config --local gitreal.graceSeconds 120
git config --local gitreal.scheduleMode hourly
git config --local gitreal.sound true
```

Current keys:

- `gitreal.enabled`
- `gitreal.armed`
- `gitreal.graceSeconds`
- `gitreal.scheduleMode` — `hourly` (default), `daily`, or `interval`
- `gitreal.dailyWindowStart` / `gitreal.dailyWindowEnd` — `HH:MM` window for daily mode (defaults `09:00` / `22:00`)
- `gitreal.intervalMinutes` — used when mode is `interval` (default `60`)
- `gitreal.sound` — when `true`, GitReal also writes a terminal bell and plays a system sound on Linux

### Schedule modes

- `hourly`: one challenge per hour at a random second within the hour. This is the legacy behavior.
- `daily`: one challenge per day at a random time within the configured window. **Known limitation: if `git real start` is interrupted and restarted on the same day, daily mode may fire again the same day.** This will be addressed in a future release.
- `interval`: one challenge every `gitreal.intervalMinutes` minutes with random jitter so it does not feel mechanical.

### Notification noticeability

On Linux, GitReal sends `notify-send -u critical -t 0` so the alert stays on screen until you dismiss it. macOS notifications include a system sound (`Sosumi`) and Windows Toast notifications also play a console beep. During the 2-minute grace window GitReal sends additional reminders at 30 seconds and 10 seconds before the deadline so a missed first alert is less likely to cost you. When `gitreal.sound=true` (the default), each alert is also accompanied by a terminal bell on stderr and a best-effort `paplay` / `canberra-gtk-play` on Linux.

## Build From Source

Expand Down
47 changes: 47 additions & 0 deletions docs/adr/0001-bereal-notification-and-schedule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 0001 — BeReal-style notification noticeability and configurable scheduler

- Status: accepted
- Date: 2026-05-01

## Context

Two complaints surfaced about the BeReal-inspired challenge flow:

1. Linux desktop notifications were dismissed within seconds, no sound was emitted, and the prototype's terminal bell (`\a`) never made it into the production code. Users in IDEs missed the alert and the deadline passed without their awareness.
2. The challenge scheduler always fired hourly. The original BeReal experience is "one chance, when you least expect it" — the hourly cadence trains the user out of the surprise.

This ADR records the decisions made during a planning interview to address both complaints in a single change.

## Decisions

### D1. Daily mode known limitation
`DailySchedule.Next` does not persist a "last fired today" marker. If `git real start` is interrupted and restarted, the daily mode may fire again the same day. The risk is accepted for this iteration and documented in `README.md` and `commandStatus`. A future change can store `gitreal.lastFiredDate` in git config to suppress same-day re-fires.

### D2. Default `scheduleMode` for fresh `init`
Stays at `hourly`. Daily is opt-in until D1 is resolved, so users get the legacy behavior unless they explicitly opt into the BeReal-flavored mode. Avoids shipping a bug as the default.

### D3. Reminders during the grace window
Send two additional reminders inside the grace window: T-30s and T-10s before the deadline. If `graceSeconds` is too short for a reminder to be in the future, that reminder is skipped (e.g. with `graceSeconds=15`, both reminders are skipped; with `20`, only T-10 fires). Three total alerts give the user multiple chances to notice.

### D4. `gitreal.sound` opt-out
A boolean key, default `true`. When `false`, both the BEL byte to stderr and the best-effort `paplay`/`canberra-gtk-play` invocations are skipped. This is the only audio-related setting; we did not want to add per-platform tuning.

### D5. Linux urgency policy
Hard-coded `notify-send -u critical -t 0`. No config key for urgency. The whole point of this change is to fix "I cannot notice the notification", and a tunable urgency would defeat that. Documented as a deliberate design choice.

### D6. Schedule package location
New package `internal/schedule/` with a `Schedule` interface and three strategies (`HourlySchedule`, `DailySchedule`, `IntervalSchedule`). Keeps the `cli` package focused on command dispatch and makes the strategy reusable for a future `git real daemon` subcommand.

### D7. Late-grace concept
Postponed. BeReal's "posted late" semantics could be added later as a `gitreal.lateGraceSeconds` key, but it is orthogonal to noticeability and broadens the PR. Tracked separately.

### D8. Daily mode in this PR
Implement `DailySchedule` despite D1, because the configurability story without daily would be unsatisfying for users explicitly asking for "once a day at a random time". The known limitation is surfaced through `commandStatus` output and `README.md` so users can decide whether to opt in.

## Consequences

- New git config keys: `gitreal.scheduleMode`, `gitreal.dailyWindowStart`, `gitreal.dailyWindowEnd`, `gitreal.intervalMinutes`, `gitreal.sound`.
- `notify-send` is now invoked with `-u critical -t 0 -a git-real -i dialog-warning` on Linux. macOS notifications include `sound name "Sosumi"`. Windows Toast notifications also call `[console]::beep(880,300)`.
- The `notify` helper takes a `repository` argument so it can read `gitreal.sound`. All six call sites in `runChallenge` are updated.
- `nextRandomSlot` is preserved as a one-line shim that delegates to `schedule.HourlySchedule{}.Next` so the existing test still compiles.
- Future work: implement `gitreal.lastFiredDate` to fix D1, and consider `gitreal.lateGraceSeconds` for D7.
143 changes: 123 additions & 20 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,36 @@ import (
"fmt"
"io"
"math/rand"
"os/exec"
"strconv"
"strings"
"time"

"github.com/watany-dev/gitreal/internal/challenge"
ggit "github.com/watany-dev/gitreal/internal/git"
"github.com/watany-dev/gitreal/internal/notify"
"github.com/watany-dev/gitreal/internal/schedule"
)

const (
scheduleModeHourly = "hourly"
scheduleModeDaily = "daily"
scheduleModeInterval = "interval"

defaultDailyWindowStart = "09:00"
defaultDailyWindowEnd = "22:00"
defaultIntervalMinutes = 60
defaultLinuxSoundFile = "/usr/share/sounds/freedesktop/stereo/message.oga"
)

type repository interface {
Root() string
SetConfigBool(key string, value bool) error
SetConfigInt(key string, value int) error
SetConfigString(key, value string) error
ConfigBool(key string, fallback bool) bool
ConfigInt(key string, fallback int) int
ConfigString(key, fallback string) string
CurrentBranch() (string, error)
Upstream() (string, error)
FetchQuiet() error
Expand All @@ -36,6 +51,7 @@ type app struct {
now func() time.Time
sleep func(time.Duration)
sendNotification func(title, message string) error
playSound func(name string, args ...string) error
rng *rand.Rand
stdout io.Writer
stderr io.Writer
Expand All @@ -54,9 +70,12 @@ func newApp(stdout, stderr io.Writer) *app {
now: time.Now,
sleep: time.Sleep,
sendNotification: notify.Send,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
stdout: stdout,
stderr: stderr,
playSound: func(name string, args ...string) error {
return exec.Command(name, args...).Start()
},
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
stdout: stdout,
stderr: stderr,
}
}

Expand Down Expand Up @@ -109,8 +128,17 @@ func (a *app) commandInit() int {
return a.fail(err)
}

if err := repo.SetConfigString("gitreal.scheduleMode", scheduleModeHourly); err != nil {
return a.fail(err)
}

if err := repo.SetConfigBool("gitreal.sound", true); err != nil {
return a.fail(err)
}

fmt.Fprintf(a.stdout, "GitReal initialized for: %s\n", repo.Root())
fmt.Fprintln(a.stdout, "Mode: dry-run")
fmt.Fprintf(a.stdout, "Schedule: %s\n", scheduleModeHourly)
fmt.Fprintln(a.stdout, "Run: git real once")
return 0
}
Expand Down Expand Up @@ -140,9 +168,14 @@ func (a *app) commandStatus() int {
fmt.Fprintf(a.stdout, "enabled: %t\n", repo.ConfigBool("gitreal.enabled", false))
fmt.Fprintf(a.stdout, "armed: %t\n", repo.ConfigBool("gitreal.armed", false))
fmt.Fprintf(a.stdout, "grace-seconds: %d\n", challenge.NormalizeGraceSeconds(repo.ConfigInt("gitreal.graceSeconds", challenge.DefaultGraceSeconds)))
fmt.Fprintf(a.stdout, "schedule: %s\n", describeSchedule(repo))
fmt.Fprintf(a.stdout, "sound: %t\n", repo.ConfigBool("gitreal.sound", true))
fmt.Fprintf(a.stdout, "branch: %s\n", branch)
fmt.Fprintf(a.stdout, "upstream: %s\n", upstream)
fmt.Fprintf(a.stdout, "ahead: %s\n", aheadText)
if repo.ConfigString("gitreal.scheduleMode", scheduleModeHourly) == scheduleModeDaily {
fmt.Fprintln(a.stdout, "note: daily mode may re-fire same day after process restart (known limitation)")
}
return 0
}

Expand Down Expand Up @@ -188,19 +221,20 @@ func (a *app) commandStart(args []string) int {

func (a *app) runStart(repo repository, graceSeconds int, iterations int) int {
base := a.now()
sched := resolveSchedule(repo, a.stderr)
fmt.Fprintf(a.stdout, "GitReal started for %s\n", repo.Root())

completed := 0
for iterations <= 0 || completed < iterations {
next := nextRandomSlot(base, a.rng)
next := sched.Next(base, a.rng)
fmt.Fprintf(a.stdout, "next challenge: %s\n", next.Format(time.RFC3339))
a.sleepUntil(next)

if err := a.runChallenge(repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil {
fmt.Fprintf(a.stderr, "git-real: %v\n", err)
}

base = next.Add(time.Hour)
base = next.Add(time.Second)
completed++
}

Expand Down Expand Up @@ -350,19 +384,19 @@ func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error
fmt.Fprintf(a.stdout, "ahead: %d\n", ahead)

if ahead == 0 {
a.notify("GitReal", "No unpushed commits. Nothing to do.")
a.notify(repo, "GitReal", "No unpushed commits. Nothing to do.")
fmt.Fprintln(a.stdout, "nothing to do: no unpushed commits")
return nil
}

deadline := a.now().Add(time.Duration(graceSeconds) * time.Second)
fmt.Fprintf(a.stdout, "deadline: %s\n", deadline.Format(time.RFC3339))
a.notify("GitReal", fmt.Sprintf("%s has %d unpushed commit(s). Push before %s.", branch, ahead, deadline.Format("15:04:05")))
a.notify(repo, "GitReal", fmt.Sprintf("%s has %d unpushed commit(s). Push before %s.", branch, ahead, deadline.Format("15:04:05")))

a.sleepUntil(deadline)
a.sleepWithReminders(repo, deadline, branch)

if err := repo.FetchQuiet(); err != nil {
a.notify("GitReal", "fetch failed; punishment skipped for safety.")
a.notify(repo, "GitReal", "fetch failed; punishment skipped for safety.")
fmt.Fprintln(a.stdout, "fetch failed after deadline; punishment skipped for safety")
return nil
}
Expand All @@ -373,13 +407,13 @@ func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error
}

if aheadAfter == 0 {
a.notify("GitReal", "Push confirmed. You are GitReal.")
a.notify(repo, "GitReal", "Push confirmed. You are GitReal.")
fmt.Fprintln(a.stdout, "push confirmed")
return nil
}

if !armed {
a.notify("GitReal dry-run", fmt.Sprintf("%d commit(s) would be reset.", aheadAfter))
a.notify(repo, "GitReal dry-run", fmt.Sprintf("%d commit(s) would be reset.", aheadAfter))
fmt.Fprintf(a.stdout, "dry-run: would reset %d commit(s) to @{u}\n", aheadAfter)
return nil
}
Expand All @@ -405,16 +439,34 @@ func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error
}
}

a.notify("GitReal", fmt.Sprintf("Local commits made unreal. Backup: %s", backupRef))
a.notify(repo, "GitReal", fmt.Sprintf("Local commits made unreal. Backup: %s", backupRef))
fmt.Fprintf(a.stdout, "backup ref: %s\n", backupRef)
fmt.Fprintf(a.stdout, "restore: git real rescue restore %s\n", backupRef)
return nil
}

func (a *app) notify(title, message string) {
func (a *app) notify(repo repository, title, message string) {
if err := a.sendNotification(title, message); err != nil {
fmt.Fprintf(a.stdout, "notification: %s: %s\n", title, message)
}
a.alertSound(repo)
}

func (a *app) alertSound(repo repository) {
if repo == nil || !repo.ConfigBool("gitreal.sound", true) {
return
}

fmt.Fprint(a.stderr, "\a")

if a.playSound == nil {
return
}

if err := a.playSound("paplay", defaultLinuxSoundFile); err == nil {
return
}
_ = a.playSound("canberra-gtk-play", "-i", "message")
}

func (a *app) sleepUntil(target time.Time) {
Expand All @@ -424,6 +476,24 @@ func (a *app) sleepUntil(target time.Time) {
}
}

func (a *app) sleepWithReminders(repo repository, deadline time.Time, branch string) {
for _, r := range []struct {
offset time.Duration
message string
}{
{30 * time.Second, fmt.Sprintf("30s left to push %s", branch)},
{10 * time.Second, fmt.Sprintf("10s left! push %s now.", branch)},
} {
fireAt := deadline.Add(-r.offset)
if !fireAt.After(a.now()) {
continue
}
a.sleepUntil(fireAt)
a.notify(repo, "GitReal", r.message)
}
a.sleepUntil(deadline)
}

func resolveGraceSeconds(args []string, repo repository, stderr io.Writer) (int, error) {
graceSeconds, explicit, err := parseGraceSeconds(args, stderr)
if err != nil {
Expand Down Expand Up @@ -463,15 +533,48 @@ func parseGraceSeconds(args []string, stderr io.Writer) (int, bool, error) {
return challenge.NormalizeGraceSeconds(*graceSeconds), explicit, nil
}

func nextRandomSlot(base time.Time, rng *rand.Rand) time.Time {
windowStart := base.Truncate(time.Hour)
offset := time.Duration(rng.Intn(3600)) * time.Second
slot := windowStart.Add(offset)
if !slot.After(base) {
slot = windowStart.Add(time.Hour + time.Duration(rng.Intn(3600))*time.Second)
func resolveSchedule(repo repository, stderr io.Writer) schedule.Schedule {
mode := repo.ConfigString("gitreal.scheduleMode", scheduleModeHourly)
switch mode {
case scheduleModeDaily:
startStr := repo.ConfigString("gitreal.dailyWindowStart", defaultDailyWindowStart)
endStr := repo.ConfigString("gitreal.dailyWindowEnd", defaultDailyWindowEnd)
start, errStart := schedule.ParseClock(startStr)
end, errEnd := schedule.ParseClock(endStr)
if errStart != nil || errEnd != nil || end <= start {
fmt.Fprintf(stderr, "git-real: invalid daily window %q-%q; falling back to hourly\n", startStr, endStr)
return schedule.HourlySchedule{}
}
return schedule.DailySchedule{Start: start, End: end}
case scheduleModeInterval:
minutes := repo.ConfigInt("gitreal.intervalMinutes", defaultIntervalMinutes)
if minutes <= 0 {
fmt.Fprintf(stderr, "git-real: invalid intervalMinutes %d; falling back to hourly\n", minutes)
return schedule.HourlySchedule{}
}
return schedule.IntervalSchedule{Interval: time.Duration(minutes) * time.Minute}
case scheduleModeHourly, "":
return schedule.HourlySchedule{}
default:
fmt.Fprintf(stderr, "git-real: unknown scheduleMode %q; falling back to hourly\n", mode)
return schedule.HourlySchedule{}
}
}

return slot
func describeSchedule(repo repository) string {
mode := repo.ConfigString("gitreal.scheduleMode", scheduleModeHourly)
switch mode {
case scheduleModeDaily:
return fmt.Sprintf("daily %s-%s",
repo.ConfigString("gitreal.dailyWindowStart", defaultDailyWindowStart),
repo.ConfigString("gitreal.dailyWindowEnd", defaultDailyWindowEnd))
case scheduleModeInterval:
return fmt.Sprintf("interval %dm", repo.ConfigInt("gitreal.intervalMinutes", defaultIntervalMinutes))
case "":
return scheduleModeHourly
default:
return mode
}
}

func printHelp(w io.Writer) {
Expand Down
Loading
Loading