Merge PR #403: feat(shortcut): 新增 commit 命令模块(提交历史/diff/blame)

# Conflicts:
#	shortcuts/commit/commit.go
#	shortcuts/commit/commit_test.go
#	shortcuts/register_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:03 +08:00
commit 54b4fd2a4c
5 changed files with 186 additions and 95 deletions

View File

@ -0,0 +1,44 @@
# Commit shortcut
新增 `commit` Shortcut 组,封装 GitLink 仓库提交Commit相关 OpenAPI 的常用操作,支持查看提交列表、单条提交变更文件、提交 Diff 与文件 Blame
- `commit +list`
- `commit +view`
- `commit +diff`
- `commit +blame`
实现要点:
- `+list` 映射 `GET /v1/{owner}/{repo}/commits`,支持 `--sha`(分支 / 标签 / 提交 SHA 过滤)、`--page`(默认 `1`)、`--limit`(默认 `20`),通过查询参数传给 API。
- `+view` 映射 `GET /v1/{owner}/{repo}/commits/{sha}/files`,返回某次提交涉及的文件清单;`--sha` 为必填,同时支持分页参数。
- `+diff` 映射 `GET /v1/{owner}/{repo}/commits/{sha}/diff`,返回指定提交的 Diff 内容;`--sha` 为必填。
- `+blame` 映射 `GET /v1/{owner}/{repo}/blame`,按文件展示逐行归属;`--path` 为必填,`--sha` 缺省为 `master`,二者以 `filepath` / `sha` 查询参数提交。
- 四个命令均通过 `ResolveOwnerRepo()` 解析 `--owner` / `--repo`(支持 `-R owner/repo` 缩写与仓库默认推断),结果统一经 `ctx.Output(env)` 输出,兼容 `--format`table / json等全局参数。
- 路径沿用 `/v1/` 前缀约定,与 webhook / milestone / label 等组保持一致;`.json` 后缀由底层 client 自动补全。
## Examples
```bash
# 列出 develop 分支最近 10 条提交
gitlink-cli commit +list --owner Gitlink --repo forgeplus --sha develop --limit 10
# 查看某次提交涉及的文件
gitlink-cli commit +view --owner Gitlink --repo forgeplus --sha abc123def
# 查看提交 Diff
gitlink-cli commit +diff --owner Gitlink --repo forgeplus --sha abc123def
# 查看 README.md 在 master 分支的 Blame 信息
gitlink-cli commit +blame --owner Gitlink --repo forgeplus --path README.md
# 使用 -R 缩写并以 JSON 输出
gitlink-cli commit +list -R Gitlink/forgeplus --page 2 --format json
```
## Tests
```bash
GOPROXY=https://goproxy.cn,direct go test ./shortcuts/commit/...
go test ./...
go run . commit +list --help
```

View File

@ -7,12 +7,11 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns repository commit inspection shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository commits",
Description: "List commits in a repository",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
@ -20,43 +19,43 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
setQuery(q, "sha", ctx.Arg("sha"))
setQuery(q, "page", ctx.Arg("page"))
setQuery(q, "limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", commitRepoPath(ctx)+"/commits", q)
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", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
return fmt.Errorf("获取提交列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "files",
Description: "List changed files for a commit",
Name: "view",
Description: "View files changed in a commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
{Name: "filepath", Short: "f", Usage: "Filter by file path"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
q := url.Values{}
setQuery(q, "filepath", ctx.Arg("filepath"))
setQuery(q, "page", ctx.Arg("page"))
setQuery(q, "limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/commits/%s/files", commitRepoPath(ctx), url.PathEscape(sha)), q)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return err
return fmt.Errorf("查看提交详情失败: %w", err)
}
return ctx.Output(env)
},
@ -69,57 +68,43 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", commitRepoPath(ctx), url.PathEscape(sha)), nil)
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
return fmt.Errorf("获取提交 Diff 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "blame",
Description: "Show blame information for a file",
Description: "Show blame for a file",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Required: true},
{Name: "filepath", Short: "f", Usage: "File path", Required: true},
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
filepath, err := ctx.RequireArg("filepath")
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
q := url.Values{}
q.Set("sha", sha)
q.Set("filepath", filepath)
env, err := ctx.CallAPIWithQuery("GET", commitRepoPath(ctx)+"/blame", q)
q.Set("filepath", filePath)
q.Set("sha", ctx.Arg("sha"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
return fmt.Errorf("获取 Blame 信息失败: %w", err)
}
return ctx.Output(env)
},
},
}
}
func commitRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func setQuery(q url.Values, key, value string) {
if value != "" {
q.Set(key, value)
}
}

