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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ jobs:
- run: go vet ./...
- run: go build ./...
- run: go test -race ./...

security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
# Scan dependencies and our own code for known Go vulnerabilities.
- name: govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Generated reports
*.md
!README.md
!SECURITY.md

# Temporary files
temp_*
Expand Down
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ Maintainers can produce all platform binaries with `make release` (output in

---

## Quick start

```bash
commit-chronicle
```

On the **first run** with no repos configured, it walks you through a one-time
setup: it scans the usual places (`~/projects`, `~/work`, `~/code`, the current
folder, …), shows how many git repos each holds, lets you pick one, and offers
to remember it. It also checks whether `gh` is authenticated so PRs and reviews
can be included.

After that, just run `commit-chronicle` from anywhere:

```bash
commit-chronicle # pick range → pick items → edit → export
commit-chronicle --since "7 days ago"
commit-chronicle --date today --copy # also: yesterday, "3 days ago", etc.
```

> Re-run setup any time with `commit-chronicle --setup`.

---

## Usage

Run it inside a git repo, or configure repos/roots (below) to scan many at once:
Expand Down Expand Up @@ -104,7 +128,7 @@ In the editor: `ctrl+s` save · `esc` cancel.
--all select everything (skip the picker)
--no-edit skip the editor step
--no-pr skip GitHub PR + review discovery (git commits only)
--copy copy the worklog to the clipboard
--copy copy the whole worklog to the clipboard (skips the picker)
-h, --help show help
```

Expand Down Expand Up @@ -135,12 +159,41 @@ file format.

---

## Pull requests & reviews

PRs and reviews are **included by default** — there's no flag to turn them on.
All you need is the GitHub CLI, authenticated once:

```bash
gh auth login # one-time
gh auth status # verify
```

With that in place, every run gathers, alongside your commits:

- pull requests **you authored** (tag `PR`)
- pull requests **you reviewed** (tag `review`, dated by your review)
- commits on your PRs that the plain author match might miss

**Fork workflows just work.** If you push to your own `origin` fork but open
PRs and submit reviews against an `upstream` parent, discovery queries *every*
remote — so your reviews on the upstream repo are found automatically.

Pass `--no-pr` if you ever want commits only. No `gh` installed (or not
authenticated) also falls back to git-only, with a one-line note telling you how
to enable PRs/reviews.

---

## How it works

- **Commits** come from `git log --all --author=<you>` across every ref.
- **PRs / reviews** come from `gh` (the GitHub CLI). It lists your PRs in the
window, then fetches commit/review details per-PR — GitHub searches are
date-bounded so it only inspects PRs that could fall in range.
- **Fork-aware:** discovery follows *every* remote of a repo, not just
`origin`. In a fork workflow you push to your `origin` fork but open PRs and
submit reviews against the `upstream` parent, so both are queried.
- Everything is keyed by hash (commits) or repo+number (PRs) and de-duplicated,
so a commit that shows up both in history and on a PR appears once.
- Output is grouped by date; commits, PRs and reviews each render as distinct,
Expand All @@ -156,6 +209,13 @@ No `gh`, or pass `--no-pr`, and it runs git-only.
- **gh**, authenticated (`gh auth login`) — optional, for PR & review discovery
- a clipboard tool for `--copy`: `pbcopy` (macOS), `wl-copy` or `xclip` (Linux)

## Security

All `git`/`gh` calls use an explicit argument vector (no shell), `gh` handles
GitHub auth (no tokens touched here), and commit/PR text is stripped of terminal
escape sequences before display. CI runs `govulncheck` on every push. See
[SECURITY.md](SECURITY.md) for details and how to report issues.

## License

See [LICENSE](LICENSE).
27 changes: 27 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Security

## Reporting a vulnerability

Please report security issues privately via
[GitHub Security Advisories](https://github.com/ashishxcode/commit-chronicle/security/advisories/new)
rather than opening a public issue. We aim to acknowledge reports within a few
days.

## Design notes

commit-chronicle is a local, single-user CLI. It reads your own repositories and
talks to GitHub through the `gh` CLI you have already authenticated. With that in
mind:

- **No shell interpolation.** All `git`/`gh` invocations use `exec.Command` with
an explicit argument vector — never a shell — so repo paths, author names, PR
numbers, and date strings cannot be used for command injection.
- **No credential handling.** GitHub authentication is delegated entirely to
`gh`; this tool never reads, stores, or transmits tokens.
- **Untrusted text is sanitized.** Commit messages and PR titles come from other
people. Before they are shown in the picker, preview, or a worklog printed to
the terminal, `model.CleanText` strips ANSI/terminal escape sequences and
control characters so a crafted message cannot hijack your terminal.
- **Dependency scanning.** CI runs
[`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) on every
push to catch known vulnerabilities in the module and its dependencies.
6 changes: 4 additions & 2 deletions cmd/commit-chronicle/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ func parseFlags() (*app.Config, error) {
fs.BoolVar(&c.NoEdit, "no-edit", false, "skip the editor step")
fs.BoolVar(&c.All, "all", false, "select everything (skip the picker)")
fs.BoolVar(&c.NoPR, "no-pr", false, "skip GitHub PR + review discovery")
fs.BoolVar(&c.Copy, "copy", false, "copy the worklog to the clipboard")
fs.BoolVar(&c.Copy, "copy", false, "copy the whole worklog to the clipboard (skips the picker)")
fs.BoolVar(&c.Setup, "setup", false, "re-run the guided repo setup")
fs.Usage = usage
if err := fs.Parse(os.Args[1:]); err != nil {
return nil, err
Expand Down Expand Up @@ -94,7 +95,8 @@ OPTIONS:
--all select everything (skip the picker)
--no-edit skip the editor step
--no-pr skip GitHub PR + review discovery (git commits only)
--copy copy the worklog to the clipboard
--copy copy the whole worklog to the clipboard (skips the picker)
--setup re-run the guided repo setup (runs automatically on first use)
-h, --help show this help

REPO CONFIG (unioned): --repos · --root · ./.commit-chronicle ·
Expand Down
29 changes: 29 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"version": "0.2",
"language": "en",
"words": [
"ashishxcode",
"bubbletea",
"charmbracelet",
"fzf",
"GOBIN",
"GOPATH",
"gum",
"isatty",
"lipgloss",
"mattn",
"pbcopy",
"unioned",
"usr",
"worklog",
"xclip",
"xdg",
"Culture"
],
"ignorePaths": [
"go.sum",
"go.mod",
"dist/",
"bin/"
]
}
60 changes: 53 additions & 7 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package app

import (
"errors"
"fmt"
"os"
"os/exec"
Expand All @@ -23,6 +24,7 @@ type Config struct {
Author, User, Repos, Root string
Out, Format string
NoEdit, All, Copy, NoPR bool
Setup bool // force the guided first-run setup
}

// Run executes the whole pipeline.
Expand All @@ -31,7 +33,31 @@ func Run(c Config) error {
return fmt.Errorf("git is required but was not found on PATH")
}

repos, err := config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root))
interactive := isTerminal()

