Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions internal/adapters/git/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,3 +363,62 @@ func TestCreateBackup_AutoPruneGate_PruneErrorDoesNotBlock(t *testing.T) {
t.Errorf("new backup ref %s was not created", backup.Ref)
}
}

// TestCollectReachabilityTips_UnbornRepo_SkipsHead verifies that on a repo
// with zero commits (unborn HEAD), collectReachabilityTips tolerates the
// rev-parse HEAD failure, skips the HEAD tip, and returns the local branch
// tips (empty on a fresh repo). No error. See spec delta "Backup prune on
// unborn repo".
func TestCollectReachabilityTips_UnbornRepo_SkipsHead(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
// Force a default branch name so symbolic-ref is consistent.
gitRun(t, dir, "symbolic-ref", "HEAD", "refs/heads/master")
a := New(dir)

// No commits — HEAD is unborn. No branches exist either (refs/heads/ is
// empty), so the expected result is an empty slice with no error.
tips, err := a.collectReachabilityTips()
if err != nil {
t.Fatalf("collectReachabilityTips on unborn repo should not error, got: %v", err)
}
if len(tips) != 0 {
t.Errorf("collectReachabilityTips on unborn repo with no branches = %v, want empty", tips)
}
}

// TestCollectReachabilityTips_UnbornRepo_WithBranchTips verifies that on an
// unborn repo that still has local branch refs pointing at commits (e.g.,
// created by a prior session), collectReachabilityTips skips the unborn HEAD
// tip but still collects the branch tips. See spec delta "Backup prune on
// unborn repo".
func TestCollectReachabilityTips_UnbornRepo_WithBranchTips(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)
gitRun(t, dir, "symbolic-ref", "HEAD", "refs/heads/master")
a := New(dir)

// Commit on master, create a feature branch at that commit.
featureCommit := commitFile(t, a, dir, "base.txt", "base")
gitRun(t, dir, "branch", "feature")
// Make HEAD unborn: delete the master ref via update-ref -d (ref-level,
// works even when HEAD points at it — HEAD becomes unborn).
gitRun(t, dir, "update-ref", "-d", "refs/heads/master")
// HEAD is now unborn (symbolic-ref still points at refs/heads/master which
// no longer exists), but refs/heads/feature points at featureCommit.

tips, err := a.collectReachabilityTips()
if err != nil {
t.Fatalf("collectReachabilityTips should tolerate unborn HEAD, got: %v", err)
}
// The feature branch tip must be collected even though HEAD was skipped.
found := false
for _, tip := range tips {
if tip == featureCommit {
found = true
}
}
if !found {
t.Errorf("feature branch tip %s not collected; tips = %v", featureCommit, tips)
}
}
18 changes: 11 additions & 7 deletions internal/adapters/git/exec_backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,20 @@ func (a *ExecAdapter) listBackupRefs() ([]domain.Backup, error) {
}

