From 22ee0c6ee7912f6592745bd8fee9e6ddb622db7c Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 2 Sep 2026 22:08:56 +0800 Subject: [PATCH] feat(cli): flatten ggc into top-level git commands - add top-level status/add/log/diff/branch/fetch/rebase/remote commands backed by shared utils.RunGit with stdio passthrough - pull: add --rebase flag (mutually exclusive with --all/--hard) - tag: add show subcommand (git show ) - rename ggccmd to uicmd: fastgit ui keeps the interactive fuzzy picker, aliases and workflows; ggc.yaml path/format unchanged - drop ggc commit entry: use git commit -m or fastgit commit (AI) - update README, docs and changelog --- .version/changelog/Unreleased.md | 17 ++-- README.md | 60 +++++++------ bootstrap/boot.go | 20 ++++- cmds/addcmd/cmd.go | 32 +++++++ cmds/addcmd/cmd_test.go | 26 ++++++ cmds/branchcmd/cmd.go | 120 ++++++++++++++++++++++++++ cmds/branchcmd/cmd_test.go | 44 ++++++++++ cmds/diffcmd/cmd.go | 56 ++++++++++++ cmds/diffcmd/cmd_test.go | 32 +++++++ cmds/docscmd/assets.go | 2 +- cmds/fetchcmd/cmd.go | 36 ++++++++ cmds/fetchcmd/cmd_test.go | 17 ++++ cmds/logcmd/cmd.go | 34 ++++++++ cmds/logcmd/cmd_test.go | 17 ++++ cmds/pullcmd/cmd.go | 43 +++++++-- cmds/pullcmd/cmd_test.go | 32 +++++++ cmds/rebasecmd/cmd.go | 85 ++++++++++++++++++ cmds/rebasecmd/cmd_test.go | 48 +++++++++++ cmds/remotecmd/cmd.go | 31 +++++++ cmds/remotecmd/cmd_test.go | 11 +++ cmds/statuscmd/cmd.go | 34 ++++++++ cmds/statuscmd/cmd_test.go | 17 ++++ cmds/tagcmd/cmd.go | 25 ++++++ cmds/tagcmd/cmd_test.go | 13 +++ cmds/{ggccmd => uicmd}/alias.go | 2 +- cmds/{ggccmd => uicmd}/alias_test.go | 2 +- cmds/{ggccmd => uicmd}/cmd.go | 10 +-- cmds/{ggccmd => uicmd}/executor.go | 2 +- cmds/{ggccmd => uicmd}/interactive.go | 4 +- cmds/{ggccmd => uicmd}/registry.go | 8 +- cmds/{ggccmd => uicmd}/store.go | 2 +- docs/architecture.md | 4 +- docs/features.md | 14 +-- docs/roadmap.md | 8 +- utils/git.go | 15 ++++ utils/git_test.go | 19 +++- 36 files changed, 873 insertions(+), 69 deletions(-) create mode 100644 cmds/addcmd/cmd.go create mode 100644 cmds/addcmd/cmd_test.go create mode 100644 cmds/branchcmd/cmd.go create mode 100644 cmds/branchcmd/cmd_test.go create mode 100644 cmds/diffcmd/cmd.go create mode 100644 cmds/diffcmd/cmd_test.go create mode 100644 cmds/fetchcmd/cmd.go create mode 100644 cmds/fetchcmd/cmd_test.go create mode 100644 cmds/logcmd/cmd.go create mode 100644 cmds/logcmd/cmd_test.go create mode 100644 cmds/rebasecmd/cmd.go create mode 100644 cmds/rebasecmd/cmd_test.go create mode 100644 cmds/remotecmd/cmd.go create mode 100644 cmds/remotecmd/cmd_test.go create mode 100644 cmds/statuscmd/cmd.go create mode 100644 cmds/statuscmd/cmd_test.go rename cmds/{ggccmd => uicmd}/alias.go (99%) rename cmds/{ggccmd => uicmd}/alias_test.go (98%) rename cmds/{ggccmd => uicmd}/cmd.go (84%) rename cmds/{ggccmd => uicmd}/executor.go (99%) rename cmds/{ggccmd => uicmd}/interactive.go (99%) rename cmds/{ggccmd => uicmd}/registry.go (98%) rename cmds/{ggccmd => uicmd}/store.go (99%) diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md index ba5cd5b..73606e3 100644 --- a/.version/changelog/Unreleased.md +++ b/.version/changelog/Unreleased.md @@ -4,7 +4,10 @@ ## 新增 -暂无 +- 新增顶层 git 动词子命令:`fastgit status|status short`、`fastgit add`、`fastgit log|log graph`、`fastgit diff [--staged|--unstaged]`、`fastgit branch current|list|checkout|checkout-remote|create|delete`、`fastgit fetch [--prune]`、`fastgit rebase |--continue|--abort|--skip`、`fastgit remote|remote list` +- `fastgit pull` 新增 `--rebase` flag(rebase 方式拉取,与 `--all`/`--hard` 互斥) +- `fastgit tag show `:查看 tag 详情(`git show `) +- `fastgit ui`:原 `ggc` 交互命令面(fuzzy 搜索 + workflow + alias),配置文件仍为 `ggc.yaml`,路径不变 ## 修复 @@ -12,20 +15,22 @@ ## 变更 -暂无 +- 移除 `fastgit ggc` 统一入口,原有命令摊平为顶层子命令;`ggc pull current|pull rebase`、`ggc push current|push force`、`ggc tag list`、`ggc commit ` 分别由 `pull`、`push`、`tag`、`git commit -m` / `fastgit commit` 承接 ## 文档 -暂无 +- README、docs/features、docs/architecture、docs/roadmap 同步新命令面 ## 影响范围 -暂无 +- CLI 命令面:`fastgit ggc` 用户需改用顶层子命令或 `fastgit ui` +- `ggc.yaml`(workflow/alias)路径与格式不变,无需迁移 ## 验证建议 -暂无 +- `go build ./... && go test ./...` +- `fastgit status`、`fastgit branch current`、`fastgit remote`、`fastgit pull --help`(确认 `--rebase`)、`fastgit ui list`、`fastgit ui path` ## 回滚建议 -暂无 +- 回退本次提交即可恢复 `fastgit ggc` 入口 diff --git a/README.md b/README.md index 6adf388..a94ea91 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,25 @@ wails3 dev - `fastgit changelog draft`: 使用 Copilot 根据当前改动更新 `Unreleased.md` - `fastgit changelog release`: 将 `Unreleased.md` 落版为版本文件,并可同步推进 `.version/VERSION` - `fastgit docs init`: 初始化文档维护用的 prompt / instruction 模板 -- `fastgit pull`: 拉取当前分支(支持 `--all`) +- `fastgit pull`: 拉取当前分支(支持 `--all` / `--rebase`) - `fastgit pull --hard`: 强制与远端同步(`fetch + reset --hard`) - `fastgit push`: 推送当前分支(支持 `--all` / `--force`) - `fastgit worktree`: 列出当前仓库 worktree - `fastgit worktree create [--base ]`: 创建 worktree - `fastgit worktree remove `: 删除 worktree - `fastgit worktree remove --path `: 按路径删除 worktree -- `fastgit ggc list`: 查看统一命令面(ggc 风格) -- `fastgit ggc `: 执行统一命令,例如 `fastgit ggc status short` -- `fastgit ggc` / `fastgit ggc interactive`: 进入交互模式(增量搜索 + workflow) -- `fastgit ggc path`: 查看当前 `ggc.yaml` 的实际路径(按 OS/XDG 规则) +- `fastgit status` / `fastgit status short`: 查看工作区状态 +- `fastgit add `: 暂存文件 +- `fastgit log` / `fastgit log graph`: 查看提交记录 +- `fastgit diff [--staged|--unstaged]`: 查看改动 +- `fastgit branch current|list|checkout |checkout-remote |create |delete `: 分支操作 +- `fastgit fetch [--prune]`: 拉取远端更新 +- `fastgit rebase ` / `fastgit rebase --continue|--abort|--skip`: 变基操作 +- `fastgit remote` / `fastgit remote list`: 查看远程仓库 +- `fastgit tag show `: 查看 tag 详情 +- `fastgit ui`: 进入交互模式(fuzzy 搜索 + workflow + alias) +- `fastgit ui list`: 查看交互命令面(含 alias) +- `fastgit ui path`: 查看当前 `ggc.yaml` 的实际路径(按 OS/XDG 规则) ## Repo Prompt Templates @@ -99,23 +107,27 @@ wails3 dev > 建议:把“生成建议”和“执行提交”区分成不同 prompt,便于在不同风险场景下选择更稳妥的工作流。 -## New ggc-style command surface (phase 1) +## Git command surface -- `fastgit ggc status|status short` -- `fastgit ggc add ` -- `fastgit ggc commit ` -- `fastgit ggc log simple|graph` -- `fastgit ggc diff|diff staged|diff unstaged` -- `fastgit ggc branch current|list local|list remote|checkout |checkout remote |create |delete ` -- `fastgit ggc fetch|fetch prune` -- `fastgit ggc pull current|pull rebase` -- `fastgit ggc push current|push force` -- `fastgit ggc rebase |continue|abort|skip` -- `fastgit ggc tag list|show ` -- `fastgit ggc remote list` +常用 git 操作已摊平为顶层子命令(原 `ggc` 统一入口已拆分): -## Interactive Mode (phase 2 - MVP) +- `fastgit status|status short` +- `fastgit add ` +- `fastgit log|log graph` +- `fastgit diff [--staged|--unstaged]` +- `fastgit branch current|list [--remote]|checkout |checkout-remote |create |delete ` +- `fastgit fetch [--prune]` +- `fastgit pull [--all|--rebase|--hard]` +- `fastgit push [--all|--force]` +- `fastgit rebase |--continue|--abort|--skip` +- `fastgit tag list|show ` +- `fastgit remote|remote list` +> 原 `fastgit ggc commit ` 已移除:带 message 的提交直接使用 `git commit -m`,AI 提交使用 `fastgit commit`。 + +## Interactive Mode + +- 入口:`fastgit ui` 或 `fastgit ui interactive` - 搜索模式(默认) - 输入字符:实时 fuzzy 过滤命令 - `↑/↓` 或 `Ctrl+N/P`:移动选中 @@ -133,12 +145,12 @@ wails3 dev > 对于带占位参数的命令(如 ``),执行时会自动提示输入参数。 -## Phase 3: Workflow 持久化 + Alias +## Workflow 持久化 + Alias - workflow 会持久化到:`/fastgit/ggc.yaml` - macOS 常见为:`~/Library/Application Support/fastgit/ggc.yaml` - Linux 常见为:`~/.config/fastgit/ggc.yaml` -- 每次进入 `fastgit ggc` 交互模式会自动加载上次 workflow +- 每次进入 `fastgit ui` 交互模式会自动加载上次 workflow - 在交互模式里对 workflow 的新增/删除/清空会自动保存 ### Alias 配置 @@ -163,9 +175,9 @@ aliases: 说明: - `{0}`、`{1}`... 表示位置参数 -- 例如:`fastgit ggc ci "fix typo"` -- 例如:`fastgit ggc quick "chore: update"` -- `fastgit ggc list` 会同时显示内置命令与 alias +- 例如:`fastgit ui ci "fix typo"` +- 例如:`fastgit ui quick "chore: update"` +- `fastgit ui list` 会同时显示内置命令与 alias ## Refer - https://github.com/Nutlope/aicommits diff --git a/bootstrap/boot.go b/bootstrap/boot.go index aac285c..87e57b2 100644 --- a/bootstrap/boot.go +++ b/bootstrap/boot.go @@ -10,24 +10,32 @@ import ( "github.com/charmbracelet/x/term" "github.com/pubgo/dix/v2" "github.com/pubgo/dix/v2/dixcontext" + "github.com/pubgo/fastgit/cmds/addcmd" + "github.com/pubgo/fastgit/cmds/branchcmd" "github.com/pubgo/fastgit/cmds/checkcmd" "github.com/pubgo/fastgit/cmds/chglogcmd" "github.com/pubgo/fastgit/cmds/conflictcmd" "github.com/pubgo/fastgit/cmds/teamcmd" "github.com/pubgo/fastgit/cmds/configcmd" "github.com/pubgo/fastgit/cmds/copilotcmd" + "github.com/pubgo/fastgit/cmds/diffcmd" "github.com/pubgo/fastgit/cmds/docscmd" "github.com/pubgo/fastgit/cmds/fastcommitcmd" - "github.com/pubgo/fastgit/cmds/ggccmd" + "github.com/pubgo/fastgit/cmds/fetchcmd" "github.com/pubgo/fastgit/cmds/historycmd" "github.com/pubgo/fastgit/cmds/initcmd" + "github.com/pubgo/fastgit/cmds/logcmd" "github.com/pubgo/fastgit/cmds/prcmd" "github.com/pubgo/fastgit/cmds/pullcmd" "github.com/pubgo/fastgit/cmds/pushcmd" + "github.com/pubgo/fastgit/cmds/rebasecmd" + "github.com/pubgo/fastgit/cmds/remotecmd" "github.com/pubgo/fastgit/cmds/reviewcmd" "github.com/pubgo/fastgit/cmds/sshcmd" + "github.com/pubgo/fastgit/cmds/statuscmd" "github.com/pubgo/fastgit/cmds/tagcmd" "github.com/pubgo/fastgit/cmds/upgradecmd" + "github.com/pubgo/fastgit/cmds/uicmd" "github.com/pubgo/fastgit/cmds/versioncmd" "github.com/pubgo/fastgit/cmds/worktreecmd" "github.com/pubgo/fastgit/pkg/aiprovider" @@ -46,10 +54,18 @@ func Main() { versioncmd.New(), initcmd.New(), upgradecmd.New(), + statuscmd.New(), + addcmd.New(), + logcmd.New(), + diffcmd.New(), + branchcmd.New(), + fetchcmd.New(), + rebasecmd.New(), + remotecmd.New(), tagcmd.New(), sshcmd.New(), historycmd.New(), - ggccmd.New(), + uicmd.New(), fastcommitcmd.New(), checkcmd.New(), conflictcmd.New(), diff --git a/cmds/addcmd/cmd.go b/cmds/addcmd/cmd.go new file mode 100644 index 0000000..00d156c --- /dev/null +++ b/cmds/addcmd/cmd.go @@ -0,0 +1,32 @@ +package addcmd + +import ( + "context" + "fmt" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + return &redant.Command{ + Use: "add ", + Short: "Stage files", + Handler: func(ctx context.Context, i *redant.Invocation) error { + args, err := addArgs(i.Args...) + if err != nil { + return err + } + + return utils.RunGit(ctx, args...) + }, + } +} + +func addArgs(files ...string) ([]string, error) { + if len(files) == 0 { + return nil, fmt.Errorf("usage: add ") + } + + return append([]string{"add"}, files...), nil +} diff --git a/cmds/addcmd/cmd_test.go b/cmds/addcmd/cmd_test.go new file mode 100644 index 0000000..2db3eb8 --- /dev/null +++ b/cmds/addcmd/cmd_test.go @@ -0,0 +1,26 @@ +package addcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAddArgs(t *testing.T) { + t.Run("stage all", func(t *testing.T) { + args, err := addArgs(".") + require.NoError(t, err) + require.Equal(t, []string{"add", "."}, args) + }) + + t.Run("stage multiple files", func(t *testing.T) { + args, err := addArgs("a.go", "b.go") + require.NoError(t, err) + require.Equal(t, []string{"add", "a.go", "b.go"}, args) + }) + + t.Run("no files is an error", func(t *testing.T) { + _, err := addArgs() + require.ErrorContains(t, err, "usage") + }) +} diff --git a/cmds/branchcmd/cmd.go b/cmds/branchcmd/cmd.go new file mode 100644 index 0000000..2841abd --- /dev/null +++ b/cmds/branchcmd/cmd.go @@ -0,0 +1,120 @@ +package branchcmd + +import ( + "context" + "fmt" + "strings" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + var flags = new(struct { + listRemote bool + }) + + return &redant.Command{ + Use: "branch", + Short: "Branch inspect and switch shortcuts", + Children: []*redant.Command{ + { + Use: "current", + Short: "Show current branch", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, "branch", "--show-current") + }, + }, + { + Use: "list", + Short: "List branches (--remote for remote branches)", + Options: []redant.Option{ + { + Flag: "remote", + Description: "List remote branches", + Value: redant.BoolOf(&flags.listRemote), + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + if flags.listRemote { + return utils.RunGit(ctx, "branch", "-r") + } + return utils.RunGit(ctx, "branch") + }, + }, + { + Use: "checkout", + Short: "Checkout branch", + Handler: func(ctx context.Context, i *redant.Invocation) error { + name, err := requireOneArg(i.Args, "branch checkout ") + if err != nil { + return err + } + return utils.RunGit(ctx, "checkout", name) + }, + }, + { + Use: "checkout-remote", + Short: "Checkout remote branch to a tracking local branch", + Handler: func(ctx context.Context, i *redant.Invocation) error { + name, err := requireOneArg(i.Args, "branch checkout-remote ") + if err != nil { + return err + } + args, err := checkoutRemoteArgs(name) + if err != nil { + return err + } + return utils.RunGit(ctx, args...) + }, + }, + { + Use: "create", + Short: "Create and checkout new branch", + Handler: func(ctx context.Context, i *redant.Invocation) error { + name, err := requireOneArg(i.Args, "branch create ") + if err != nil { + return err + } + return utils.RunGit(ctx, "checkout", "-b", name) + }, + }, + { + Use: "delete", + Short: "Delete local branch", + Handler: func(ctx context.Context, i *redant.Invocation) error { + name, err := requireOneArg(i.Args, "branch delete ") + if err != nil { + return err + } + return utils.RunGit(ctx, "branch", "-d", name) + }, + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + return redant.DefaultHelpFn()(ctx, i) + }, + } +} + +func requireOneArg(args []string, usage string) (string, error) { + if len(args) != 1 { + return "", fmt.Errorf("usage: %s", usage) + } + return strings.TrimSpace(args[0]), nil +} + +func checkoutRemoteArgs(name string) ([]string, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("usage: branch checkout-remote ") + } + + remote := name + if !strings.HasPrefix(remote, "origin/") { + remote = "origin/" + remote + } + + local := strings.TrimPrefix(remote, "origin/") + return []string{"checkout", "-b", local, "--track", remote}, nil +} diff --git a/cmds/branchcmd/cmd_test.go b/cmds/branchcmd/cmd_test.go new file mode 100644 index 0000000..80bd53d --- /dev/null +++ b/cmds/branchcmd/cmd_test.go @@ -0,0 +1,44 @@ +package branchcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckoutRemoteArgs(t *testing.T) { + t.Run("bare branch name gets origin prefix", func(t *testing.T) { + args, err := checkoutRemoteArgs("main") + require.NoError(t, err) + require.Equal(t, []string{"checkout", "-b", "main", "--track", "origin/main"}, args) + }) + + t.Run("nested branch name keeps local path", func(t *testing.T) { + args, err := checkoutRemoteArgs("origin/feature/demo") + require.NoError(t, err) + require.Equal(t, []string{"checkout", "-b", "feature/demo", "--track", "origin/feature/demo"}, args) + }) + + t.Run("empty name is rejected", func(t *testing.T) { + _, err := checkoutRemoteArgs("") + require.Error(t, err) + }) +} + +func TestRequireOneArg(t *testing.T) { + t.Run("single arg passes", func(t *testing.T) { + name, err := requireOneArg([]string{"feature"}, "branch create ") + require.NoError(t, err) + require.Equal(t, "feature", name) + }) + + t.Run("no arg fails with usage", func(t *testing.T) { + _, err := requireOneArg(nil, "branch create ") + require.ErrorContains(t, err, "usage: branch create ") + }) + + t.Run("multiple args fail", func(t *testing.T) { + _, err := requireOneArg([]string{"a", "b"}, "branch create ") + require.Error(t, err) + }) +} diff --git a/cmds/diffcmd/cmd.go b/cmds/diffcmd/cmd.go new file mode 100644 index 0000000..97fdce5 --- /dev/null +++ b/cmds/diffcmd/cmd.go @@ -0,0 +1,56 @@ +package diffcmd + +import ( + "context" + "fmt" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + var flags = new(struct { + staged bool + unstaged bool + }) + + return &redant.Command{ + Use: "diff", + Short: "Show diff (--staged for staged, --unstaged for unstaged, default HEAD)", + Options: []redant.Option{ + { + Flag: "staged", + Description: "Show staged diff", + Value: redant.BoolOf(&flags.staged), + }, + { + Flag: "unstaged", + Description: "Show unstaged diff", + Value: redant.BoolOf(&flags.unstaged), + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + args, err := diffArgs(flags.staged, flags.unstaged) + if err != nil { + return err + } + + return utils.RunGit(ctx, args...) + }, + } +} + +func diffArgs(staged, unstaged bool) ([]string, error) { + if staged && unstaged { + return nil, fmt.Errorf("usage: diff takes only one of --staged/--unstaged") + } + + if staged { + return []string{"diff", "--cached"}, nil + } + if unstaged { + return []string{"diff"}, nil + } + + return []string{"diff", "HEAD"}, nil +} diff --git a/cmds/diffcmd/cmd_test.go b/cmds/diffcmd/cmd_test.go new file mode 100644 index 0000000..925edb9 --- /dev/null +++ b/cmds/diffcmd/cmd_test.go @@ -0,0 +1,32 @@ +package diffcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiffArgs(t *testing.T) { + t.Run("default diffs against HEAD", func(t *testing.T) { + args, err := diffArgs(false, false) + require.NoError(t, err) + require.Equal(t, []string{"diff", "HEAD"}, args) + }) + + t.Run("staged diff", func(t *testing.T) { + args, err := diffArgs(true, false) + require.NoError(t, err) + require.Equal(t, []string{"diff", "--cached"}, args) + }) + + t.Run("unstaged diff", func(t *testing.T) { + args, err := diffArgs(false, true) + require.NoError(t, err) + require.Equal(t, []string{"diff"}, args) + }) + + t.Run("staged and unstaged together is an error", func(t *testing.T) { + _, err := diffArgs(true, true) + require.Error(t, err) + }) +} diff --git a/cmds/docscmd/assets.go b/cmds/docscmd/assets.go index 275a8d6..b0430dd 100644 --- a/cmds/docscmd/assets.go +++ b/cmds/docscmd/assets.go @@ -116,7 +116,7 @@ agent: agent 优先根据当前仓库模块推断 scope,例如: - {{BT}}copilot{{BT}} -- {{BT}}ggc{{BT}} +- {{BT}}ui{{BT}} - {{BT}}changelog{{BT}} - {{BT}}agentline{{BT}} - {{BT}}ssh{{BT}} diff --git a/cmds/fetchcmd/cmd.go b/cmds/fetchcmd/cmd.go new file mode 100644 index 0000000..0a28726 --- /dev/null +++ b/cmds/fetchcmd/cmd.go @@ -0,0 +1,36 @@ +package fetchcmd + +import ( + "context" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + var flags = new(struct { + prune bool + }) + + return &redant.Command{ + Use: "fetch", + Short: "Fetch from remote", + Options: []redant.Option{ + { + Flag: "prune", + Description: "Fetch and prune stale refs", + Value: redant.BoolOf(&flags.prune), + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, fetchArgs(flags.prune)...) + }, + } +} + +func fetchArgs(prune bool) []string { + if prune { + return []string{"fetch", "--prune"} + } + return []string{"fetch"} +} diff --git a/cmds/fetchcmd/cmd_test.go b/cmds/fetchcmd/cmd_test.go new file mode 100644 index 0000000..685505b --- /dev/null +++ b/cmds/fetchcmd/cmd_test.go @@ -0,0 +1,17 @@ +package fetchcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFetchArgs(t *testing.T) { + t.Run("plain fetch", func(t *testing.T) { + require.Equal(t, []string{"fetch"}, fetchArgs(false)) + }) + + t.Run("fetch with prune", func(t *testing.T) { + require.Equal(t, []string{"fetch", "--prune"}, fetchArgs(true)) + }) +} diff --git a/cmds/logcmd/cmd.go b/cmds/logcmd/cmd.go new file mode 100644 index 0000000..5357483 --- /dev/null +++ b/cmds/logcmd/cmd.go @@ -0,0 +1,34 @@ +package logcmd + +import ( + "context" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + return &redant.Command{ + Use: "log", + Short: "Show commit log (--oneline -20)", + Children: []*redant.Command{ + { + Use: "graph", + Short: "Show graph commit log", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, logArgs(true)...) + }, + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, logArgs(false)...) + }, + } +} + +func logArgs(graph bool) []string { + if graph { + return []string{"log", "--graph", "--decorate", "--oneline", "-30"} + } + return []string{"log", "--oneline", "-20"} +} diff --git a/cmds/logcmd/cmd_test.go b/cmds/logcmd/cmd_test.go new file mode 100644 index 0000000..5763c66 --- /dev/null +++ b/cmds/logcmd/cmd_test.go @@ -0,0 +1,17 @@ +package logcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLogArgs(t *testing.T) { + t.Run("simple log", func(t *testing.T) { + require.Equal(t, []string{"log", "--oneline", "-20"}, logArgs(false)) + }) + + t.Run("graph log", func(t *testing.T) { + require.Equal(t, []string{"log", "--graph", "--decorate", "--oneline", "-30"}, logArgs(true)) + }) +} diff --git a/cmds/pullcmd/cmd.go b/cmds/pullcmd/cmd.go index da8de22..fd88ea5 100644 --- a/cmds/pullcmd/cmd.go +++ b/cmds/pullcmd/cmd.go @@ -23,6 +23,7 @@ func New() *redant.Command { var flagData = new(struct { pullAll bool hard bool + rebase bool }) app := &redant.Command{ Use: "pull", @@ -38,6 +39,11 @@ func New() *redant.Command { Description: "force sync current branch with remote via fetch + reset --hard", Value: redant.BoolOf(&flagData.hard), }, + { + Flag: "rebase", + Description: "pull with rebase instead of merge", + Value: redant.BoolOf(&flagData.rebase), + }, }, Handler: func(ctx context.Context, i *redant.Invocation) (gErr error) { defer result.RecoveryErr(&gErr, func(err error) error { @@ -60,8 +66,8 @@ func New() *redant.Command { utils.LogConfigAndBranch() - if flagData.pullAll && flagData.hard { - return errors.New("--hard cannot be used with --all") + if err := validatePullFlags(flagData.pullAll, flagData.hard, flagData.rebase); err != nil { + return err } if flagData.pullAll { @@ -78,7 +84,7 @@ func New() *redant.Command { return nil } - err := pullCurrentBranch(ctx, utils.GetBranchName()) + err := pullCurrentBranch(ctx, utils.GetBranchName(), pullExtraArgs(flagData.rebase)...) if err != nil { if gitconflict.HasConflicts(ctx, "") { handleMergeConflict(ctx) @@ -95,16 +101,39 @@ func New() *redant.Command { return app } -func pullCurrentBranch(ctx context.Context, branch string) error { +func validatePullFlags(all, hard, rebase bool) error { + if all && hard { + return errors.New("--hard cannot be used with --all") + } + + if rebase && hard { + return errors.New("--rebase cannot be used with --hard") + } + + if rebase && all { + return errors.New("--rebase cannot be used with --all") + } + + return nil +} + +func pullExtraArgs(rebase bool) []string { + if rebase { + return []string{"--rebase"} + } + return nil +} + +func pullCurrentBranch(ctx context.Context, branch string, extra ...string) error { if hasUpstream() { - return utils.GitPull(ctx).GetErr() + return utils.GitPull(ctx, extra...).GetErr() } if err := utils.GitBranchSetUpstream(ctx, branch).GetErr(); err != nil { - return utils.GitPull(ctx, "origin", branch).GetErr() + return utils.GitPull(ctx, append([]string{"origin", branch}, extra...)...).GetErr() } - return utils.GitPull(ctx).GetErr() + return utils.GitPull(ctx, extra...).GetErr() } func hasUpstream() bool { diff --git a/cmds/pullcmd/cmd_test.go b/cmds/pullcmd/cmd_test.go index f9dbe9e..4efb56d 100644 --- a/cmds/pullcmd/cmd_test.go +++ b/cmds/pullcmd/cmd_test.go @@ -31,3 +31,35 @@ func TestSplitRemoteRef(t *testing.T) { require.Equal(t, "feature/demo", branch) }) } + +func TestValidatePullFlags(t *testing.T) { + t.Run("no flags is valid", func(t *testing.T) { + require.NoError(t, validatePullFlags(false, false, false)) + }) + + t.Run("rebase alone is valid", func(t *testing.T) { + require.NoError(t, validatePullFlags(false, false, true)) + }) + + t.Run("hard with all is invalid", func(t *testing.T) { + require.Error(t, validatePullFlags(true, true, false)) + }) + + t.Run("rebase with hard is invalid", func(t *testing.T) { + require.Error(t, validatePullFlags(false, true, true)) + }) + + t.Run("rebase with all is invalid", func(t *testing.T) { + require.Error(t, validatePullFlags(true, false, true)) + }) +} + +func TestPullExtraArgs(t *testing.T) { + t.Run("no rebase adds nothing", func(t *testing.T) { + require.Nil(t, pullExtraArgs(false)) + }) + + t.Run("rebase adds --rebase", func(t *testing.T) { + require.Equal(t, []string{"--rebase"}, pullExtraArgs(true)) + }) +} diff --git a/cmds/rebasecmd/cmd.go b/cmds/rebasecmd/cmd.go new file mode 100644 index 0000000..e4ec212 --- /dev/null +++ b/cmds/rebasecmd/cmd.go @@ -0,0 +1,85 @@ +package rebasecmd + +import ( + "context" + "fmt" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + var flags = new(struct { + cont bool + abort bool + skip bool + }) + + return &redant.Command{ + Use: "rebase ", + Short: "Rebase current branch", + Options: []redant.Option{ + { + Flag: "continue", + Description: "Continue in-progress rebase", + Value: redant.BoolOf(&flags.cont), + }, + { + Flag: "abort", + Description: "Abort in-progress rebase", + Value: redant.BoolOf(&flags.abort), + }, + { + Flag: "skip", + Description: "Skip current patch in rebase", + Value: redant.BoolOf(&flags.skip), + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + upstream := "" + if len(i.Args) > 0 { + upstream = i.Args[0] + } + + args, err := rebaseArgs(upstream, flags.cont, flags.abort, flags.skip) + if err != nil { + return err + } + + return utils.RunGit(ctx, args...) + }, + } +} + +func rebaseArgs(upstream string, cont, abort, skip bool) ([]string, error) { + flags := 0 + for _, on := range []bool{cont, abort, skip} { + if on { + flags++ + } + } + + if flags > 1 { + return nil, fmt.Errorf("usage: rebase takes only one of --continue/--abort/--skip") + } + + if flags == 1 && upstream != "" { + return nil, fmt.Errorf("usage: rebase cannot be combined with --continue/--abort/--skip") + } + + if cont { + return []string{"rebase", "--continue"}, nil + } + if abort { + return []string{"rebase", "--abort"}, nil + } + if skip { + return []string{"rebase", "--skip"}, nil + } + + if upstream == "" { + return nil, fmt.Errorf("usage: rebase or one of --continue/--abort/--skip") + } + + return []string{"rebase", upstream}, nil +} diff --git a/cmds/rebasecmd/cmd_test.go b/cmds/rebasecmd/cmd_test.go new file mode 100644 index 0000000..4387543 --- /dev/null +++ b/cmds/rebasecmd/cmd_test.go @@ -0,0 +1,48 @@ +package rebasecmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRebaseArgs(t *testing.T) { + t.Run("rebase onto upstream", func(t *testing.T) { + args, err := rebaseArgs("main", false, false, false) + require.NoError(t, err) + require.Equal(t, []string{"rebase", "main"}, args) + }) + + t.Run("continue in-progress rebase", func(t *testing.T) { + args, err := rebaseArgs("", true, false, false) + require.NoError(t, err) + require.Equal(t, []string{"rebase", "--continue"}, args) + }) + + t.Run("abort in-progress rebase", func(t *testing.T) { + args, err := rebaseArgs("", false, true, false) + require.NoError(t, err) + require.Equal(t, []string{"rebase", "--abort"}, args) + }) + + t.Run("skip current patch", func(t *testing.T) { + args, err := rebaseArgs("", false, false, true) + require.NoError(t, err) + require.Equal(t, []string{"rebase", "--skip"}, args) + }) + + t.Run("no upstream and no flag is an error", func(t *testing.T) { + _, err := rebaseArgs("", false, false, false) + require.ErrorContains(t, err, "usage") + }) + + t.Run("upstream with control flag is an error", func(t *testing.T) { + _, err := rebaseArgs("main", true, false, false) + require.Error(t, err) + }) + + t.Run("multiple control flags are an error", func(t *testing.T) { + _, err := rebaseArgs("", true, true, false) + require.Error(t, err) + }) +} diff --git a/cmds/remotecmd/cmd.go b/cmds/remotecmd/cmd.go new file mode 100644 index 0000000..da6eb94 --- /dev/null +++ b/cmds/remotecmd/cmd.go @@ -0,0 +1,31 @@ +package remotecmd + +import ( + "context" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + return &redant.Command{ + Use: "remote", + Short: "List remotes", + Children: []*redant.Command{ + { + Use: "list", + Short: "List remotes", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, remoteArgs()...) + }, + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, remoteArgs()...) + }, + } +} + +func remoteArgs() []string { + return []string{"remote", "-v"} +} diff --git a/cmds/remotecmd/cmd_test.go b/cmds/remotecmd/cmd_test.go new file mode 100644 index 0000000..5a78809 --- /dev/null +++ b/cmds/remotecmd/cmd_test.go @@ -0,0 +1,11 @@ +package remotecmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRemoteArgs(t *testing.T) { + require.Equal(t, []string{"remote", "-v"}, remoteArgs()) +} diff --git a/cmds/statuscmd/cmd.go b/cmds/statuscmd/cmd.go new file mode 100644 index 0000000..65b0165 --- /dev/null +++ b/cmds/statuscmd/cmd.go @@ -0,0 +1,34 @@ +package statuscmd + +import ( + "context" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + return &redant.Command{ + Use: "status", + Short: "Show working tree status", + Children: []*redant.Command{ + { + Use: "short", + Short: "Show concise status", + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, statusArgs(true)...) + }, + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) error { + return utils.RunGit(ctx, statusArgs(false)...) + }, + } +} + +func statusArgs(short bool) []string { + if short { + return []string{"status", "--short"} + } + return []string{"status"} +} diff --git a/cmds/statuscmd/cmd_test.go b/cmds/statuscmd/cmd_test.go new file mode 100644 index 0000000..24f2a09 --- /dev/null +++ b/cmds/statuscmd/cmd_test.go @@ -0,0 +1,17 @@ +package statuscmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStatusArgs(t *testing.T) { + t.Run("full status", func(t *testing.T) { + require.Equal(t, []string{"status"}, statusArgs(false)) + }) + + t.Run("short status", func(t *testing.T) { + require.Equal(t, []string{"status", "--short"}, statusArgs(true)) + }) +} diff --git a/cmds/tagcmd/cmd.go b/cmds/tagcmd/cmd.go index c283b6a..6430532 100644 --- a/cmds/tagcmd/cmd.go +++ b/cmds/tagcmd/cmd.go @@ -59,6 +59,22 @@ func New() *redant.Command { return nil }, }, + { + Use: "show", + Short: "Show tag info (git show )", + Handler: func(ctx context.Context, i *redant.Invocation) error { + if len(i.Args) != 1 { + return fmt.Errorf("usage: tag show ") + } + + args, err := showTagArgs(i.Args[0]) + if err != nil { + return err + } + + return utils.RunGit(ctx, args...) + }, + }, }, Options: []redant.Option{ { @@ -240,3 +256,12 @@ func localTagExists(tagName string) bool { cmd := exec.Command("git", "rev-parse", "-q", "--verify", "refs/tags/"+tagName) return cmd.Run() == nil } + +func showTagArgs(tag string) ([]string, error) { + tag = strings.TrimSpace(tag) + if tag == "" { + return nil, fmt.Errorf("usage: tag show ") + } + + return []string{"show", tag}, nil +} diff --git a/cmds/tagcmd/cmd_test.go b/cmds/tagcmd/cmd_test.go index af6b23f..3d493de 100644 --- a/cmds/tagcmd/cmd_test.go +++ b/cmds/tagcmd/cmd_test.go @@ -29,3 +29,16 @@ func TestEnsureVersionAlignedMismatch(t *testing.T) { err := ensureVersionAligned(verFile, tag, []*fastcommitcmd.Config{{GenVersion: true}}) require.Error(t, err) } + +func TestShowTagArgs(t *testing.T) { + t.Run("tag name builds git show args", func(t *testing.T) { + args, err := showTagArgs("v0.0.15") + require.NoError(t, err) + require.Equal(t, []string{"show", "v0.0.15"}, args) + }) + + t.Run("empty tag name is rejected", func(t *testing.T) { + _, err := showTagArgs(" ") + require.Error(t, err) + }) +} diff --git a/cmds/ggccmd/alias.go b/cmds/uicmd/alias.go similarity index 99% rename from cmds/ggccmd/alias.go rename to cmds/uicmd/alias.go index b0b3660..63eedc9 100644 --- a/cmds/ggccmd/alias.go +++ b/cmds/uicmd/alias.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import ( "fmt" diff --git a/cmds/ggccmd/alias_test.go b/cmds/uicmd/alias_test.go similarity index 98% rename from cmds/ggccmd/alias_test.go rename to cmds/uicmd/alias_test.go index e29c0fb..c56caf6 100644 --- a/cmds/ggccmd/alias_test.go +++ b/cmds/uicmd/alias_test.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import "testing" diff --git a/cmds/ggccmd/cmd.go b/cmds/uicmd/cmd.go similarity index 84% rename from cmds/ggccmd/cmd.go rename to cmds/uicmd/cmd.go index f58b43c..a94d05f 100644 --- a/cmds/ggccmd/cmd.go +++ b/cmds/uicmd/cmd.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import ( "context" @@ -13,8 +13,8 @@ func New() *redant.Command { store := NewStateStore() return &redant.Command{ - Use: "ggc", - Short: "Unified Git command surface inspired by ggc", + Use: "ui", + Short: "Interactive command picker with aliases and workflows", Children: []*redant.Command{ { Use: "list", @@ -40,7 +40,7 @@ func New() *redant.Command { }, { Use: "path", - Short: "Show ggc state file path", + Short: "Show ggc.yaml state file path (file name unchanged)", Handler: func(ctx context.Context, i *redant.Invocation) error { fmt.Println(store.Path()) return nil @@ -64,7 +64,7 @@ func New() *redant.Command { } if err := executeWithAliases(ctx, registry, state, parts); err != nil { - return fmt.Errorf("%w\nTry: fastgit ggc list\nIf this should be an alias, check: fastgit ggc path", err) + return fmt.Errorf("%w\nTry: fastgit ui list\nIf this should be an alias, check: fastgit ui path", err) } return nil diff --git a/cmds/ggccmd/executor.go b/cmds/uicmd/executor.go similarity index 99% rename from cmds/ggccmd/executor.go rename to cmds/uicmd/executor.go index 6f9a205..40c5c0d 100644 --- a/cmds/ggccmd/executor.go +++ b/cmds/uicmd/executor.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import ( "context" diff --git a/cmds/ggccmd/interactive.go b/cmds/uicmd/interactive.go similarity index 99% rename from cmds/ggccmd/interactive.go rename to cmds/uicmd/interactive.go index 21f2dd4..43c00ba 100644 --- a/cmds/ggccmd/interactive.go +++ b/cmds/uicmd/interactive.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import ( "context" @@ -228,7 +228,7 @@ func (m *interactiveModel) applyFilter() { func (m *interactiveModel) View() string { var b strings.Builder - b.WriteString("ggc interactive mode\n") + b.WriteString("fastgit ui interactive mode\n") b.WriteString("Search keys: type to filter, ↑/↓ or Ctrl+N/P, Enter execute, Tab add workflow, Ctrl+T workflow mode, Ctrl+C quit\n") b.WriteString("Workflow keys: n new, d/Ctrl+D delete, c clear, x/Enter execute, Ctrl+N/P switch, Ctrl+T search mode\n\n") diff --git a/cmds/ggccmd/registry.go b/cmds/uicmd/registry.go similarity index 98% rename from cmds/ggccmd/registry.go rename to cmds/uicmd/registry.go index b999c80..a28d1d0 100644 --- a/cmds/ggccmd/registry.go +++ b/cmds/uicmd/registry.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import ( "context" @@ -159,7 +159,7 @@ func NewRegistry() *Registry { r.Register(CommandEntry{ Key: "branch checkout remote", - Usage: "branch checkout remote ", + Usage: "branch checkout-remote ", Description: "Checkout remote branch to local", Handler: func(ctx context.Context, rest []string) error { if len(rest) != 1 { @@ -236,7 +236,7 @@ func NewRegistry() *Registry { r.Register(CommandEntry{ Key: "pull rebase", - Usage: "pull rebase", + Usage: "pull --rebase", Description: "Pull with rebase", Handler: func(ctx context.Context, _ []string) error { return runGitCommand(ctx, "pull", "--rebase") @@ -259,7 +259,7 @@ func NewRegistry() *Registry { r.Register(CommandEntry{ Key: "push force", - Usage: "push force", + Usage: "push --force", Description: "Force push current branch", Handler: func(ctx context.Context, _ []string) error { branch, err := utils.GetCurrentBranchV1() diff --git a/cmds/ggccmd/store.go b/cmds/uicmd/store.go similarity index 99% rename from cmds/ggccmd/store.go rename to cmds/uicmd/store.go index 932a841..7ed3b31 100644 --- a/cmds/ggccmd/store.go +++ b/cmds/uicmd/store.go @@ -1,4 +1,4 @@ -package ggccmd +package uicmd import ( "os" diff --git a/docs/architecture.md b/docs/architecture.md index 3bef262..c928a26 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ - 本地代码评审(`fastgit review *`) - 冲突助手(`fastgit conflict *`) - 团队仓库规则(`.fastgit/` + `fastgit team *`) -- 常见 Git 工作流封装(`pull/push/tag/worktree/ggc`) +- 常见 Git 工作流封装(`status/add/log/diff/branch/fetch/rebase/remote/pull/push/tag/worktree`) 不在本项目内实现的能力: @@ -74,7 +74,7 @@ flowchart TD `bootstrap.Main()` 注册当前主命令: -- `version / init / upgrade / tag / ssh-login / history / ggc` +- `version / init / upgrade / tag / ssh-login / history / ui` - `commit / check / review / conflict / pr / team / config / docs` - `pull / push / worktree / changelog / copilot` diff --git a/docs/features.md b/docs/features.md index 8952b44..3d386d4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -31,7 +31,7 @@ | 推送发布 | `push` | 推送当前分支;保护分支策略阻断;`--override-policy` | | 标签发布 | `tag` | 生成并推送 tag,支持列表与交互选择 | | 工作树 | `worktree` | 创建/删除/查看多工作树并行开发 | -| 统一命令面 | `ggc` | 统一 git 子命令 + 交互 workflow + alias | +| 交互命令面 | `ui` | fuzzy 命令选择 + workflow + alias | | Copilot 集成 | `copilot` | 会话聊天、恢复、诊断、模型/skills 管理 | | 自升级 | `upgrade` | 查询并下载匹配当前 OS/ARCH 的发布版本 | | 其他工具 | `ssh-login`、`history` | SSH 二次认证登录、历史命令交互处理 | @@ -186,13 +186,13 @@ --- -### 2.9 统一 Git 命令面(`fastgit ggc`) +### 2.9 交互命令面(`fastgit ui`) -`ggc` 提供统一命令入口与交互检索: +原 `ggc` 统一入口已摊平为顶层子命令(`status` / `add` / `log` / `diff` / `branch` / `fetch` / `rebase` / `remote`;`pull --rebase`、`tag show` 并入现有命令)。`ui` 保留交互检索能力: -- `ggc list`:查看命令面 -- `ggc interactive`:fuzzy 选择 + workflow,底部展示 `Next:` 推荐链 -- `ggc path`:查看状态文件位置 +- `ui list`:查看命令面(含 alias) +- `ui` / `ui interactive`:fuzzy 选择 + workflow,底部展示 `Next:` 推荐链 +- `ui path`:查看状态文件位置 可将多步 Git 操作沉淀成 workflow/alias,适合高频重复动作。 @@ -284,7 +284,7 @@ - `pr` 命令族依赖 `gh` CLI 已安装并登录,且分支需有 upstream。 - AI 能力不可用时,`commit`/`pr`/`review`/`conflict` 自动降级为规则版输出。 - `upgrade` 按当前 `GOOS/GOARCH` 过滤资产,不会跨平台安装。 -- 部分命令是交互式设计(例如 `tag`、`ggc interactive`、`history`、`pr merge`),在非 TTY 下不可用。 +- 部分命令是交互式设计(例如 `tag`、`ui interactive`、`history`、`pr merge`),在非 TTY 下不可用。 --- diff --git a/docs/roadmap.md b/docs/roadmap.md index d8704ec..89e58d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -252,7 +252,7 @@ PR 正文建议固定小节,便于 review: - 冲突文件按目录/模块分组 - 生成冲突原因摘要与处理建议(不自动改文件,需用户确认) - 一键打开冲突文件列表 + 处理清单 -- 与 `pull`、`ggc rebase` 集成 +- 与 `pull`、`rebase` 集成 #### 验收标准(DoD) @@ -335,7 +335,7 @@ PR 正文建议固定小节,便于 review: #### 目标 -在现有 `ggc` workflow 持久化基础上,增加“常用链 → 推荐下一步”。 +在现有 `ui` workflow 持久化基础上,增加“常用链 → 推荐下一步”。 #### 现状 @@ -346,13 +346,13 @@ PR 正文建议固定小节,便于 review: - 记录命令序列频率(本地、可清除) - 在 TUI/交互模式下推荐下一步(如 commit 成功后提示 `push` / `pr create`) -- 与 `ggc interactive` 集成 +- 与 `ui interactive` 集成 #### 验收标准(DoD) - [x] 推荐基于真实使用频率(`workflow.yaml`)+ 默认链 - [x] commit/pull 完成后输出 `Next:` 提示 -- [x] `ggc interactive` TUI 底部展示 workflow 推荐 +- [x] `ui interactive` TUI 底部展示 workflow 推荐 - [x] 不发送数据到远端(本地文件) #### 当前进度 diff --git a/utils/git.go b/utils/git.go index 52f083a..3b1a222 100644 --- a/utils/git.go +++ b/utils/git.go @@ -737,3 +737,18 @@ func GitBranchSetUpstream(ctx context.Context, branch string) (r result.Error) { ShellExecOutput(ctx, "git", "branch", "--set-upstream-to=origin/"+branch, branch).Throw(&r) return r } + +// RunGit runs git with stdout/stderr/stdin passed through so interactive +// commands (checkout, rebase, diff pager) behave like the raw git CLI. +func RunGit(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) + } + + return nil +} diff --git a/utils/git_test.go b/utils/git_test.go index 77a51ae..a73664d 100644 --- a/utils/git_test.go +++ b/utils/git_test.go @@ -1,11 +1,28 @@ package utils import ( + "context" + "strings" "testing" - + "github.com/stretchr/testify/assert" ) func TestIsDirty(t *testing.T) { assert.NoError(t, IsDirty().GetErr()) } + +func TestRunGit(t *testing.T) { + ctx := context.Background() + + t.Run("runs a real git command", func(t *testing.T) { + err := RunGit(ctx, "--version") + assert.NoError(t, err) + }) + + t.Run("fails on unknown git subcommand", func(t *testing.T) { + err := RunGit(ctx, "definitely-not-a-git-subcommand") + assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "git definitely-not-a-git-subcommand")) + }) +}