diff --git a/README.md b/README.md index f5ca034..4561468 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ __Features:__ - The timer __automatically starts__ when you switch to a (new) branch using `git checkout` - The timer __automatically pauses__ when it doesn't detect any file activity for a while -- The time you spent is automatically added to the next `git commit` message +- The time you spent is automatically added to the next `git commit` message by default - The timer increments in discreet steps: the _minimal billable unit_ (MBU), by default this is 1m. +- Spent time is stored as commit metadata using [git-notes](git-scm.com/docs/git-notes) and pushed automatically to your remote by default + __Currently Supported: (see roadmap)__ @@ -26,7 +28,7 @@ __Currently Supported: (see roadmap)__ glass init ``` - _NOTE: you'll only have to run this once per clone_ + _NOTE: you'll have to run this once per clone_ 3. Start the timer by creating a new branch: @@ -47,6 +49,10 @@ __Currently Supported: (see roadmap)__ git log -n 1 ``` +## Documentation +More documentation will be available in the future, for now we offer the following: + +- [Configuration Reference](/docs/config.md) ## Roadmap, input welcome! diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/command/init.go b/command/init.go index 7bc0605..42145aa 100644 --- a/command/init.go +++ b/command/init.go @@ -44,16 +44,16 @@ func (c *Init) Run(ctx *cli.Context) error { return errwrap.Wrapf("Failed to fetch current working dir: {{err}}", err) } - vcs, err := vcs.GetVCS(dir) + vc, err := vcs.GetVCS(dir) if err != nil { return errwrap.Wrapf("Failed to setup VCS: {{err}}", err) } - err = vcs.Hook() + err = vc.Hook() if err != nil { return errwrap.Wrapf("Failed to write hooks: {{err}}", err) } fmt.Println("Timeglass: hooks written") - return nil + return NewPull().Run(ctx) } diff --git a/command/lap.go b/command/lap.go index 81477b2..d3a1a4d 100644 --- a/command/lap.go +++ b/command/lap.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/errwrap" "github.com/timeglass/glass/model" + "github.com/timeglass/glass/vcs" ) type Lap struct { @@ -23,15 +24,20 @@ func (c *Lap) Name() string { } func (c *Lap) Description() string { - return fmt.Sprintf("") + return fmt.Sprintf("Resets the running timer, report spent time and punch as time spent on last commit") } func (c *Lap) Usage() string { - return "Show the measured time and reset the timer to 0s" + return "Register time spent on last commit and reset the timer to 0s" } func (c *Lap) Flags() []cli.Flag { - return []cli.Flag{} + return []cli.Flag{ + cli.BoolFlag{ + Name: "from-hook", + Usage: "Indicate it is called from a git, now expects refs on stdin", + }, + } } func (c *Lap) Action() func(ctx *cli.Context) { @@ -50,16 +56,33 @@ func (c *Lap) Run(ctx *cli.Context) error { return errwrap.Wrapf(fmt.Sprintf("Failed to get Daemon address: {{err}}"), err) } + //get time and reset client := NewClient(info) t, err := client.Lap() if err != nil { if err == ErrDaemonDown { + //if were calling this from a hook, supress errors + if ctx.Bool("from-hook") { + return nil + } + return errwrap.Wrapf(fmt.Sprintf("No timer appears to be running for '%s': {{err}}", dir), err) } else { return err } } + //write the vcs + vc, err := vcs.GetVCS(dir) + if err != nil { + return errwrap.Wrapf("Failed to setup VCS: {{err}}", err) + } + + err = vc.Log(t) + if err != nil { + return errwrap.Wrapf("Failed to log time into VCS: {{err}}", err) + } + fmt.Println(t) return nil } diff --git a/command/pull.go b/command/pull.go new file mode 100644 index 0000000..0aa9e94 --- /dev/null +++ b/command/pull.go @@ -0,0 +1,69 @@ +package command + +import ( + "fmt" + "os" + + "github.com/codegangsta/cli" + "github.com/hashicorp/errwrap" + + "github.com/timeglass/glass/vcs" +) + +type Pull struct { + *command +} + +func NewPull() *Pull { + return &Pull{newCommand()} +} + +func (c *Pull) Name() string { + return "pull" +} + +func (c *Pull) Description() string { + return fmt.Sprintf("Pull the Timeglass notes branch from the remote repository. Provide the remote's name as the first argument, if no argument is provided it tries to pull from to the VCS default remote") +} + +func (c *Pull) Usage() string { + return "Pull time data from the remote repository" +} + +func (c *Pull) Flags() []cli.Flag { + return []cli.Flag{} +} + +func (c *Pull) Action() func(ctx *cli.Context) { + return c.command.Action(c.Run) +} + +func (c *Pull) Run(ctx *cli.Context) error { + dir, err := os.Getwd() + if err != nil { + return errwrap.Wrapf("Failed to fetch current working dir: {{err}}", err) + } + + vc, err := vcs.GetVCS(dir) + if err != nil { + return errwrap.Wrapf("Failed to setup VCS: {{err}}", err) + } + + remote := ctx.Args().First() + if remote == "" { + remote = vc.DefaultRemote() + } + + err = vc.Fetch(remote) + if err != nil { + if err == vcs.ErrNoRemoteTimeData { + fmt.Printf("Timeglass: remote '%s' has no time data (yet), nothing to pull\n", remote) + return nil + } + + return errwrap.Wrapf("Failed to pull time data: {{err}}", err) + } + + fmt.Println("Timeglass: time data fetched successfully") + return nil +} diff --git a/command/punch.go b/command/punch.go new file mode 100644 index 0000000..b7df5c6 --- /dev/null +++ b/command/punch.go @@ -0,0 +1,69 @@ +package command + +import ( + "fmt" + "os" + "time" + + "github.com/codegangsta/cli" + "github.com/hashicorp/errwrap" + + "github.com/timeglass/glass/vcs" +) + +type Punch struct { + *command +} + +func NewPunch() *Punch { + return &Punch{newCommand()} +} + +func (c *Punch) Name() string { + return "punch" +} + +func (c *Punch) Description() string { + return fmt.Sprintf("") +} + +func (c *Punch) Usage() string { + return "Manually register time spent on the last commit" +} + +func (c *Punch) Flags() []cli.Flag { + return []cli.Flag{} +} + +func (c *Punch) Action() func(ctx *cli.Context) { + return c.command.Action(c.Run) +} + +func (c *Punch) Run(ctx *cli.Context) error { + dir, err := os.Getwd() + if err != nil { + return errwrap.Wrapf("Failed to fetch current working dir: {{err}}", err) + } + + if ctx.Args().First() == "" { + return fmt.Errorf("Please provide the time you spent as the first argument") + } + + t, err := time.ParseDuration(ctx.Args().First()) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to parse provided argument '%s' as a valid duration (e.g 1h2m10s): {{err}}", ctx.Args().First()), err) + } + + //write the vcs + vc, err := vcs.GetVCS(dir) + if err != nil { + return errwrap.Wrapf("Failed to setup VCS: {{err}}", err) + } + + err = vc.Log(t) + if err != nil { + return errwrap.Wrapf("Failed to log time into VCS: {{err}}", err) + } + + return nil +} diff --git a/command/push.go b/command/push.go new file mode 100644 index 0000000..851a5ae --- /dev/null +++ b/command/push.go @@ -0,0 +1,106 @@ +package command + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/codegangsta/cli" + "github.com/hashicorp/errwrap" + + "github.com/timeglass/glass/model" + "github.com/timeglass/glass/vcs" +) + +type Push struct { + *command +} + +func NewPush() *Push { + return &Push{newCommand()} +} + +func (c *Push) Name() string { + return "push" +} + +func (c *Push) Description() string { + return fmt.Sprintf("Pushes the Timeglass notes branch to the remote repository. Provide the remote's name as the first argument, if no argument is provided it tries to push to the VCS default remote") +} + +func (c *Push) Usage() string { + return "Push time data to the remote repository" +} + +func (c *Push) Flags() []cli.Flag { + return []cli.Flag{ + cli.BoolFlag{ + Name: "from-hook", + Usage: "Indicate it is called from a git, now expects refs on stdin", + }, + } +} + +func (c *Push) Action() func(ctx *cli.Context) { + return c.command.Action(c.Run) +} + +func (c *Push) Run(ctx *cli.Context) error { + dir, err := os.Getwd() + if err != nil { + return errwrap.Wrapf("Failed to fetch current working dir: {{err}}", err) + } + + m := model.New(dir) + conf, err := m.ReadConfig() + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to read configuration: {{err}}"), err) + } + + //hooks require us require us to check the refs that are pushed over stdin + //to prevent inifinte push loop + refs := "" + if ctx.Bool("from-hook") { + bytes, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return errwrap.Wrapf("Failed to read from stdin: {{err}}", err) + } + + refs = string(bytes) + //when `glass push` triggers the pre-push hook it will not + //provide any refs on stdin + //this probalby means means there is nothing left to push and + //we return here to prevent recursive push + if refs == "" { + return nil + } + + //configuration can explicitly request not to push time data automatically + //on hook usage + if !conf.AutoPush { + return nil + } + } + + vc, err := vcs.GetVCS(dir) + if err != nil { + return errwrap.Wrapf("Failed to setup VCS: {{err}}", err) + } + + remote := ctx.Args().First() + if remote == "" { + remote = vc.DefaultRemote() + } + + err = vc.Push(remote, refs) + if err != nil { + if err == vcs.ErrNoLocalTimeData { + fmt.Printf("Timeglass: local clone has no time data (yet), nothing to push to '%s'. Start a timer and commit changes to record local time data.\n", remote) + return nil + } + + return errwrap.Wrapf("Failed to push time data: {{err}}", err) + } + + return nil +} diff --git a/command/start.go b/command/start.go index 7bca910..dbbbfeb 100644 --- a/command/start.go +++ b/command/start.go @@ -51,6 +51,11 @@ func (c *Start) Run(ctx *cli.Context) error { return errwrap.Wrapf(fmt.Sprintf("Failed to get Daemon address: {{err}}"), err) } + conf, err := m.ReadConfig() + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to read configuration: {{err}}"), err) + } + client := NewClient(info) err = client.Call("timer.start") if err != nil { @@ -58,7 +63,7 @@ func (c *Start) Run(ctx *cli.Context) error { return err } - cmd := exec.Command("glass-daemon") + cmd := exec.Command("glass-daemon", fmt.Sprintf("--mbu=%s", conf.MBU)) err := cmd.Start() if err != nil { return errwrap.Wrapf(fmt.Sprintf("Failed to start Daemon: {{err}}"), err) diff --git a/command/status.go b/command/status.go index a6a7218..6e73bbe 100644 --- a/command/status.go +++ b/command/status.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "text/template" "time" "github.com/codegangsta/cli" @@ -57,10 +58,25 @@ func (c *Status) Run(ctx *cli.Context) error { return errwrap.Wrapf(fmt.Sprintf("Failed to get Daemon address: {{err}}"), err) } + conf, err := m.ReadConfig() + if err != nil { + //@todo find a more elegant way to 'print' this for script usage + if ctx.Bool("time-only") { + return nil + } + + return errwrap.Wrapf(fmt.Sprintf("Failed to read configuration: {{err}}"), err) + } + client := NewClient(info) status, err := client.GetStatus() if err != nil { if err == ErrDaemonDown { + //if called from hook, don't interrupt + if ctx.Bool("time-only") { + return nil + } + return errwrap.Wrapf(fmt.Sprintf("No timer appears to be running for '%s': {{err}}", dir), err) } else { return err @@ -72,8 +88,8 @@ func (c *Status) Run(ctx *cli.Context) error { return errwrap.Wrapf(fmt.Sprintf("Failed to parse '%s' as a time duration: {{err}}", status.Time), err) } - //simple semver check if !ctx.Bool("time-only") { + //simple semver check curr, _ := strconv.Atoi(strings.Replace(status.CurrentVersion, ".", "", 2)) recent, _ := strconv.Atoi(strings.Replace(status.MostRecentVersion, ".", "", 2)) if curr != 0 && recent > curr { @@ -85,7 +101,29 @@ func (c *Status) Run(ctx *cli.Context) error { return nil } - fmt.Printf(" [%s]", t) + //parse temlate and only report error if we're talking to a human + tmpl, err := template.New("commit-msg").Parse(conf.CommitMessage) + if err != nil { + //@todo find a more elegant way to 'print' this for script usage + if ctx.Bool("time-only") { + return nil + } else { + return errwrap.Wrapf(fmt.Sprintf("Failed to parse commit_message: '%s' in configuration as a text/template: {{err}}", conf.CommitMessage), err) + } + } + + //execute template and write to stdout + err = tmpl.Execute(os.Stdout, t) + if err != nil { + //@todo find a more elegant way to 'print' this for script usage + if ctx.Bool("time-only") { + return nil + } else { + return errwrap.Wrapf(fmt.Sprintf("Failed to execute commit_message: template for time '%s': {{err}}", t), err) + } + } + + //end with newline if we're printing for a human if !ctx.Bool("time-only") { fmt.Println() } diff --git a/command/stop.go b/command/stop.go index d017bfb..503e4b0 100644 --- a/command/stop.go +++ b/command/stop.go @@ -27,7 +27,7 @@ func (c *Stop) Description() string { } func (c *Stop) Usage() string { - return "Stop the timer completely" + return "Shutdown the timer, discarding the current measurement" } func (c *Stop) Flags() []cli.Flag { diff --git a/command/types.go b/command/types.go index 6caf3d9..63879ec 100644 --- a/command/types.go +++ b/command/types.go @@ -21,7 +21,7 @@ func (c *command) Action(fn func(c *cli.Context) error) func(ctx *cli.Context) { return func(ctx *cli.Context) { err := fn(ctx) if err != nil { - c.Fatalf("[Error]: %s", err) + c.Fatalf("[Timeglass Error]: %s", err) return } } diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..c5a87f9 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,28 @@ +# Configuration +Timeglass can be configured by creating a `timeglass.json` file in the root of the repository you are tracking. The following example shows all options with their default configuration: + +```json +{ + "mbu": "1m", + "commit_message": " [{{.}}]", + "auto_push": true +} +``` + +## MBU +__key__: `mbu` + +A timer runs in the background and increments by a set amount of time each tick: the "minimal billable unit". It accepts a human readable format that is parsed by: [time.ParseDuration()](http://golang.org/pkg/time/#ParseDuration), e.g: `1h5m2s` + +## Commit Message Template +__key__: `commit_message` + +This options allows you to specify how Timeglass should write spent time to commit messages. To disable this feature completely, provide an empty string, e.g: `"commit_message": ""` + +The template is parsed using the standard Go [text/templating](http://golang.org/pkg/text/template/), but you probably only need to know that `{{.}}` is replaced by a human readable representation of the measured time, e.g: `1h5m2s` + +## Automatically Push Time data +__key__: `auto_push` +__requirements__: git v1.8.2.1 or higher + +Timeglass uses [git-notes](http://git-scm.com/docs/git-notes) for storing commit times, since git-notes uses a seperate branch for such data it needs to be explicitely pushed or else data is merely stored local and lost whenever the clone is removed. To prevent this, Timeglass installes a pre-push hook that automatically pushes time data to the same remote as the push itself. If you rather want full control over when to push time data using the `glass push` command, you can disable the automatic behaviour with this options: `"auto_push": false`. The pre-push hook was introduced in git v1.8.2, if you're running an older version the hook is simply not run and this option does nothing. \ No newline at end of file diff --git a/main.go b/main.go index 18afa84..f3843a3 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,10 @@ func main() { command.NewPause(), command.NewStatus(), command.NewLap(), + command.NewPunch(), command.NewStop(), + command.NewPush(), + command.NewPull(), } for _, c := range cmds { diff --git a/model/config.go b/model/config.go new file mode 100644 index 0000000..1d0e7ff --- /dev/null +++ b/model/config.go @@ -0,0 +1,39 @@ +package model + +import ( + "strconv" + "time" + + "github.com/hashicorp/errwrap" +) + +type MBU time.Duration + +func (m MBU) String() string { return time.Duration(m).String() } + +func (t *MBU) UnmarshalJSON(data []byte) error { + raw, err := strconv.Unquote(string(data)) + if err != nil { + return err + } + + parsed, err := time.ParseDuration(raw) + if err != nil { + return errwrap.Wrapf("Failed to parse duration: {{err}}", err) + } + + *t = MBU(parsed) + return nil +} + +var DefaultConfig = &Config{ + MBU: MBU(time.Minute), + CommitMessage: " [{{.}}]", + AutoPush: true, +} + +type Config struct { + MBU MBU `json:"mbu"` + CommitMessage string `json:"commit_message"` + AutoPush bool `json:"auto_push"` +} diff --git a/model/model.go b/model/model.go index 999a5de..7d3d727 100644 --- a/model/model.go +++ b/model/model.go @@ -2,6 +2,7 @@ package model import ( "crypto/md5" + "encoding/json" "fmt" "os" "os/user" @@ -49,8 +50,10 @@ func (m *Model) Open() (*bolt.DB, error) { } func (m *Model) Close(db *bolt.DB) { + err := db.Close() + //@todo handle error? - db.Close() + _ = err } func (m *Model) UpsertDaemonInfo(info *Daemon) error { @@ -103,3 +106,31 @@ func (m *Model) ReadDaemonInfo() (*Daemon, error) { return nil }) } + +func (m *Model) ReadConfig() (*Config, error) { + conf := DefaultConfig + + p := filepath.Join(m.repoDir, "timeglass.json") + f, err := os.Open(p) + if err != nil { + if os.IsNotExist(err) { + return conf, nil + } + + return nil, errwrap.Wrapf(fmt.Sprintf("Error opening configuration file '%s', it does exist but: {{err}}", p), err) + } + + dec := json.NewDecoder(f) + + defer f.Close() + err = dec.Decode(conf) + if err != nil { + return nil, errwrap.Wrapf(fmt.Sprintf("Error decoding '%s' as JSON, please check for syntax errors: {{err}}", p), err) + } + + if time.Duration(conf.MBU) < time.Minute { + return nil, fmt.Errorf("configuration 'mbu': An MBU of less then 1min is not supported, received: '%s'", conf.MBU) + } + + return conf, nil +} diff --git a/timeglass.json b/timeglass.json new file mode 100644 index 0000000..9ae502d --- /dev/null +++ b/timeglass.json @@ -0,0 +1,5 @@ +{ + "mbu": "1m10s", + "commit_message": " [spent {{.}}]", + "auto_push": true +} \ No newline at end of file diff --git a/vcs/git.go b/vcs/git.go index 5a309aa..551e927 100644 --- a/vcs/git.go +++ b/vcs/git.go @@ -1,14 +1,21 @@ package vcs import ( + "bytes" "fmt" + "io" "os" + "os/exec" "path/filepath" + "strings" "text/template" + "time" "github.com/hashicorp/errwrap" ) +var TimeSpentNotesRef = "time-spent" + var PostCheckoutTmpl = template.Must(template.New("name").Parse(`#!/bin/sh # when checkout is a branch, start timer if [ $3 -eq 1 ]; then @@ -27,7 +34,12 @@ esac var PostCommitTmpl = template.Must(template.New("name").Parse(`#!/bin/sh #always reset after commit -glass lap +glass lap --from-hook +`)) + +var PrePushTmpl = template.Must(template.New("name").Parse(`#!/bin/sh +#push time data +glass push $1 --from-hook `)) type Git struct { @@ -40,7 +52,8 @@ func NewGit(dir string) *Git { } } -func (g *Git) Name() string { return "git" } +func (g *Git) DefaultRemote() string { return "origin" } +func (g *Git) Name() string { return "git" } func (g *Git) Supported() bool { fi, err := os.Stat(g.dir) if err != nil || !fi.IsDir() { @@ -50,6 +63,75 @@ func (g *Git) Supported() bool { return true } +func (g *Git) Log(t time.Duration) error { + args := []string{"notes", "--ref=" + TimeSpentNotesRef, "add", "-f", "-m", fmt.Sprintf("total=%s", t)} + cmd := exec.Command("git", args...) + err := cmd.Run() + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to log time '%s' using git command %s: {{err}}", t, args), err) + } + + return nil +} + +func (g *Git) Fetch(remote string) error { + args := []string{"fetch", remote, fmt.Sprintf("refs/notes/%s:refs/notes/%s", TimeSpentNotesRef, TimeSpentNotesRef)} + cmd := exec.Command("git", args...) + buff := bytes.NewBuffer(nil) + + cmd.Stdout = os.Stdout + cmd.Stderr = buff + + err := cmd.Run() + if err != nil && strings.Contains(buff.String(), "Couldn't find remote ref") { + return ErrNoRemoteTimeData + } + + //in other cases present user with git output + _, err2 := io.Copy(os.Stderr, buff) + if err2 != nil { + return err + } + + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to fetch from remote '%s' using git command %s: {{err}}", remote, args), err) + } + + return nil +} + +func (g *Git) Push(remote string, refs string) error { + + //if time ref is already pushed, dont do it again + if strings.Contains(refs, TimeSpentNotesRef) { + return nil + } + + args := []string{"push", remote, fmt.Sprintf("refs/notes/%s", TimeSpentNotesRef)} + cmd := exec.Command("git", args...) + buff := bytes.NewBuffer(nil) + + cmd.Stdout = os.Stdout + cmd.Stderr = buff + + err := cmd.Run() + if err != nil && strings.Contains(buff.String(), "src refspec refs/notes/"+TimeSpentNotesRef+" does not match any") { + return ErrNoLocalTimeData + } + + //in other cases present user with git output + _, err2 := io.Copy(os.Stderr, buff) + if err2 != nil { + return err + } + + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to push to remote '%s' using git command %s: {{err}}", remote, args), err) + } + + return nil +} + func (g *Git) Hook() error { hpath := filepath.Join(g.dir, "hooks") @@ -88,7 +170,7 @@ func (g *Git) Hook() error { //post commit: lap() postcof, err := os.Create(filepath.Join(hpath, "post-commit")) if err != nil { - return errwrap.Wrapf(fmt.Sprintf("Failed to create post-commit '%s': {{err}}", postchf.Name()), err) + return errwrap.Wrapf(fmt.Sprintf("Failed to create post-commit '%s': {{err}}", postchf.Name()), err) } err = postcof.Chmod(0766) @@ -101,5 +183,21 @@ func (g *Git) Hook() error { return errwrap.Wrapf("Failed to run post-commit template: {{err}}", err) } + //post receive: push() + prepushf, err := os.Create(filepath.Join(hpath, "pre-push")) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to create pre-push '%s': {{err}}", postchf.Name()), err) + } + + err = prepushf.Chmod(0766) + if err != nil { + return errwrap.Wrapf(fmt.Sprintf("Failed to make pre-push file '%s' executable: {{err}}", hpath), err) + } + + err = PrePushTmpl.Execute(prepushf, struct{}{}) + if err != nil { + return errwrap.Wrapf("Failed to run pre-push template: {{err}}", err) + } + return nil } diff --git a/vcs/vcs.go b/vcs/vcs.go index 52b04e9..b0d2ca4 100644 --- a/vcs/vcs.go +++ b/vcs/vcs.go @@ -1,14 +1,23 @@ package vcs import ( + "errors" "fmt" "strings" + "time" ) +var ErrNoRemoteTimeData = errors.New("Remote doesn't have any time data") +var ErrNoLocalTimeData = errors.New("Local clone doesn't have any time data") + type VCS interface { Name() string Supported() bool Hook() error + Push(string, string) error + Fetch(string) error + DefaultRemote() string + Log(time.Duration) error } func GetVCS(dir string) (VCS, error) {