Skip to content

Commit e1d832f

Browse files
committed
feat(compare): add compare and do-git workflows
1 parent c1f3652 commit e1d832f

19 files changed

+531
-3
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
77

88
## [Unreleased]
99

10+
## [0.1.3] - 2026-03-10
11+
1012
### Added
11-
- Placeholder for upcoming changes.
13+
- `xgit compare` for AI-assisted revision/range comparison summaries.
14+
- `xgit do-git` for recommending Git command sets from natural-language goals.
15+
16+
### Fixed
17+
- Improved `xgit do-git` recommendations so repositories without remotes prefer local bases like `main` instead of assuming `origin/main`.
1218

1319
## [0.1.2] - 2026-03-07
1420

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,10 @@ In short: **xgit is not a new VCS**. It is a practical AI layer over the Git you
138138
- **Commit/review productivity**
139139
- `xgit commit`: generate high-quality commit messages from diffs
140140
- `xgit review`: structured risk-focused diff review
141+
- `xgit compare`: compare any two revisions/ranges with an AI summary
141142
- `xgit enhance`: low-risk refactor suggestions from current changes
142143
- `xgit explain history`: summarize commit timeline, turning points, and hotspots
144+
- `xgit do-git`: turn natural-language Git goals into a recommended command set
143145

144146
- **Automation helpers**
145147
- `xgit test-fix`: propose and validate fixes for failing tests
@@ -185,11 +187,17 @@ xgit commit
185187
# Review current branch changes vs main
186188
xgit review --base main
187189

190+
# Compare two revisions with a semantic summary
191+
xgit compare main HEAD
192+
188193
# Suggest improvements on current diff
189194
xgit enhance --focus readability
190195