// --setup forces the guided flow regardless of existing config.
if c.Setup && !interactive {
return fmt.Errorf("--setup needs an interactive terminal")
}

ranSetup := false
var repos []string
var err error
if c.Setup {
repos, err = firstRunSetup()
ranSetup = true
} else {
repos, err = config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root))
if errors.Is(err, config.ErrNoRepos) {
if !interactive {
return fmt.Errorf("no repositories configured.\n" +
" Point at repos with --repos <path,…> or --root <dir>, or configure\n" +
" ~/.config/commit-chronicle/{repos,roots}. Run in a terminal for guided setup.")
}
repos, err = firstRunSetup()
ranSetup = true
}
}
if err != nil {
return err
}
Expand All @@ -44,8 +70,6 @@ func Run(c Config) error {
return fmt.Errorf("could not determine author; pass --author \"Your Name\"")
}

interactive := isTerminal()

rng, err := resolveRange(c, interactive)
if err != nil {
return err
Expand All @@ -56,6 +80,15 @@ func Run(c Config) error {
if ghUser == "" && !c.NoPR && collect.HasGH() {
ghUser = ghLogin()
}
// If PRs/reviews were wanted but gh can't provide them, say so once (setup
// already reports gh status, so skip the note right after first-run setup).
if !c.NoPR && !ranSetup && ghUser == "" {
if !collect.HasGH() {
fmt.Fprintln(os.Stderr, "ℹ️ gh not found — commits only (install gh + `gh auth login` for PRs & reviews).")
} else {
fmt.Fprintln(os.Stderr, "ℹ️ gh not authenticated — commits only (run `gh auth login` for PRs & reviews).")
}
}

opts := collect.Options{
Repos: repos,
Expand Down Expand Up @@ -85,12 +118,25 @@ func Run(c Config) error {
return err
}
if len(items) == 0 {
return fmt.Errorf("nothing found for \"%s\" in range (%s)", author, rng.Label)
var b strings.Builder
fmt.Fprintf(&b, "nothing found for \"%s\" in range (%s)\n", author, rng.Label)
b.WriteString(" • try a wider range, e.g. --since \"30 days ago\"\n")
b.WriteString(" • check the name matches your commits: --author \"Your Name\"\n")
switch {
case c.NoPR:
b.WriteString(" • drop --no-pr to include PRs & reviews")
case ghUser == "":
b.WriteString(" • authenticate gh (`gh auth login`) to include PRs & reviews")
default:
b.WriteString(" • PR/review activity may live on a different remote — those are scanned automatically")
}
return fmt.Errorf("%s", b.String())
}

// Pick
// Pick. --all and --copy both take everything; --copy is meant to be a
// one-shot "scan and copy the lot", so it skips the picker entirely.
selected := items
if !c.All {
if !c.All && !c.Copy {
if !interactive {
return fmt.Errorf("no TTY for the picker; re-run with --all or in a terminal")
}
Expand All @@ -112,7 +158,7 @@ func Run(c Config) error {
content = render.JSON(selected, meta)
} else {
content = render.Markdown(selected, meta)
if !c.NoEdit && interactive {
if !c.NoEdit && interactive && !c.Copy {
edited, canceled, err := tui.Edit(content)
if err != nil {
return err
Expand Down
Loading
Loading