// collectReachabilityTips returns the commit OIDs for HEAD and every local
// branch tip under refs/heads/. HEAD is always first.
// branch tip under refs/heads/. HEAD is always first when it resolves. On an
// unborn repo (zero commits) rev-parse HEAD fails; the HEAD tip is skipped
// and only branch tips are returned. See the unborn-first-commit spec delta
// "Backup prune on unborn repo".
func (a *ExecAdapter) collectReachabilityTips() ([]string, error) {
var tips []string

head, err := a.runGit("rev-parse", "HEAD")
if err != nil {
return nil, fmt.Errorf("resolving HEAD: %w", err)
}
if h := strings.TrimSpace(head); h != "" {
tips = append(tips, h)
// HEAD is best-effort: on an unborn repo rev-parse HEAD fails, so skip
// the HEAD tip rather than aborting the whole prune. Branch tips below
// still protect any commits reachable from local branches.
if head, err := a.runGit("rev-parse", "HEAD"); err == nil {
if h := strings.TrimSpace(head); h != "" {
tips = append(tips, h)
}
}

// Local branch tips: one OID per line.
Expand Down
30 changes: 19 additions & 11 deletions internal/adapters/git/exec_read_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,28 @@ func (a *ExecAdapter) Status() (domain.Status, error) {
status.Branch, _ = a.CurrentBranch()
status.IsClean = len(status.Files) == 0

// Get ahead/behind info
abOut, err := a.runGit("rev-list", "--left-right", "--count", "HEAD...@{upstream}")
if err == nil {
status.HasUpstream = true
parts := strings.Fields(strings.TrimSpace(abOut))
if len(parts) == 2 {
if ahead, err := strconv.Atoi(parts[0]); err == nil {
status.Ahead = ahead
}
if behind, err := strconv.Atoi(parts[1]); err == nil {
status.Behind = behind
// Ahead/behind only make sense when HEAD resolves to a commit. On an
// unborn repo (zero commits) rev-list would crash; detect unborn via
// rev-parse --verify HEAD. When HEAD is unborn OR no upstream is
// configured, Ahead/Behind stay nil — the JSON null signal. See the
// spec delta "Status ahead/behind on unborn or upstream-less repos".
if _, verifyErr := a.runGit("rev-parse", "--verify", "HEAD"); verifyErr == nil {
abOut, err := a.runGit("rev-list", "--left-right", "--count", "HEAD...@{upstream}")
if err == nil {
status.HasUpstream = true
parts := strings.Fields(strings.TrimSpace(abOut))
if len(parts) == 2 {
if ahead, err := strconv.Atoi(parts[0]); err == nil {
status.Ahead = &ahead
}
if behind, err := strconv.Atoi(parts[1]); err == nil {
status.Behind = &behind
}
}
}
// err != nil here means no upstream configured — leave Ahead/Behind nil.
}
// verifyErr != nil means unborn HEAD — leave Ahead/Behind nil.

return status, nil
}
Expand Down
56 changes: 56 additions & 0 deletions internal/adapters/git/exec_read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,3 +533,59 @@ func TestDiffStatStagedWithPath(t *testing.T) {
t.Error("DiffStatStaged(subdir) should contain the staged file")
}
}

// TestStatus_UnbornRepo_ReturnsNilAheadBehind verifies Status on a freshly
// init'd repo (unborn HEAD, no commits) skips the rev-list ahead/behind call
// and reports Ahead==nil, Behind==nil. See spec delta "Unborn branch".
func TestStatus_UnbornRepo_ReturnsNilAheadBehind(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)

// Stage a file so the working tree has content but no commits exist.
os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("content"), 0644)
adapter := New(dir)
adapter.Add([]string{"staged.txt"})

status, err := adapter.Status()
if err != nil {
t.Fatalf("Status() on unborn repo should not error, got: %v", err)
}
if status.Ahead != nil {
t.Errorf("Status().Ahead = %v, want nil on unborn repo", *status.Ahead)
}
if status.Behind != nil {
t.Errorf("Status().Behind = %v, want nil on unborn repo", *status.Behind)
}
if status.HasUpstream {
t.Error("Status().HasUpstream should be false on unborn repo")
}
}

// TestStatus_RepoWithCommitsNoUpstream_ReturnsNilAheadBehind verifies that a
// repo WITH commits but no configured upstream reports Ahead==nil, Behind==nil
// (not 0). See spec delta "Commits exist but no upstream configured".
func TestStatus_RepoWithCommitsNoUpstream_ReturnsNilAheadBehind(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)

adapter := New(dir)
os.WriteFile(filepath.Join(dir, "file.txt"), []byte("x"), 0644)
adapter.Add([]string{"file.txt"})
if _, err := adapter.Commit("initial"); err != nil {
t.Fatalf("Commit error: %v", err)
}

status, err := adapter.Status()
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if status.Ahead != nil {
t.Errorf("Status().Ahead = %v, want nil when no upstream configured", *status.Ahead)
}
if status.Behind != nil {
t.Errorf("Status().Behind = %v, want nil when no upstream configured", *status.Behind)
}
if status.HasUpstream {
t.Error("HasUpstream should be false when no upstream configured")
}
}
12 changes: 11 additions & 1 deletion internal/adapters/git/exec_write_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,18 @@ func (a *ExecAdapter) WriteTree() (string, error) {
return hash, nil
}

// CommitTree creates a commit object pointing at treeHash. When parentHash
// is empty the resulting commit has no parent (a root commit) — the -p flag
// is omitted entirely. This is required for the first commit on a freshly
// init'd repo (unborn HEAD), where applyPlumbing passes "" as parentHash
// because Head() fails. See the unborn-first-commit spec delta.
func (a *ExecAdapter) CommitTree(treeHash, parentHash, message string) (string, error) {
out, err := a.runGit("commit-tree", treeHash, "-p", parentHash, "-m", message)
args := []string{"commit-tree", treeHash}
if parentHash != "" {
args = append(args, "-p", parentHash)
}
args = append(args, "-m", message)
out, err := a.runGit(args...)
if err != nil {
return "", err
}
Expand Down
61 changes: 61 additions & 0 deletions internal/adapters/git/exec_write_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,64 @@ func TestHead_UnbornRepo_ReturnsError(t *testing.T) {
t.Fatal("Head() should return error on unborn repo (no commits)")
}
}

// TestCommitTree_EmptyParent_CreatesRootCommit verifies CommitTree omits the
// -p flag when parentHash is empty, producing a valid root commit on a repo
// with zero commits. See spec delta "Commit plumbing supports root commits".
func TestCommitTree_EmptyParent_CreatesRootCommit(t *testing.T) {
dir := t.TempDir()
initGitRepo(t, dir)

adapter := New(dir)

// Stage a file and write the tree (no commits exist yet — unborn).
os.WriteFile(filepath.Join(dir, "root.txt"), []byte("root content"), 0644)
adapter.Add([]string{"root.txt"})
treeHash, err := adapter.WriteTree()
if err != nil {
t.Fatalf("WriteTree() error = %v", err)
}

// Create a root commit with empty parent — must NOT pass -p.
commitHash, err := adapter.CommitTree(treeHash, "", "root commit")
if err != nil {
t.Fatalf("CommitTree(tree, \"\", msg) error = %v", err)
}
if len(commitHash) != 40 {
t.Errorf("CommitTree() hash length = %d, want 40 (got %q)", len(commitHash), commitHash)
}

// Verify the commit object exists and is a commit.
cmd := exec.Command("git", "cat-file", "-t", commitHash)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cat-file -t %s failed: %v", commitHash, err)
}
if strings.TrimSpace(string(out)) != "commit" {
t.Errorf("cat-file -t %s = %q, want 'commit'", commitHash, strings.TrimSpace(string(out)))
}

// Verify it has NO parent (root commit) — git cat-file -p shows no "parent" line.
cmd = exec.Command("git", "cat-file", "-p", commitHash)
cmd.Dir = dir
out, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("cat-file -p %s failed: %v", commitHash, err)
}
if strings.Contains(string(out), "parent ") {
t.Errorf("root commit must have no parent line, got:\n%s", string(out))
}

