Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 f2e7c14637 test(cli): isolate the exec image tests from the models.dev refresh
runExec starts a background models.dev download unless
ZERO_DISABLE_MODELS_FETCH is set. TestRunExecImageAttachmentHistory moved
the user directories into a TempDir by hand but left the fetch on, so a
5.7 MB download landed in the TempDir while its cleanup ran, and the
macOS runner failed with "unlinkat ... directory not empty". The two
older image tests had no isolation and fetched into the real user cache.

Use isolateCLIUserState in all three, which also points the cache path
into the fixture and turns the fetch off.
2026-10-17 19:17:58 +05:30
Vasanth T ef4da1323f fix(workspaceindex): import gitsafe under the Twigpine module path (#1153)
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
2026-10-17 16:59:52 +08:00
zzucs 1e848492e5 fix(sessions): preserve image attachment summaries on resume (#1152)
* fix(sessions): preserve image attachment summaries on resume

Persist TUI-compatible attachment counts for ACP and exec user events.
Restore readable markers in live history, session replay and resumed
prompts without storing or resending historical image data.

Cover image-only and mixed prompts, restart recovery, malformed metadata
and the aborted-turn boundary with regression tests.

Fixes #1135

* fix(workspaceindex): follow the current module path

Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its
tests. The upstream module rename preceded the scanner's gitsafe change,
leaving these two imports on the old path and preventing the rebased tree
from compiling.

* fix(sessions): count only images accepted by the model

Take ACP and exec attachment counts after the vision capability gate,
matching the TUI's persisted attachment semantics. Update the ignored-image
regressions to require zero saved images and no historical image marker.

Refs #1135
2026-10-17 16:59:21 +08:00
10 changed files with 444 additions and 14 deletions

No files matched your search

+15 -8
View File
@@ -528,8 +528,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
@@ -553,6 +551,9 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
images = nil
}
// Match the TUI: only images actually sent to the model enter history.
attachments := sessions.MessageAttachments{Images: len(images)}
queue(messageEvent("user", userText, attachments))
opts := agent.Options{
Cwd: sess.cwd,
@@ -593,7 +594,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer))
queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{}))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
@@ -614,7 +615,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
@@ -930,10 +931,14 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
return err
}
func messageEvent(role, content string) sessions.AppendEventInput {
func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput {
payload := map[string]any{"role": role, "content": content}
if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) {
payload["attachments"] = attachments
}
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": role, "content": content},
Payload: payload,
}
}
@@ -1094,14 +1099,16 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
continue
}
var msg struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
Attachments sessions.MessageAttachments `json:"attachments"`
}
if json.Unmarshal(raw, &msg) != nil {
continue
}
switch msg.Role {
case "user":
msg.Content = msg.Attachments.WithContent(msg.Content)
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
+5 -1
View File
@@ -960,7 +960,11 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
SessionID: created.SessionID,
Prompt: []ContentBlock{
TextBlock("failed question"),
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
+181
View File
@@ -0,0 +1,181 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestACPImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name string
text string
images int
withoutVision bool
want string
}{
{name: "image only", images: 1, want: "[1 image]"},
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
modalities := []string{"text", "image"}
if tc.withoutVision {
modalities = []string{"text"}
}
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
}
type observedTurn struct {
prompt string
images int
}
turns := make(chan observedTurn, 8)
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
return agent.Result{FinalAnswer: "received"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
h := newHarness(t, deps)
defer h.stop()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
const imageData = "image-history-bytes-not-for-storage"
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
var blocks []ContentBlock
if tc.text != "" {
blocks = append(blocks, TextBlock(tc.text))
}
for range tc.images {
blocks = append(blocks, ImageBlock(encoded, "image/png"))
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
t.Fatal(err)
}
live := <-turns
wantLiveImages := tc.images
if tc.withoutVision {
wantLiveImages = 0
}
if live.images != wantLiveImages {
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil || len(events) < 2 {
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
}
var user struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
t.Errorf("image data leaked into session event: %s", event.Payload)
}
}
followUp := func(t *testing.T, client *clientHarness) {
t.Helper()
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := <-turns
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
}
if got.images != 0 {
t.Errorf("historical images reattached: %d", got.images)
}
}
followUp(t, h)
h.stop()
// Fresh agents and stores reproduce a process restart, with no shared
// in-memory history to hide a missing attachment in the event log.
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
t.Run(method, func(t *testing.T) {
restoredDeps := deps
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
loader := newHarness(t, restoredDeps)
defer loader.stop()
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
t.Fatal(err)
}
if method == MethodSessionLoad {
select {
case update := <-loader.notifications:
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
t.Errorf("replayed user = %+v, want %q", update, tc.want)
}
case <-ctx.Done():
t.Fatal("user turn was not replayed")
}
} else {
select {
case update := <-loader.notifications:
t.Errorf("resume replayed transcript: %+v", update)
default:
}
}
followUp(t, loader)
})
}
})
}
}
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for i, payload := range []string{
`{"role":"user","content":"old text"}`,
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
} {
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
t.Fatalf("append message %d: %v", i, err)
}
}
a := &Agent{deps: deps}
_, messages, _, err := a.loadHistory(meta.SessionID, false)
if err != nil {
t.Fatal(err)
}
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
if len(messages) != len(want) {
t.Fatalf("messages = %+v", messages)
}
for i, text := range want {
if messages[i].content != text {
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
}
}
}
+8 -2
View File
@@ -434,6 +434,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
images = nil
}
// Match the TUI: only images actually sent to the model enter history.
attachments := sessions.MessageAttachments{Images: len(images)}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
@@ -614,10 +616,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
sessionRecorder := execSessionRecorder{prepared: preparedSession}
// Surface a best-effort session-recording failure once, on every exit path.
defer sessionRecorder.warnIfRecordingFailed(stderr)
sessionRecorder.append(sessions.EventMessage, map[string]any{
userPayload := map[string]any{
"role": "user",
"content": prompt,
})
}
if attachments.Images > 0 {
userPayload["attachments"] = attachments
}
sessionRecorder.append(sessions.EventMessage, userPayload)
// OnAskUser is intentionally left unset: headless runs have no interactive
// user, so ask_user degrades to a "proceed with your best assumption" result
+107
View File
@@ -4,11 +4,16 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
@@ -18,6 +23,7 @@ import (
type capturingImageProvider struct {
mu sync.Mutex
images []zeroruntime.ImageBlock
prompt string
}
func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
@@ -25,6 +31,7 @@ func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, re
for index := len(request.Messages) - 1; index >= 0; index-- {
if request.Messages[index].Role == zeroruntime.MessageRoleUser {
provider.images = append([]zeroruntime.ImageBlock(nil), request.Messages[index].Images...)
provider.prompt = request.Messages[index].Content
break
}
}
@@ -37,12 +44,111 @@ func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, re
return ch, nil
}
func TestRunExecImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name, model string
fileImage bool
}{
{name: "vision", model: "gpt-4.1"},
{name: "ignored by model", model: "gpt-3.5-turbo"},
{name: "stream and file images", model: "gpt-4.1", fileImage: true},
} {
t.Run(tc.name, func(t *testing.T) {
// The shared isolation also turns off the models.dev refresh that
// runExec starts in the background. Without it, the refresh downloads
// the catalog into this HOME and races the TempDir cleanup.
isolateCLIUserState(t)
cwd := t.TempDir()
png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52}
encoded := base64.StdEncoding.EncodeToString(png)
input := `{"schemaVersion":2,"type":"message","role":"user","content":"","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
provider := &capturingImageProvider{}
deps := appDeps{
stdin: strings.NewReader(input),
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
cfg := execResolvedConfig()
cfg.Provider.Model = tc.model
return cfg, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return provider, nil },
}
var stdout, stderr bytes.Buffer
args := []string{"exec", "--input-format", "stream-json", "--init-session-id", "image_history"}
wantImages := 1
if tc.fileImage {
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o600); err != nil {
t.Fatal(err)
}
args = append(args, "--image", "shot.png")
wantImages++
}
code := runWithDeps(args, &stdout, &stderr, deps)
if code != exitSuccess {
t.Fatalf("image-only exec exit=%d stderr=%s", code, stderr.String())
}
provider.mu.Lock()
liveImages := len(provider.images)
provider.mu.Unlock()
wantLiveImages := wantImages
if tc.model == "gpt-3.5-turbo" {
wantLiveImages = 0
}
if liveImages != wantLiveImages {
t.Fatalf("live images = %d, want %d", liveImages, wantLiveImages)
}
store := sessions.NewStore(sessions.StoreOptions{})
events, err := store.ReadEvents("image_history")
if err != nil || len(events) == 0 {
t.Fatalf("read exec events: %v, %v", events, err)
}
var user struct {
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Content != "" || user.Attachments.Images != wantLiveImages {
t.Errorf("stored exec user event = %s, want empty content and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) {
t.Errorf("image data leaked into session: %s", event.Payload)
}
}
stdout.Reset()
stderr.Reset()
deps.stdin = strings.NewReader("")
code = runWithDeps([]string{"exec", "--resume", "image_history", "what did I send?"}, &stdout, &stderr, deps)
if code != exitSuccess {
t.Fatalf("resume exec exit=%d stderr=%s", code, stderr.String())
}
provider.mu.Lock()
defer provider.mu.Unlock()
marker := "[1 image]"
if wantImages > 1 {
marker = fmt.Sprintf("[%d images]", wantImages)
}
if hasMarker := strings.Contains(provider.prompt, marker); hasMarker != (wantLiveImages > 0) {
t.Errorf("resume marker %q present = %v, want %v: %q", marker, hasMarker, wantLiveImages > 0, provider.prompt)
}
if len(provider.images) != 0 {
t.Errorf("resume reattached %d historical images", len(provider.images))
}
})
}
}
// TestRunExecStreamJSONImageReachesAgent locks the end-to-end wiring: a base64
// image carried on a stream-json `message` event, fed via stdin on a vision
// model, must reach agent.Options.Images (and therefore the provider request's
// user turn). Before the fix, stream-json images were parsed and validated but
// silently dropped — never threaded into the agent run.
func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
// A minimal PNG, base64-encoded for the stream-json image payload.
@@ -112,6 +218,7 @@ func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
// the fix, ResolvePrompt's "must include at least one prompt or user message
// event" error was returned before images were considered, rejecting the turn.
func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
png := []byte{
+27 -1
View File
@@ -182,7 +182,11 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
lines := []string{}
for _, event := range events {
lines = append(lines, fmt.Sprintf("- #%d %s: %s", event.Sequence, event.Type, summarizePayload(event.Payload)))
payload := any(event.Payload)
if event.Type == EventMessage {
payload = messageContextPayload(event.Payload)
}
lines = append(lines, fmt.Sprintf("- #%d %s: %s", event.Sequence, event.Type, summarizePayload(payload)))
}
label := "Continuing"
sessionID := prepared.Session.SessionID
@@ -512,6 +516,28 @@ func forkTitle(title string) string {
return title + " (fork)"
}
// Project attachment counts into readable text before the generic summary walks
// the payload. Keep other message metadata and non-message events unchanged.
func messageContextPayload(payload json.RawMessage) any {
var message map[string]any
if json.Unmarshal(payload, &message) != nil || message["role"] != "user" {
return payload
}
content, ok := message["content"].(string)
if !ok {
return payload
}
var fields struct {
Attachments MessageAttachments `json:"attachments"`
}
if json.Unmarshal(payload, &fields) != nil {
return payload
}
message["content"] = fields.Attachments.WithContent(content)
delete(message, "attachments")
return message
}
func summarizePayload(payload any) string {
text := extractText(payload)
text = strings.Join(strings.Fields(text), " ")
+68
View File
@@ -0,0 +1,68 @@
package sessions
import (
"encoding/json"
"fmt"
"math"
"strings"
)
// MessageAttachments is the count-only summary stored on user message events.
// It matches the TUI's attachments payload; image data is never persisted here.
type MessageAttachments struct {
Images int `json:"images"`
Documents int `json:"documents"`
}
// Match the TUI's bound when reading untrusted persisted attachment counts.
const maxMessageAttachments = 64
// UnmarshalJSON ignores malformed counts without dropping the message's text.
func (a *MessageAttachments) UnmarshalJSON(data []byte) error {
var raw struct {
Images json.RawMessage `json:"images"`
Documents json.RawMessage `json:"documents"`
}
*a = MessageAttachments{}
if json.Unmarshal(data, &raw) != nil {
return nil
}
a.Images = messageAttachmentCount(raw.Images)
a.Documents = messageAttachmentCount(raw.Documents)
return nil
}
func messageAttachmentCount(raw json.RawMessage) int {
var count float64
if json.Unmarshal(raw, &count) != nil || count <= 0 || count != math.Trunc(count) {
return 0
}
return int(min(count, maxMessageAttachments))
}
// WithContent renders an attachment marker beside the original message text for
// history and replay. The stored content itself stays unchanged.
func (a MessageAttachments) WithContent(content string) string {
parts := []string{}
for _, item := range []struct {
count int
name string
}{{a.Images, "image"}, {a.Documents, "document"}} {
count := min(max(item.count, 0), maxMessageAttachments)
if count == 0 {
continue
}
name := item.name
if count != 1 {
name += "s"
}
parts = append(parts, fmt.Sprintf("[%d %s]", count, name))
}
if len(parts) == 0 {
return content
}
if content != "" {
return content + " " + strings.Join(parts, " ")
}
return strings.Join(parts, " ")
}
@@ -0,0 +1,31 @@
package sessions
import (
"encoding/json"
"strings"
"testing"
)
func TestFormatExecPromptAttachmentSummary(t *testing.T) {
for _, tc := range []struct{ name, payload, want string }{
{"image only", `{"role":"user","content":"","attachments":{"images":1,"documents":0}}`, "[1 image] user"},
{"text and images", `{"role":"user","content":"compare","attachments":{"images":2}}`, "compare [2 images] user"},
{"TUI documents", `{"role":"user","content":"read","attachments":{"documents":1}}`, "read [1 document] user"},
{"old text", `{"role":"user","content":"old"}`, "old user"},
{"malformed counts", `{"role":"user","content":"text","attachments":{"images":-2,"documents":1.5}}`, "text user"},
{"wrong attachment type", `{"role":"user","content":"text","attachments":"wrong"}`, "text user"},
{"wrong count type", `{"role":"user","content":"text","attachments":{"images":"1","documents":true}}`, "text user"},
{"bounded counts", `{"role":"user","content":"","attachments":{"images":1e100,"documents":64.5}}`, "[64 images] user"},
{"preserve metadata", `{"role":"user","content":"read","origin":"cross_session","from":"peer","attachments":{"images":1}}`, "read [1 image] peer cross_session user"},
} {
t.Run(tc.name, func(t *testing.T) {
got := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
ContextEvents: []Event{{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(tc.payload)}},
})
if !strings.Contains(got, "- #1 message: "+tc.want+"\n") {
t.Errorf("resume context = %q, want message %q", got, tc.want)
}
})
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/gitsafe"
"github.com/Twigpine/zero/internal/gitsafe"
)
// gitIgnoredPathsTimeout bounds how long Scan waits on git before giving up and falling
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/gitsafe"
"github.com/Twigpine/zero/internal/gitsafe"
)
// isolateGitConfig keeps the git child processes away from the developer's real global and