191196
# Explain recent branch history
192197
xgit explain history main..HEAD --limit 40
198+
199+
# Ask xgit which Git commands to run
200+
xgit do-git compare my branch with main before I merge
193201
```
194202

195203
### 3) High-impact workflows

cmd/alias.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ var reservedAliasNames = map[string]bool{
196196
"pr-split": true,
197197
"deadcode": true,
198198
"hist-debug": true,
199+
"compare": true,
200+
"do-git": true,
199201
"explain": true,
200202
"why": true,
201203
"map": true,

cmd/command_execution_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,16 @@ func TestAdditionalCommandsReachRunE(t *testing.T) {
3535
if _, err := execRoot("sync", "--write-report=false"); err != nil {
3636
t.Fatalf("expected sync command to work outside git repo, got %v", err)
3737
}
38+
doGitOut := mustExecRoot(t, "do-git", "show", "me", "the", "commands", "to", "compare", "my", "branch", "with", "main", "--provider", "mock")
39+
if !strings.Contains(doGitOut, "Recommended command set:") {
40+
t.Fatalf("expected do-git output, got %q", doGitOut)
41+
}
3842

3943
repoRequiredCases := [][]string{
4044
{"commit", "--provider", "mock"},
4145
{"map"},
4246
{"why", "Add", "--provider", "mock"},
47+
{"compare", "main", "HEAD", "--provider", "mock"},
4348
{"explain", "commit", "HEAD", "--provider", "mock"},
4449
{"explain", "file", "README.md", "--provider", "mock"},
4550
{"explain", "history", "main..HEAD", "--provider", "mock"},

cmd/command_flags_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,14 @@ func TestCommandFlagDefaults(t *testing.T) {
9393
t.Fatalf("expected hist-debug --flaky-retries default 3, got %d", got)
9494
}
9595
})
96+
97+
t.Run("compare defaults", func(t *testing.T) {
98+
c := newCompareCommand()
99+
if got, _ := c.Flags().GetString("format"); got != "md" {
100+
t.Fatalf("expected compare --format default md, got %q", got)
101+
}
102+
if got, _ := c.Flags().GetStringArray("path"); len(got) != 0 {
103+
t.Fatalf("expected compare --path default empty, got %v", got)
104+
}
105+
})
96106
}

cmd/compare.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
6+
cfgpkg "github.com/hjun1052/xgit/internal/config"
7+
"github.com/hjun1052/xgit/internal/workflow"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func newCompareCommand() *cobra.Command {
12+
var format string
13+
var paths []string
14+
15+
cmd := &cobra.Command{
16+
Use: "compare <left> [right]",
17+
Short: "Compare revisions with AI summary",
18+
Args: cobra.RangeArgs(1, 2),
19+
RunE: func(cmd *cobra.Command, args []string) error {
20+
cfg, err := cfgpkg.Load()
21+
if err != nil {
22+
return err
23+
}
24+
25+
left := args[0]
26+
right := ""
27+
if len(args) == 2 {
28+
right = args[1]
29+
}
30+
31+
return workflow.RunCompare(context.Background(), cfg, workflow.CompareOptions{
32+
Left: left,
33+
Right: right,
34+
Paths: paths,
35+
Format: format,
36+
Runtime: runtimeOptions(cmd),
37+
})
38+
},
39+
}
40+
41+
cmd.Flags().StringVar(&format, "format", "md", "Output format: md|json")
42+
cmd.Flags().StringArrayVar(&paths, "path", nil, "Limit comparison to specific path(s)")
43+
return cmd
44+
}

cmd/do_git.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
cfgpkg "github.com/hjun1052/xgit/internal/config"
8+
"github.com/hjun1052/xgit/internal/workflow"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newDoGitCommand() *cobra.Command {
13+
return &cobra.Command{
14+
Use: "do-git <goal...>",
15+
Short: "Recommend a git command set from natural language",
16+
Args: cobra.MinimumNArgs(1),
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
cfg, err := cfgpkg.Load()
19+
if err != nil {
20+
return err
21+
}
22+
return workflow.RunDoGit(context.Background(), cfg, workflow.DoGitOptions{
23+
Goal: strings.Join(args, " "),
24+
Runtime: runtimeOptions(cmd),
25+
})
26+
},
27+
}
28+
}

cmd/root.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ func NewRootCommand() *cobra.Command {
2727
newPRSplitCommand(),
2828
newDeadcodeCommand(),
2929
newHistDebugCommand(),
30+
newCompareCommand(),
31+
newDoGitCommand(),
3032
newExplainCommand(),
3133
newWhyCommand(),
3234
newMapCommand(),
@@ -39,7 +41,7 @@ func NewRootCommand() *cobra.Command {
3941
)
4042
registerAliasShortcuts(root)
4143

42-
root.SetHelpTemplate(root.HelpTemplate() + "\nExamples:\n xgit init --provider openai --api-key-env OPENAI_API_KEY\n xgit config show\n xgit alias set ship \"review --base main\" \"commit --apply\"\n xgit auto --base main\n xgit auto --base main --aggressive\n xgit commit\n xgit enhance --focus readability\n xgit safe-refactor --base main --focus structure\n xgit review --base main\n xgit merge\n xgit rebase main --auto-resolve --auto-apply\n xgit pr-split --base main\n xgit deadcode --lang go\n xgit explain commit HEAD~1\n xgit explain history main..HEAD --limit 40\n xgit map\n xgit test-fix\n xgit doc --write\n xgit feature stash auth src/auth\n xgit repo merge ../other-repo vendor/other\n xgit sync --fix\n xgit doctor\n")
44+
root.SetHelpTemplate(root.HelpTemplate() + "\nExamples:\n xgit init --provider openai --api-key-env OPENAI_API_KEY\n xgit config show\n xgit alias set ship \"review --base main\" \"commit --apply\"\n xgit auto --base main\n xgit auto --base main --aggressive\n xgit commit\n xgit enhance --focus readability\n xgit safe-refactor --base main --focus structure\n xgit review --base main\n xgit compare main HEAD\n xgit do-git compare my branch with main before I merge\n xgit merge\n xgit rebase main --auto-resolve --auto-apply\n xgit pr-split --base main\n xgit deadcode --lang go\n xgit explain commit HEAD~1\n xgit explain history main..HEAD --limit 40\n xgit map\n xgit test-fix\n xgit doc --write\n xgit feature stash auth src/auth\n xgit repo merge ../other-repo vendor/other\n xgit sync --fix\n xgit doctor\n")
4345
root.SilenceUsage = true
4446
root.SilenceErrors = true
4547

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestNewRootCommandRegistersCoreCommandsAndFlags(t *testing.T) {
2828
required := []string{
2929
"config", "alias", "init", "version", "commit", "auto", "enhance",
3030
"review", "merge", "rebase", "safe-refactor", "pr-split", "deadcode",
31-
"hist-debug", "explain", "why", "map", "test-fix", "doc", "feature",
31+
"hist-debug", "compare", "do-git", "explain", "why", "map", "test-fix", "doc", "feature",
3232
"repo", "sync", "doctor",
3333
}
3434
for _, name := range required {

internal/gitutil/git.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,34 @@ func RangeDiff(r string) (string, error) {
5959
return runGit("diff", r)
6060
}
6161

62+
func CompareDiff(left, right string, paths []string) (string, error) {
63+
left = strings.TrimSpace(left)
64+
right = strings.TrimSpace(right)
65+
if left == "" && right == "" {
66+
return "", errors.New("comparison target is empty")
67+
}
68+
69+
args := []string{"diff"}
70+
switch {
71+
case left != "" && right != "":
72+
args = append(args, left, right)
73+
case left != "":
74+
if strings.Contains(left, "..") {
75+
args = append(args, left)
76+
} else {
77+
args = append(args, left, "HEAD")
78+
}
79+
default:
80+
args = append(args, "HEAD", right)
81+
}
82+
83+
if len(paths) > 0 {
84+
args = append(args, "--")
85+
args = append(args, paths...)
86+
}
87+
return runGit(args...)
88+
}
89+
6290
func BaseDiff(base string) (string, error) {
6391
if strings.TrimSpace(base) == "" {
6492
base = "main"
@@ -134,6 +162,36 @@ func CurrentBranch() (string, error) {
134162
return strings.TrimSpace(out), nil
135163
}
136164

165+
func ListRemotes() ([]string, error) {
166+
out, err := runGit("remote")
167+
if err != nil {
168+
return nil, err
169+
}
170+
return splitNonEmptyLines(out), nil
171+
}
172+
173+
func RevisionExists(rev string) (bool, error) {
174+
rev = strings.TrimSpace(rev)
175+
if rev == "" {
176+
return false, nil
177+
}
178+
179+
cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", rev)
180+
var stderr bytes.Buffer
181+
cmd.Stderr = &stderr
182+
if err := cmd.Run(); err != nil {
183+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
184+
return false, nil
185+
}
186+
msg := strings.TrimSpace(stderr.String())
187+
if msg == "" {
188+
msg = err.Error()
189+
}
190+
return false, fmt.Errorf("git rev-parse --verify %s failed: %s", rev, msg)
191+
}
192+
return true, nil
193+
}
194+
137195
func ResolveRevision(rev string) (string, error) {
138196
if strings.TrimSpace(rev) == "" {
139197
rev = "HEAD"

0 commit comments

Comments
 (0)