mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1b78f5ff | ||
|
|
7b10aab74b | ||
|
|
3c81fea228 | ||
|
|
f6c060631e | ||
|
|
563a6dbe91 | ||
|
|
2312abe5dd |
No files matched your search
@@ -47,6 +47,12 @@ Tips:
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
### Personal guidelines, across every project
|
||||
|
||||
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
|
||||
|
||||
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
@@ -307,6 +313,7 @@ A team that wants every contributor's Zero to behave the same way commits:
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
@@ -41,6 +41,7 @@ events when present.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
|
||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
@@ -51,6 +52,10 @@ events when present.
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
`reasoning` events carry live model reasoning/status deltas for providers that
|
||||
stream them separately from answer text. They are liveness/progress events only:
|
||||
they are not folded into `text` or the final answer.
|
||||
|
||||
Permission events may include structured sandbox metadata:
|
||||
|
||||
```json
|
||||
|
||||
@@ -429,8 +429,9 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
|
||||
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
||||
hasToolCalls := len(collected.ToolCalls) > 0
|
||||
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
||||
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
|
||||
|
||||
if hasToolCalls || hasVisibleText {
|
||||
if hasToolCalls || hasVisibleText || hasReasoning {
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
|
||||
@@ -22,6 +22,14 @@ func textTurn(content string) []zeroruntime.StreamEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTurn produces live reasoning without visible assistant text.
|
||||
func reasoningTurn(content string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventReasoning, Content: content},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
||||
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
@@ -101,6 +109,31 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningTurn("thinking 1"),
|
||||
reasoningTurn("thinking 2"),
|
||||
reasoningTurn("thinking 3"),
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+108
-19
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
@@ -38,6 +39,14 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// userContextFile is the per-user instruction file, resolved under
|
||||
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
|
||||
// (config.json, commands, specialists) so users can keep personal guidance out
|
||||
// of individual repositories.
|
||||
const userContextFile = "ZERO.md"
|
||||
|
||||
var userConfigDirForPrompt = config.UserConfigDir
|
||||
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
@@ -81,6 +90,13 @@ func buildSystemPrompt(options Options) string {
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
// User guidelines are injected before workspace/project guidelines so the
|
||||
// project's AGENTS.md/ZERO.md is the later, more specific instruction
|
||||
// block. See userGuidelines for the explicit precedence note carried in
|
||||
// the section text itself.
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
@@ -312,13 +328,7 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -326,6 +336,92 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// userGuidelines returns the per-user ZERO.md instructions block, if present.
|
||||
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
|
||||
// per-user config; the basename match is case-insensitive so a file saved as
|
||||
// zero.md still resolves on case-sensitive filesystems, mirroring the project
|
||||
// guideline loader. The section carries an explicit precedence note because
|
||||
// this is a global, personal preferences file: it is injected earlier in the
|
||||
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
|
||||
// the note keeps that precedence unambiguous even if a model weighs later
|
||||
// context more heavily than section order alone implies.
|
||||
func userGuidelines() string {
|
||||
configDir, err := userConfigDirForPrompt()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
configDir = strings.TrimSpace(configDir)
|
||||
if configDir == "" {
|
||||
return ""
|
||||
}
|
||||
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
|
||||
if match == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
content = truncateGuidelineContent(content, maxProjectContextBytes)
|
||||
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
|
||||
"These are the operator's personal preferences, not project policy. " +
|
||||
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
|
||||
content
|
||||
}
|
||||
|
||||
// truncationMarker is appended to guideline content that was cut short.
|
||||
const truncationMarker = "\n… (truncated)"
|
||||
|
||||
// truncateGuidelineContent caps content at limit bytes without splitting a
|
||||
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
|
||||
// the marker is reserved before choosing the cut point. When limit is too
|
||||
// small to fit the marker itself, the marker is dropped instead, so the
|
||||
// returned string never exceeds limit for any non-negative limit.
|
||||
func truncateGuidelineContent(content string, limit int) string {
|
||||
if len(content) <= limit {
|
||||
return content
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
cut := limit - len(truncationMarker)
|
||||
if cut < 0 {
|
||||
cut = 0
|
||||
}
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
|
||||
return truncated
|
||||
}
|
||||
// limit is smaller than the marker itself: fall back to a hard,
|
||||
// rune-safe cut at limit bytes with no marker rather than exceeding it.
|
||||
end := limit
|
||||
for end > 0 && !utf8.RuneStart(content[end]) {
|
||||
end--
|
||||
}
|
||||
return content[:end]
|
||||
}
|
||||
|
||||
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
|
||||
// whose name matches basename case-insensitively, or "" when absent.
|
||||
func findCaseInsensitiveFile(dir, basename string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
|
||||
return filepath.Join(dir, e.Name())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
@@ -378,23 +474,16 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
// matching, then find a regular file with the same basename. This works
|
||||
// on both case-sensitive and case-insensitive filesystems and always
|
||||
// returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -63,6 +63,10 @@ work.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -9,6 +9,25 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
|
||||
// directory for every test in this package. Without this, buildSystemPrompt
|
||||
// falls back to the real config.UserConfigDir, so any developer with a
|
||||
// personal ~/.config/zero/ZERO.md would get its content folded into
|
||||
// otherwise-unrelated prompt assertions, making test runs non-deterministic
|
||||
// across contributor machines. Tests that specifically exercise user
|
||||
// guidelines stub userConfigDirForPrompt themselves via
|
||||
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
userConfigDirForPrompt = func() (string, error) { return dir, nil }
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
|
||||
@@ -128,6 +147,88 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
if strings.Contains(prompt, " Prefer concise summaries. ") {
|
||||
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
|
||||
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
|
||||
// User guidelines are global personal preferences; project guidelines
|
||||
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
|
||||
// and the user section must say so explicitly so the precedence holds
|
||||
// even if a model otherwise weighs later context more heavily.
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
|
||||
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
|
||||
if userIdx < 0 || projectIdx < 0 {
|
||||
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
|
||||
}
|
||||
if userIdx > projectIdx {
|
||||
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
|
||||
}
|
||||
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
|
||||
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
|
||||
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if strings.Contains(prompt, "## User guidelines") {
|
||||
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
|
||||
t.Helper()
|
||||
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
|
||||
t.Helper()
|
||||
old := userConfigDirForPrompt
|
||||
userConfigDirForPrompt = fn
|
||||
return func() {
|
||||
userConfigDirForPrompt = old
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
@@ -257,6 +358,30 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
|
||||
limit := 64
|
||||
content := strings.Repeat("x", limit+1)
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
|
||||
}
|
||||
if !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("expected truncation marker, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
|
||||
// Limits at or below the truncation marker's own length can't fit the
|
||||
// marker at all; the helper must still never exceed the requested limit.
|
||||
content := strings.Repeat("x", 100)
|
||||
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
|
||||
@@ -78,6 +78,8 @@ type appDeps struct {
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
@@ -179,6 +181,8 @@ func defaultAppDeps() appDeps {
|
||||
runAgentEval: defaultRunAgentEval,
|
||||
inspectChanges: zerogit.Inspect,
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
@@ -517,6 +521,12 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.commitChanges == nil {
|
||||
deps.commitChanges = defaults.commitChanges
|
||||
}
|
||||
if deps.pushChanges == nil {
|
||||
deps.pushChanges = defaults.pushChanges
|
||||
}
|
||||
if deps.createPR == nil {
|
||||
deps.createPR = defaults.createPR
|
||||
}
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
|
||||
@@ -544,6 +544,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
OnReasoning: writer.reasoning,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
writer.toolCall(call, registry)
|
||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||
|
||||
@@ -529,6 +529,45 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return reasoningExecProvider{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
|
||||
if reasoning["delta"] != "Thinking. " {
|
||||
t.Fatalf("unexpected reasoning event: %#v", reasoning)
|
||||
}
|
||||
textIdx, text := findJSONEventIndex(t, events, "text")
|
||||
if text["delta"] != "done" {
|
||||
t.Fatalf("unexpected text event: %#v", text)
|
||||
}
|
||||
if reasoningIdx >= textIdx {
|
||||
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
|
||||
}
|
||||
final := findJSONEvent(t, events, "final")
|
||||
if final["text"] != "done" {
|
||||
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
writer := execEventWriter{
|
||||
@@ -751,6 +790,17 @@ type toolCallingExecProvider struct {
|
||||
answer string
|
||||
}
|
||||
|
||||
type reasoningExecProvider struct{}
|
||||
|
||||
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
@@ -777,13 +827,19 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
|
||||
|
||||
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
||||
t.Helper()
|
||||
for _, event := range events {
|
||||
_, event := findJSONEventIndex(t, events, eventType)
|
||||
return event
|
||||
}
|
||||
|
||||
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
for idx, event := range events {
|
||||
if event["type"] == eventType {
|
||||
return event
|
||||
return idx, event
|
||||
}
|
||||
}
|
||||
t.Fatalf("event %q not found in %#v", eventType, events)
|
||||
return nil
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
||||
|
||||
@@ -80,6 +80,16 @@ func (writer *execEventWriter) text(delta string) {
|
||||
writer.writeStdout(delta)
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) reasoning(delta string) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
|
||||
@@ -88,6 +88,15 @@ func formatRepoInfo(info repoinfo.Info) string {
|
||||
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
|
||||
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
|
||||
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
|
||||
if info.CommitCount != nil {
|
||||
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
|
||||
}
|
||||
if info.BranchCount != nil {
|
||||
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
|
||||
}
|
||||
if info.TagCount != nil {
|
||||
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
|
||||
}
|
||||
if info.PrimaryLanguage != "" {
|
||||
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
|
||||
contrib := 3
|
||||
info := repoInfoTestData(age, contrib)
|
||||
out := formatRepoInfo(info)
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("text output missing %q:\n%s", want, out)
|
||||
}
|
||||
@@ -113,12 +113,16 @@ func initTempGitRepo(t *testing.T) string {
|
||||
}
|
||||
|
||||
func repoInfoTestData(age, contrib int) repoinfo.Info {
|
||||
commits := 100
|
||||
branches := 5
|
||||
tags := 10
|
||||
return repoinfo.Info{
|
||||
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
|
||||
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
|
||||
PrimaryLanguage: "TypeScript", LanguageCount: 1,
|
||||
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
|
||||
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
|
||||
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+195
-2
@@ -41,6 +41,13 @@ type changesCommandOptions struct {
|
||||
hasMessage bool
|
||||
dryRun bool
|
||||
maxDiffBytes int
|
||||
remote string
|
||||
force bool
|
||||
title string
|
||||
body string
|
||||
fill bool
|
||||
draft bool
|
||||
yes bool
|
||||
auto bool
|
||||
}
|
||||
|
||||
@@ -266,6 +273,10 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
case "push":
|
||||
return runChangesPush(args, stdout, stderr, deps)
|
||||
case "pr", "pull-request":
|
||||
return runChangesPR(args, stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
|
||||
}
|
||||
@@ -465,6 +476,41 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.maxDiffBytes = maxDiffBytes
|
||||
case arg == "--remote":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.remote = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--remote="):
|
||||
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
|
||||
case arg == "--force":
|
||||
options.force = true
|
||||
case arg == "--title":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.title = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--title="):
|
||||
options.title = strings.TrimPrefix(arg, "--title=")
|
||||
case arg == "--body":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.body = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--body="):
|
||||
options.body = strings.TrimPrefix(arg, "--body=")
|
||||
case arg == "--fill":
|
||||
options.fill = true
|
||||
case arg == "--draft":
|
||||
options.draft = true
|
||||
case arg == "--yes":
|
||||
options.yes = true
|
||||
case arg == "-a" || arg == "--auto":
|
||||
options.auto = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
@@ -473,15 +519,30 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && options.message != "" {
|
||||
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
||||
}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command == "commit" && options.baseRef != "" {
|
||||
if command != "commit" && command != "push" && options.dryRun {
|
||||
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||
}
|
||||
if command != "inspect" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
}
|
||||
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
|
||||
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
|
||||
}
|
||||
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
|
||||
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
|
||||
}
|
||||
if command != "push" && command != "pr" && options.yes {
|
||||
return options, false, execUsageError{"--yes is only valid with push or pr"}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
@@ -759,14 +820,24 @@ func writeChangesHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero changes inspect [flags]
|
||||
zero changes commit [flags]
|
||||
zero changes push [flags]
|
||||
zero changes pr [flags]
|
||||
|
||||
Inspects local git changes and optionally creates a commit.
|
||||
Inspects, commits, pushes, and creates pull requests for local git changes.
|
||||
|
||||
Flags:
|
||||
-C, --cwd <path> Workspace directory
|
||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||
--diff-bytes <n> Maximum diff bytes to include
|
||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||
--dry-run Preview commit metadata / push without mutating git state
|
||||
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
|
||||
--force Use force-with-lease when pushing
|
||||
--title <text> PR title
|
||||
--body <text> PR body
|
||||
--fill Automatically populate PR title and body from commits
|
||||
--draft Create PR as a draft
|
||||
--yes Confirm pushing to a default/protected branch
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
@@ -775,6 +846,128 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseChangesArgs(args, "push")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeChangesHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
DryRun: options.dryRun,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
dryRunStr := ""
|
||||
if options.dryRun {
|
||||
dryRunStr = " (dry run)"
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if result.Output != "" {
|
||||
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseChangesArgs(args, "pr")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeChangesHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.fill && options.title == "" {
|
||||
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
|
||||
}
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
|
||||
Cwd: workspaceRoot,
|
||||
Fill: options.fill,
|
||||
Draft: options.draft,
|
||||
Title: options.title,
|
||||
Body: options.body,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if options.json {
|
||||
type prJSONResult struct {
|
||||
Branch string `json:"branch"`
|
||||
Remote string `json:"remote"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
res := prJSONResult{
|
||||
Branch: pushResult.Branch,
|
||||
Remote: pushResult.Remote,
|
||||
Output: strings.TrimSpace(prResult.Output),
|
||||
}
|
||||
if err := writePrettyJSON(stdout, res); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||
|
||||
@@ -283,10 +283,10 @@ func (provider *Provider) emitChunk(
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: choice.Delta.ReasoningContent,
|
||||
Content: reasoning,
|
||||
})
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
@@ -317,6 +317,13 @@ func (provider *Provider) emitChunk(
|
||||
}
|
||||
}
|
||||
|
||||
func (delta streamDelta) reasoningText() string {
|
||||
if delta.ReasoningContent != "" {
|
||||
return delta.ReasoningContent
|
||||
}
|
||||
return delta.Reasoning
|
||||
}
|
||||
|
||||
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
|
||||
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
|
||||
func mapFinishReason(reason string) string {
|
||||
|
||||
@@ -227,6 +227,39 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningAliasDeltas(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Thinking. "}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Answering now."}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 2 {
|
||||
t.Fatalf("reasoning events = %#v, want two reasoning deltas", reasoning)
|
||||
}
|
||||
if reasoning[0].Content != "Thinking. " || reasoning[1].Content != "Answering now." {
|
||||
t.Fatalf("unexpected reasoning events: %#v", reasoning)
|
||||
}
|
||||
if text := eventsOfType(events, zeroruntime.StreamEventText); len(text) != 0 {
|
||||
t.Fatalf("reasoning must not emit text events, got %#v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionPrefersReasoningContentOverAlias(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"standard","reasoning":"alias"}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 1 || reasoning[0].Content != "standard" {
|
||||
t.Fatalf("reasoning events = %#v, want standard reasoning_content", reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
|
||||
|
||||
@@ -78,6 +78,7 @@ type streamChoice struct {
|
||||
type streamDelta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ type Info struct {
|
||||
AgeDays *int `json:"ageDays,omitempty"`
|
||||
Contributors90d *int `json:"contributors90d,omitempty"`
|
||||
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
|
||||
BranchCount *int `json:"branchCount,omitempty"`
|
||||
CommitCount *int `json:"commitCount,omitempty"`
|
||||
TagCount *int `json:"tagCount,omitempty"`
|
||||
}
|
||||
|
||||
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
|
||||
@@ -229,6 +232,43 @@ func Collect(ctx context.Context, opts Options) (Info, error) {
|
||||
info.CommitVelocity30d = &n
|
||||
}
|
||||
}
|
||||
if count, err := run(ctx, dir, "rev-list", "--count", "HEAD"); err == nil {
|
||||
if n, perr := strconv.Atoi(strings.TrimSpace(count)); perr == nil {
|
||||
info.CommitCount = &n
|
||||
}
|
||||
}
|
||||
if branches, err := run(ctx, dir, "branch", "-a"); err == nil {
|
||||
set := map[string]bool{}
|
||||
for _, line := range strings.Split(branches, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.Contains(line, "->") {
|
||||
continue
|
||||
}
|
||||
// Strip leading '* ' (current branch) or '+ ' (checked out in another worktree).
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
line = strings.TrimPrefix(line, "+ ")
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "(") {
|
||||
continue
|
||||
}
|
||||
// If it's a remote branch, extract the branch name.
|
||||
// E.g., remotes/origin/main -> main
|
||||
// E.g., remotes/origin/feature/xyz -> feature/xyz
|
||||
if strings.HasPrefix(line, "remotes/") {
|
||||
line = strings.TrimPrefix(line, "remotes/")
|
||||
if slashIdx := strings.Index(line, "/"); slashIdx != -1 {
|
||||
line = line[slashIdx+1:]
|
||||
}
|
||||
}
|
||||
set[line] = true
|
||||
}
|
||||
n := len(set)
|
||||
info.BranchCount = &n
|
||||
}
|
||||
if tags, err := run(ctx, dir, "tag"); err == nil {
|
||||
n := countNonEmptyLines(tags)
|
||||
info.TagCount = &n
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -328,3 +368,13 @@ func defaultRunGit(ctx context.Context, dir string, args ...string) (string, err
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func countNonEmptyLines(s string) int {
|
||||
count := 0
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -43,6 +43,8 @@ func TestCollectCoreMetrics(t *testing.T) {
|
||||
"remote": "https://github.com/x/y.git\n",
|
||||
"log": "0\n",
|
||||
"rev-list": "12\n",
|
||||
"branch": " main\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature\n",
|
||||
"tag": "v0.1.0\nv0.2.0\n",
|
||||
}, nil),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -85,6 +87,15 @@ func TestCollectCoreMetrics(t *testing.T) {
|
||||
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
|
||||
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
|
||||
}
|
||||
if info.CommitCount == nil || *info.CommitCount != 12 {
|
||||
t.Fatalf("CommitCount=%v want 12", info.CommitCount)
|
||||
}
|
||||
if info.BranchCount == nil || *info.BranchCount != 2 {
|
||||
t.Fatalf("BranchCount=%v want 2", info.BranchCount)
|
||||
}
|
||||
if info.TagCount == nil || *info.TagCount != 2 {
|
||||
t.Fatalf("TagCount=%v want 2", info.TagCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContributorsUnique(t *testing.T) {
|
||||
@@ -252,12 +263,13 @@ func TestCollectNotGitRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true}
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true, "branch": true, "tag": true}
|
||||
var used []string
|
||||
_, err := Collect(context.Background(), Options{
|
||||
Now: time.Unix(0, 0),
|
||||
RunGit: fakeGit(t, map[string]string{
|
||||
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
|
||||
"branch": "main\n", "tag": "v0.1.0\n",
|
||||
}, &used),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -190,6 +190,67 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsRestrictedTokenNestedPipeCapture pins the fix in
|
||||
// broadenWindowsRestrictedTokenDefaultDacl (windows_token_windows.go): a
|
||||
// nested subprocess spawned FROM WITHIN the sandboxed process, with its
|
||||
// output captured via a pipe, must succeed. Before the fix, the sandboxed
|
||||
// process's own attempt to create a pipe for such a subprocess failed with
|
||||
// ERROR_ACCESS_DENIED (Win32 error 5) — the WRITE_RESTRICTED token's extra
|
||||
// access check has no restricted-SID match against a freshly created pipe's
|
||||
// default security descriptor. This is exactly the pattern any tool that
|
||||
// shells out internally and captures output hits (`gh` invoking `git`, for
|
||||
// one; cmd.exe's own FOR /F does the identical CreatePipe+CreateProcess
|
||||
// dance internally, so it reproduces the bug with no external dependency
|
||||
// beyond cmd.exe itself).
|
||||
func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
|
||||
root := t.TempDir()
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
marker := filepath.Join(root, "nested-pipe-marker.txt")
|
||||
script := filepath.Join(root, "nested-pipe.cmd")
|
||||
// FOR /F drives cmd.exe's own internal CreatePipe+CreateProcess capture
|
||||
// of the quoted command's output — the exact mechanism this fix targets.
|
||||
// The full path to whoami.exe sidesteps PATH resolution inside the
|
||||
// sandboxed process's minimal environment.
|
||||
scriptBody := "for /F %%i in ('C:\\Windows\\System32\\whoami.exe') do echo %%i> " + marker + "\r\n"
|
||||
if err := os.WriteFile(script, []byte(scriptBody), 0o644); err != nil {
|
||||
t.Fatalf("write nested-pipe script: %v", err)
|
||||
}
|
||||
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", script,
|
||||
}, 0)
|
||||
captured, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("read nested-pipe marker: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(captured)) == "" {
|
||||
t.Fatalf("nested-pipe marker is empty; FOR /F failed to capture the subprocess's output")
|
||||
}
|
||||
}
|
||||
|
||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||
t.Helper()
|
||||
if path := os.Getenv(envKey); path != "" {
|
||||
|
||||
@@ -21,6 +21,11 @@ const (
|
||||
|
||||
var procCreateRestrictedToken = windows.NewLazySystemDLL("advapi32.dll").NewProc("CreateRestrictedToken")
|
||||
|
||||
// procSetEntriesInAclW merges new ACEs into an existing ACL. Not wrapped by
|
||||
// x/sys/windows (its internal setEntriesInAcl is unexported), so it is
|
||||
// declared directly the same way procCreateRestrictedToken is.
|
||||
var procSetEntriesInAclW = windows.NewLazySystemDLL("advapi32.dll").NewProc("SetEntriesInAclW")
|
||||
|
||||
type windowsLocalSID struct {
|
||||
sid *windows.SID
|
||||
}
|
||||
@@ -122,9 +127,139 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
|
||||
_ = restricted.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := broadenWindowsRestrictedTokenDefaultDacl(restricted, sidFromBytes(logonSID)); err != nil {
|
||||
_ = restricted.Close()
|
||||
return 0, err
|
||||
}
|
||||
runtime.KeepAlive(logonSID)
|
||||
return restricted, nil
|
||||
}
|
||||
|
||||
// broadenWindowsRestrictedTokenDefaultDacl adds the token's own logon SID to
|
||||
// its default DACL (the DACL applied to any kernel object the token's process
|
||||
// creates WITHOUT an explicit security descriptor, e.g. anonymous pipes,
|
||||
// events, mutexes).
|
||||
//
|
||||
// A WRITE_RESTRICTED token requires a WRITE-type access check to match BOTH
|
||||
// the normal enabled-SID grant AND a SEPARATE grant to one of the token's
|
||||
// restricted SIDs. CreateRestrictedToken does not touch the default DACL it
|
||||
// inherits from the base token, so newly created objects only carry ACEs for
|
||||
// the base token's normal identity (owner, SYSTEM, Administrators) — none of
|
||||
// which are in the restricted-SID list — so the second check fails. This
|
||||
// breaks anything that creates a pipe/handle for itself with default
|
||||
// security, which is exactly what any tool using a language runtime's
|
||||
// "spawn a subprocess and capture its output" primitive does (Go's os/exec,
|
||||
// and by extension `gh`, and many other CLI tools that shell out
|
||||
// internally): CreatePipe() succeeds, but the creating process's own token
|
||||
// then fails to actually read/write the handle it just made.
|
||||
//
|
||||
// The logon SID is already unconditionally present in this token's
|
||||
// restricted-SID list (see createWindowsRestrictedTokenFromBase), so adding
|
||||
// it here — and ONLY it — to the default DACL is the minimal change that
|
||||
// satisfies the WRITE_RESTRICTED extra check for self-created objects. It
|
||||
// does not touch file-system access: NTFS write-jailing is enforced by the
|
||||
// explicit ACL grants applied to workspace paths (windows_acl.go), which are
|
||||
// created WITH an explicit security descriptor and never consult the token's
|
||||
// default DACL. The exposure is bounded to the same logon session (other
|
||||
// processes running as the same signed-in user could, in principle, open a
|
||||
// NAMED kernel object the sandboxed process creates with default security;
|
||||
// anonymous pipes — the actual object type this fixes — have no name and are
|
||||
// only reachable via an inherited handle, so this is a no-op for them).
|
||||
func broadenWindowsRestrictedTokenDefaultDacl(token windows.Token, logonSID *windows.SID) error {
|
||||
oldDacl, oldDaclBuf, err := windowsTokenDefaultDacl(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read token default DACL: %w", err)
|
||||
}
|
||||
// The second (WRITE-type) access check only needs a read/write match; a
|
||||
// full GENERIC_ALL grant (which also implies DELETE, WRITE_DAC, and
|
||||
// WRITE_OWNER) is broader than the pipe/event/mutex use case this exists
|
||||
// for actually requires.
|
||||
access := []windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_READ | windows.GENERIC_WRITE,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE,
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(logonSID),
|
||||
},
|
||||
}}
|
||||
newDacl, err := setEntriesInACL(access, oldDacl)
|
||||
// oldDacl points INTO oldDaclBuf (see windowsTokenDefaultDacl) and is
|
||||
// dereferenced by native code inside setEntriesInACL, above. Nothing in Go
|
||||
// references oldDaclBuf once windowsTokenDefaultDacl returned, so without
|
||||
// this KeepAlive it is eligible for GC the moment an allocation in this
|
||||
// function (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) hits a
|
||||
// safepoint, before the native call ever reads it - a real, if narrow,
|
||||
// use-after-free window in security-boundary code.
|
||||
runtime.KeepAlive(oldDaclBuf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("merge token default DACL: %w", err)
|
||||
}
|
||||
defer func() { _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(newDacl))) }()
|
||||
if err := windowsSetTokenDefaultDacl(token, newDacl); err != nil {
|
||||
return fmt.Errorf("set token default DACL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type windowsTokenDefaultDaclInfo struct {
|
||||
DefaultDacl *windows.ACL
|
||||
}
|
||||
|
||||
// windowsTokenDefaultDacl returns the token's current default DACL along with
|
||||
// the buffer backing it. TOKEN_DEFAULT_DACL embeds the ACL data inside the
|
||||
// same allocation GetTokenInformation fills, so the returned *windows.ACL is
|
||||
// only valid for as long as the returned buffer stays reachable: the caller
|
||||
// must runtime.KeepAlive(buf) until every native call that dereferences the
|
||||
// ACL has completed. A KeepAlive inside this function, ending at its own
|
||||
// return, would not protect any use by the caller after that.
|
||||
func windowsTokenDefaultDacl(token windows.Token) (*windows.ACL, []byte, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, nil, 0, &size)
|
||||
if err == nil || err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, nil, fmt.Errorf("size TokenDefaultDacl: %w", err)
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, &buf[0], size, &size); err != nil {
|
||||
return nil, nil, fmt.Errorf("get TokenDefaultDacl: %w", err)
|
||||
}
|
||||
info := (*windowsTokenDefaultDaclInfo)(unsafe.Pointer(&buf[0]))
|
||||
return info.DefaultDacl, buf, nil
|
||||
}
|
||||
|
||||
func windowsSetTokenDefaultDacl(token windows.Token, dacl *windows.ACL) error {
|
||||
info := windowsTokenDefaultDaclInfo{DefaultDacl: dacl}
|
||||
err := windows.SetTokenInformation(token, windows.TokenDefaultDacl, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
|
||||
runtime.KeepAlive(info)
|
||||
return err
|
||||
}
|
||||
|
||||
// setEntriesInACL merges entries into oldACL via Win32 SetEntriesInAclW,
|
||||
// returning a newly allocated ACL the caller must free with windows.LocalFree.
|
||||
func setEntriesInACL(entries []windows.EXPLICIT_ACCESS, oldACL *windows.ACL) (*windows.ACL, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, errors.New("setEntriesInACL requires at least one entry")
|
||||
}
|
||||
var newACL *windows.ACL
|
||||
// SetEntriesInAclW reports failure directly in its return value (ERROR_SUCCESS
|
||||
// on success, a Win32 error code otherwise); unlike many Win32 APIs it does not
|
||||
// set the thread's last-error, so the syscall trio's second/third return
|
||||
// values (which surface GetLastError) are not meaningful here.
|
||||
ret, _, _ := procSetEntriesInAclW.Call(
|
||||
uintptr(len(entries)),
|
||||
uintptr(unsafe.Pointer(&entries[0])),
|
||||
uintptr(unsafe.Pointer(oldACL)),
|
||||
uintptr(unsafe.Pointer(&newACL)),
|
||||
)
|
||||
runtime.KeepAlive(entries)
|
||||
if ret != 0 {
|
||||
return nil, fmt.Errorf("SetEntriesInAclW: %w", syscall.Errno(ret))
|
||||
}
|
||||
return newACL, nil
|
||||
}
|
||||
|
||||
func copyWindowsLogonSID(token windows.Token) ([]byte, error) {
|
||||
groups, err := token.GetTokenGroups()
|
||||
if err == nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ type EventType string
|
||||
const (
|
||||
EventRunStart EventType = "run_start"
|
||||
EventText EventType = "text"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventPermission EventType = "permission"
|
||||
EventPermissionRequest EventType = "permission_request"
|
||||
|
||||
@@ -526,3 +526,159 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type PushOptions struct {
|
||||
Cwd string
|
||||
Remote string
|
||||
Branch string
|
||||
Force bool
|
||||
DryRun bool
|
||||
AllowPushDefaultBranch bool
|
||||
RunGit Runner
|
||||
RunGitEnv EnvRunner
|
||||
}
|
||||
|
||||
type PushResult struct {
|
||||
Remote string `json:"remote"`
|
||||
Branch string `json:"branch"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func Push(ctx context.Context, options PushOptions) (PushResult, error) {
|
||||
cwd, err := resolveCwd(options.Cwd)
|
||||
if err != nil {
|
||||
return PushResult{}, err
|
||||
}
|
||||
runGit, _ := resolveRunners(options.RunGit, options.RunGitEnv)
|
||||
|
||||
root, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("not a git repository: %w", err)
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
branch := strings.TrimSpace(options.Branch)
|
||||
if branch == "" {
|
||||
branch, err = gitOutput(ctx, runGit, root, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("resolve current branch: %w", err)
|
||||
}
|
||||
}
|
||||
if branch == "HEAD" {
|
||||
return PushResult{}, fmt.Errorf("cannot push: not currently on a branch")
|
||||
}
|
||||
|
||||
remote := strings.TrimSpace(options.Remote)
|
||||
if remote == "" {
|
||||
if upstream, err := gitOutput(ctx, runGit, root, "config", "branch."+branch+".remote"); err == nil && upstream != "" {
|
||||
remote = upstream
|
||||
} else {
|
||||
remote = "origin"
|
||||
}
|
||||
}
|
||||
|
||||
if !options.AllowPushDefaultBranch {
|
||||
if isDefaultBranch(ctx, runGit, root, remote, branch) {
|
||||
return PushResult{}, fmt.Errorf("refusing to push to %q (default/protected branch); use --yes to override", branch)
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"push"}
|
||||
if options.DryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
if options.Force {
|
||||
args = append(args, "--force-with-lease")
|
||||
}
|
||||
args = append(args, "-u", "--", remote, branch)
|
||||
|
||||
output, err := gitRawOutput(ctx, runGit, root, args...)
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
|
||||
return PushResult{
|
||||
Remote: remote,
|
||||
Branch: branch,
|
||||
Output: output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isDefaultBranch(ctx context.Context, runGit Runner, dir, remote, branch string) bool {
|
||||
if out, err := gitOutput(ctx, runGit, dir, "ls-remote", "--symref", remote, "HEAD"); err == nil {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "ref: refs/heads/") && strings.HasSuffix(line, "\tHEAD") {
|
||||
symref := strings.TrimPrefix(line, "ref: refs/heads/")
|
||||
symref = strings.TrimSuffix(symref, "\tHEAD")
|
||||
return branch == symref
|
||||
}
|
||||
}
|
||||
}
|
||||
return branch == "main" || branch == "master"
|
||||
}
|
||||
|
||||
type PROptions struct {
|
||||
Cwd string
|
||||
Fill bool
|
||||
Draft bool
|
||||
Title string
|
||||
Body string
|
||||
RunGH Runner
|
||||
}
|
||||
|
||||
type PRResult struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func CreatePR(ctx context.Context, options PROptions) (PRResult, error) {
|
||||
cwd, err := resolveCwd(options.Cwd)
|
||||
if err != nil {
|
||||
return PRResult{}, err
|
||||
}
|
||||
|
||||
runGH := options.RunGH
|
||||
if runGH == nil {
|
||||
runGH = func(ctx context.Context, dir string, args ...string) (CommandResult, error) {
|
||||
cmd := exec.CommandContext(ctx, "gh", args...)
|
||||
cmd.Dir = dir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
var exitCode int
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, err
|
||||
}
|
||||
}
|
||||
|
||||
prArgs := []string{"pr", "create"}
|
||||
if options.Fill {
|
||||
prArgs = append(prArgs, "--fill")
|
||||
}
|
||||
if options.Draft {
|
||||
prArgs = append(prArgs, "--draft")
|
||||
}
|
||||
if options.Title != "" {
|
||||
prArgs = append(prArgs, "--title", options.Title)
|
||||
}
|
||||
if options.Body != "" {
|
||||
prArgs = append(prArgs, "--body", options.Body)
|
||||
}
|
||||
|
||||
res, err := runGH(ctx, cwd, prArgs...)
|
||||
if err != nil {
|
||||
return PRResult{}, fmt.Errorf("gh pr create failed: %w\n%s", err, res.Stderr)
|
||||
}
|
||||
|
||||
return PRResult{
|
||||
Output: res.Stdout,
|
||||
}, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -521,3 +522,208 @@ func TestParseStatusZHandlesRenamesAndSpecialPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushBranchesToRemote(t *testing.T) {
|
||||
t.Run("HappyPath", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "feat/some-feature\n"},
|
||||
{Stdout: "origin\n"}, // config branch.feat/some-feature.remote
|
||||
{}, // ls-remote --symref (no match → falls through)
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
result, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Remote != "origin" || result.Branch != "feat/some-feature" || !strings.Contains(result.Output, "Everything up-to-date") {
|
||||
t.Fatalf("unexpected push result: %#v", result)
|
||||
}
|
||||
|
||||
if got := runner.commandLine(4); got != "git push -u -- origin feat/some-feature" {
|
||||
t.Fatalf("unexpected push command: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FlagsForceAndDryRun", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "feat/some-feature\n"},
|
||||
{Stdout: "origin\n"}, // config branch.feat/some-feature.remote
|
||||
{}, // ls-remote --symref (no match)
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
_, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
Force: true,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := runner.commandLine(4); got != "git push --dry-run --force-with-lease -u -- origin feat/some-feature" {
|
||||
t.Fatalf("unexpected push command: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DetachedHEAD", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "HEAD\n"},
|
||||
}}
|
||||
|
||||
_, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on detached HEAD push, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot push: not currently on a branch") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RejectsDefaultBranch", func(t *testing.T) {
|
||||
for _, branch := range []string{"main", "master"} {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: branch + "\n"},
|
||||
}}
|
||||
|
||||
_, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when pushing %q, got nil", branch)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "default/protected branch") {
|
||||
t.Fatalf("unexpected error for %q: %v", branch, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllowDefaultBranchWithYes", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "main\n"},
|
||||
{Stdout: "origin\n"},
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
result, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
AllowPushDefaultBranch: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
if result.Branch != "main" {
|
||||
t.Fatalf("expected branch main, got %q", result.Branch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FallbackRemoteToOrigin", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "feat/some-feature\n"},
|
||||
{ExitCode: 1, Stderr: "error: no such section"}, // config lookup fails
|
||||
{}, // ls-remote --symref (no match)
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
result, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Remote != "origin" {
|
||||
t.Fatalf("expected fallback remote to be origin, got: %q", result.Remote)
|
||||
}
|
||||
|
||||
if got := runner.commandLine(4); got != "git push -u -- origin feat/some-feature" {
|
||||
t.Fatalf("unexpected push command: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePRCommandConstruction(t *testing.T) {
|
||||
t.Run("CreatePRWithAllOptions", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: "https://github.com/Gitlawb/zero/pull/123\n"},
|
||||
}}
|
||||
|
||||
result, err := CreatePR(context.Background(), PROptions{
|
||||
Cwd: root,
|
||||
Fill: true,
|
||||
Draft: true,
|
||||
Title: "Feat: some title",
|
||||
Body: "Some body description",
|
||||
RunGH: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Output != "https://github.com/Gitlawb/zero/pull/123\n" {
|
||||
t.Fatalf("unexpected PR result: %#v", result)
|
||||
}
|
||||
|
||||
expectedArgs := []string{"pr", "create", "--fill", "--draft", "--title", "Feat: some title", "--body", "Some body description"}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 runner call, got %d", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].args; !reflect.DeepEqual(got, expectedArgs) {
|
||||
t.Fatalf("unexpected gh args: %v, want %v", got, expectedArgs)
|
||||
}
|
||||
if runner.calls[0].dir != root {
|
||||
t.Fatalf("unexpected dir: %q, want %q", runner.calls[0].dir, root)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreatePRMinimal", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: "https://github.com/Gitlawb/zero/pull/124\n"},
|
||||
}}
|
||||
|
||||
_, err := CreatePR(context.Background(), PROptions{
|
||||
Cwd: root,
|
||||
RunGH: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
|
||||
expectedArgs := []string{"pr", "create"}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 runner call, got %d", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].args; !reflect.DeepEqual(got, expectedArgs) {
|
||||
t.Fatalf("unexpected gh args: %v, want %v", got, expectedArgs)
|
||||
}
|
||||
if runner.calls[0].dir != root {
|
||||
t.Fatalf("unexpected dir: %q, want %q", runner.calls[0].dir, root)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,9 @@ type CollectedStream struct {
|
||||
// thinking blocks) that must be replayed on the next turn. Empty for providers
|
||||
// or runs without extended thinking.
|
||||
ReasoningBlocks []ReasoningBlock
|
||||
// HasReasoning records whether the provider streamed reasoning deltas. The
|
||||
// deltas remain non-answer content, but they still prove the turn was live.
|
||||
HasReasoning bool
|
||||
}
|
||||
|
||||
// Truncated reports whether the response ended for a non-normal reason (the
|
||||
@@ -132,6 +135,9 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
|
||||
options.OnText(event.Content)
|
||||
}
|
||||
case StreamEventReasoning:
|
||||
if strings.TrimSpace(event.Content) != "" {
|
||||
collected.HasReasoning = true
|
||||
}
|
||||
if options.OnReasoning != nil {
|
||||
options.OnReasoning(event.Content)
|
||||
}
|
||||
|
||||
@@ -312,6 +312,9 @@ func TestCollectStreamWithOptionsEmitsTextReasoningAndUsageCallbacks(t *testing.
|
||||
if collected.Text != "Hello zero" {
|
||||
t.Fatalf("text = %q, want Hello zero", collected.Text)
|
||||
}
|
||||
if !collected.HasReasoning {
|
||||
t.Fatal("expected reasoning stream to mark collected turn as reasoning-bearing")
|
||||
}
|
||||
if len(textDeltas) != 2 || textDeltas[0] != "Hello " || textDeltas[1] != "zero" {
|
||||
t.Fatalf("unexpected text callbacks: %#v", textDeltas)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user