View File

@ -2,6 +2,7 @@ package commit
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@ -11,91 +12,149 @@ import (
)
func TestCommitList(t *testing.T) {
server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/commits.json")
assertEqual(t, r.URL.Query().Get("sha"), "master")
assertEqual(t, r.URL.Query().Get("page"), "2")
writeJSON(t, w, map[string]interface{}{"total_count": 0, "commits": []interface{}{}})
})
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)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"commits": []map[string]interface{}{
{"sha": "abc123", "commit_message": "initial commit"},
},
})
}))
defer server.Close()
if err := runCommitShortcut(t, server, "list", map[string]string{"sha": "master", "page": "2", "limit": "20"}); err != nil {
t.Fatalf("list failed: %v", err)
err := runCommitShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestCommitFiles(t *testing.T) {
server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc/files.json")
assertEqual(t, r.URL.Query().Get("filepath"), "README.md")
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
})
func TestCommitListWithSHA(t *testing.T) {
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)
}
if r.URL.Query().Get("sha") != "develop" {
t.Fatalf("expected sha=develop, got %s", r.URL.Query().Get("sha"))
}
writeJSON(t, w, map[string]interface{}{
"total_count": 0,
"commits": []map[string]interface{}{},
})
}))
defer server.Close()
if err := runCommitShortcut(t, server, "files", map[string]string{"sha": "abc", "filepath": "README.md"}); err != nil {
t.Fatalf("files failed: %v", err)
err := runCommitShortcut(t, server, "list", map[string]string{"sha": "develop"})
if err != nil {
t.Fatalf("list with sha failed: %v", err)
}
}
func TestCommitView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/files.json" {
writeJSON(t, w, map[string]interface{}{
"file_nums": 1,
"files": []map[string]interface{}{
{"filename": "main.go", "additions": 10, "deletions": 2},
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestCommitDiff(t *testing.T) {
server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/commits/abc/diff.json")
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/diff.json" {
writeJSON(t, w, map[string]interface{}{
"file_nums": 1,
"total_addition": 10,
"total_deletion": 2,
"files": []map[string]interface{}{{"name": "main.go"}},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
if err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc"}); err != nil {
t.Fatalf("diff failed: %v", err)
err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"})
if err != nil {
t.Fatalf("diff shortcut failed: %v", err)
}
}
func TestCommitBlame(t *testing.T) {
server := newCommitServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/blame.json")
assertEqual(t, r.URL.Query().Get("sha"), "master")
assertEqual(t, r.URL.Query().Get("filepath"), "README.md")
writeJSON(t, w, map[string]interface{}{"file_name": "README.md"})
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/blame.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("filepath") != "main.go" {
t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath"))
}
writeJSON(t, w, map[string]interface{}{
"file_name": "main.go",
"num_lines": 20,
})
}))
defer server.Close()
if err := runCommitShortcut(t, server, "blame", map[string]string{"sha": "master", "filepath": "README.md"}); err != nil {
t.Fatalf("blame failed: %v", err)
err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go", "sha": "master"})
if err != nil {
t.Fatalf("blame shortcut failed: %v", err)
}
}
// === helpers ===
func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
var shortcut *common.Shortcut
shortcut := findCommitShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findCommitShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
shortcut = s
return s
}
}
if shortcut == nil {
t.Fatalf("shortcut %q not found", name)
}
return shortcut.Run(&common.RuntimeContext{Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "owner", Repo: "repo", Format: "json", Args: args})
}
func newCommitServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("write json: %v", err)
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got != want {
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

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/feedback"
@ -48,6 +49,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"commit": commit.Shortcuts(),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"feedback": feedback.Shortcuts(),
@ -71,6 +73,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"commit": "Commit history, view, diff and blame operations",
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"feedback": "Submit feedback and suggestions",

View File

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