feat(commit): add commit group (+list, +recent, +diff, +files)

The CLI had no way to read commit history at all. New group wrapping
Api::V1::Projects::CommitsController:

- commit +list [-s ref]: paged commit list, optionally from a
  branch/tag/SHA
- commit +recent [-k keyword]: recent commits with keyword filter
- commit +diff -s <sha>: full diff of a commit
- commit +files -s <sha> [-f filepath]: files changed by a commit,
  optionally restricted to one path

Production-verified on gitlink.org.cn against a 6700+-commit repo
(list totals, keyword-filtered recent, real SHA diff/files). 3 unit
tests, README section, bilingual i18n keys.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1os21ka23r9navae6mrro 2026-07-08 14:55:31 +00:00
parent c09645da62
commit 1bd6e667a7
7 changed files with 261 additions and 1 deletions

View File

@ -872,3 +872,18 @@ See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/
## License
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
### Commits
```bash
# List commits (optionally from a ref)
gitlink-cli commit +list --owner Gitlink --repo forgeplus -s develop
# Recent commits filtered by keyword
gitlink-cli commit +recent --owner Gitlink --repo forgeplus -k fix
# Diff and changed files of a commit
gitlink-cli commit +diff --owner Gitlink --repo forgeplus -s <sha>
gitlink-cli commit +files --owner Gitlink --repo forgeplus -s <sha> -f app/models/user.rb
```

View File

@ -16,6 +16,11 @@
"cmd.ci.restart.short": "Restart a build",
"cmd.ci.short": "CI/CD operations",
"cmd.ci.stop.short": "Stop a build",
"cmd.commit.diff.short": "Show the diff of a commit",
"cmd.commit.files.short": "List files changed by a commit",
"cmd.commit.list.short": "List commits, optionally starting from a ref",
"cmd.commit.recent.short": "List recent commits, filterable by keyword",
"cmd.commit.short": "Commit operations",
"cmd.config.get.short": "Get a configuration value",
"cmd.config.init.short": "Initialize configuration file",
"cmd.config.list.short": "List all configuration values",
@ -130,6 +135,10 @@
"flag.ci.stage": "Stage number",
"flag.ci.step": "Step number",
"flag.comment.body": "Comment body",
"flag.commit.filepath": "Restrict to a single file path",
"flag.commit.keyword": "Filter commits by keyword",
"flag.commit.sha": "Commit SHA",
"flag.commit.sha_start": "Ref (branch, tag, or SHA) to list commits from",
"flag.dataset.description": "Dataset description",
"flag.dataset.dry_run": "Preview the request without writing the dataset",
"flag.dataset.dry_run_delete": "Preview the request without deleting the attachment",

View File

@ -16,6 +16,11 @@
"cmd.ci.restart.short": "重启构建",
"cmd.ci.short": "CI/CD 操作",
"cmd.ci.stop.short": "停止构建",
"cmd.commit.diff.short": "查看提交的 diff",
"cmd.commit.files.short": "列出提交变更的文件",
"cmd.commit.list.short": "列出提交,可从指定 ref 开始",
"cmd.commit.recent.short": "列出最近提交,可按关键字过滤",
"cmd.commit.short": "提交操作",
"cmd.config.get.short": "获取配置项",
"cmd.config.init.short": "初始化配置文件",
"cmd.config.list.short": "列出所有配置项",
@ -130,6 +135,10 @@
"flag.ci.stage": "阶段编号",
"flag.ci.step": "步骤编号",
"flag.comment.body": "评论内容",
"flag.commit.filepath": "仅限单个文件路径",
"flag.commit.keyword": "按关键字过滤提交",
"flag.commit.sha": "提交 SHA",
"flag.commit.sha_start": "起始 ref分支、标签或 SHA",
"flag.dataset.description": "数据集描述",
"flag.dataset.dry_run": "预览请求,不写入数据集",
"flag.dataset.dry_run_delete": "预览请求,不删除附件",

127
shortcuts/commit/commit.go Normal file
View File

@ -0,0 +1,127 @@
package commit
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
{
Name: "list",
Description: tr.T("cmd.commit.list.short"),
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha_start")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if sha := ctx.Arg("sha"); sha != "" {
q.Set("sha", sha)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "recent",
Description: tr.T("cmd.commit.recent.short"),
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: tr.T("flag.commit.keyword")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits/recent", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: tr.T("cmd.commit.diff.short"),
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", v1RepoPath(ctx), url.PathEscape(sha)), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "files",
Description: tr.T("cmd.commit.files.short"),
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha"), Required: true},
{Name: "filepath", Short: "f", Usage: tr.T("flag.commit.filepath")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/commits/%s/files", v1RepoPath(ctx), url.PathEscape(sha)), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

View File

@ -0,0 +1,97 @@
package commit
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return s.Run(ctx)
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func TestCommitListForwardsShaAndPaging(t *testing.T) {
var query string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
query = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"total_count":1,"commits":[]}`))
}))
defer server.Close()
if err := runShortcut(t, server, "list", map[string]string{"sha": "develop", "page": "2", "limit": "5"}); err != nil {
t.Fatalf("list failed: %v", err)
}
for _, want := range []string{"sha=develop", "page=2", "limit=5"} {
if !strings.Contains(query, want) {
t.Fatalf("expected %q in query, got %q", want, query)
}
}
}
func TestCommitRecentForwardsKeyword(t *testing.T) {
var query string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits/recent.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
query = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"commits":[]}`))
}))
defer server.Close()
if err := runShortcut(t, server, "recent", map[string]string{"keyword": "fix", "page": "1", "limit": "20"}); err != nil {
t.Fatalf("recent failed: %v", err)
}
if !strings.Contains(query, "keyword=fix") {
t.Fatalf("expected keyword=fix in query, got %q", query)
}
}
func TestCommitDiffAndFilesBuildShaPaths(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
if err := runShortcut(t, server, "diff", map[string]string{"sha": "abc123"}); err != nil {
t.Fatalf("diff failed: %v", err)
}
if err := runShortcut(t, server, "files", map[string]string{"sha": "abc123", "filepath": "app/x.rb", "page": "1", "limit": "20"}); err != nil {
t.Fatalf("files failed: %v", err)
}
if paths[0] != "/v1/owner/repo/commits/abc123/diff.json" {
t.Fatalf("diff path = %s", paths[0])
}
if paths[1] != "/v1/owner/repo/commits/abc123/files.json" {
t.Fatalf("files path = %s", paths[1])
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
@ -51,6 +52,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"commit": commit.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
@ -76,6 +78,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"commit": tr.T("cmd.commit.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),

View File

@ -13,7 +13,7 @@ func TestRegisterAll(t *testing.T) {
expectedGroups := []string{
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"commit", "compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki",
}