// Point HEAD at it and verify HEAD resolves — repo is no longer unborn.
if _, err := adapter.UpdateRef("HEAD", commitHash); err != nil {
t.Fatalf("UpdateRef(HEAD, %s) error = %v", commitHash, err)
}
head, err := adapter.Head()
if err != nil {
t.Fatalf("Head() after UpdateRef error = %v", err)
}
if head != commitHash {
t.Errorf("Head() = %q, want %q", head, commitHash)
}
}
4 changes: 2 additions & 2 deletions internal/core/domain/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ type Status struct {
IsClean bool `json:"is_clean"`
RepoPath string `json:"repo_path"`
Branch string `json:"branch"`
Ahead int `json:"ahead,omitempty"`
Behind int `json:"behind,omitempty"`
Ahead *int `json:"ahead"`
Behind *int `json:"behind"`
HasUpstream bool `json:"has_upstream"`
Files []FileStatus `json:"files"`
Staged int `json:"staged_count"`
Expand Down
39 changes: 39 additions & 0 deletions internal/core/domain/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestReleaseIntent_JSONSerialization(t *testing.T) {
Expand Down Expand Up @@ -39,3 +41,40 @@ func TestReleaseIntent_JSONSerialization(t *testing.T) {
}
})
}

// TestStatus_AheadBehind_NullableJSON verifies the *int contract for
// Status.Ahead/Behind: nil MUST serialize as JSON null (not omitted, not 0),
// and a concrete value MUST serialize as a number. This is the unborn-repo
// signal contract — see spec delta "Status ahead/behind on unborn or
// upstream-less repos".
func TestStatus_AheadBehind_NullableJSON(t *testing.T) {
t.Run("nil Ahead/Behind serialize as null", func(t *testing.T) {
s := Status{Branch: "main"}
data, err := json.Marshal(s)
assert.NoError(t, err)
jsonStr := string(data)
// null is the explicit signal — NOT omitted, NOT 0.
assert.Contains(t, jsonStr, `"ahead":null`, "nil Ahead must be null, not omitted")
assert.Contains(t, jsonStr, `"behind":null`, "nil Behind must be null, not omitted")
})

t.Run("set Ahead/Behind serialize as numbers", func(t *testing.T) {
ahead, behind := 3, 1
s := Status{Branch: "main", Ahead: &ahead, Behind: &behind}
data, err := json.Marshal(s)
assert.NoError(t, err)
jsonStr := string(data)
assert.Contains(t, jsonStr, `"ahead":3`)
assert.Contains(t, jsonStr, `"behind":1`)
})

t.Run("deserializes null back to nil", func(t *testing.T) {
jsonData := `{"is_clean":false,"repo_path":"","branch":"main","ahead":null,"behind":null,"has_upstream":false,"files":null,"staged_count":0,"modified_count":0,"untracked_count":0,"conflicted_count":0}`
var s Status
assert.NoError(t, json.Unmarshal([]byte(jsonData), &s))
assert.Nil(t, s.Ahead, "null must deserialize to nil")
assert.Nil(t, s.Behind, "null must deserialize to nil")
})
}

// requires import of assert (added above)
7 changes: 5 additions & 2 deletions internal/delivery/mcp/core/commit_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,10 +673,13 @@ func (h *Handler) applyPlumbing(ctx context.Context, jobID string, pushAfter boo
message = overrideCommitType(message, typeOverride)
}

// Get parent commit hash
// Get parent commit hash. On an unborn repo (zero commits) Head() fails
// because HEAD doesn't resolve. Tolerate this by passing "" as parentHash
// — CommitTree omits the -p flag and creates a valid root commit. See the
// unborn-first-commit spec delta "First commit on a fresh repo".
parentHash, err := h.git.Head()
if err != nil {
return nil, fmt.Errorf("APPLY: failed to get HEAD: %w", err)
parentHash = ""
}

// Create commit from tree snapshot
Expand Down
Loading
Loading