package git import ( "context" "os" "os/exec" "path/filepath" "reflect" "strings" "testing" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) // nulp joins porcelain v2 tokens the way git does with -z: NUL separated. func nulp(parts ...string) string { return strings.Join(parts, "\x00") } // parseFixture parses canned porcelain v2 output and classifies it. func parseFixture(t *testing.T, out string) status.RepoInfo { t.Helper() var info status.RepoInfo parsePorcelainV2(out, &info) info.Status = classify(&info) return info } func TestParsePorcelainV2Clean(t *testing.T) { info := parseFixture(t, nulp( "# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863", "# branch.head main", "# branch.upstream origin/main", "# branch.ab +0 -0", )) if info.Branch != "main" || info.Detached { t.Errorf("branch = %q detached=%v, want main/false", info.Branch, info.Detached) } if info.Status != status.StatusClean { t.Errorf("Status = %v, want clean", info.Status) } if info.FileCount() != 0 { t.Errorf("FileCount = %d, want 0", info.FileCount()) } } func TestParsePorcelainV2Modified(t *testing.T) { info := parseFixture(t, nulp( "# branch.head main", "1 .M N... 100644 100644 100644 aaa bbb a.txt", "? new dir/", )) if info.Status != status.StatusModified { t.Errorf("Status = %v, want modified", info.Status) } if want := []string{"a.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) { t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want) } if want := []string{"new dir/"}; !reflect.DeepEqual(info.UntrackedFiles, want) { t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want) } } func TestParsePorcelainV2StagedAndRenamed(t *testing.T) { info := parseFixture(t, nulp( "# branch.head main", "1 M. N... 100644 100644 100644 aaa bbb staged.txt", "1 MM N... 100644 100644 100644 aaa bbb both.txt", "2 R. N... 100644 100644 100644 aaa bbb R100 new file.txt", )) if want := []string{"staged.txt", "both.txt", "new file.txt"}; !reflect.DeepEqual(info.StagedFiles, want) { t.Errorf("StagedFiles = %v, want %v", info.StagedFiles, want) } if want := []string{"both.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) { t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want) } } func TestParsePorcelainV2Diverged(t *testing.T) { info := parseFixture(t, nulp( "# branch.head main", "# branch.ab +3 -5", )) if info.AheadBy != 3 || info.BehindBy != 5 { t.Errorf("ahead/behind = %d/%d, want 3/5", info.AheadBy, info.BehindBy) } if info.Status != status.StatusDiverged { t.Errorf("Status = %v, want diverged", info.Status) } } func TestParsePorcelainV2Behind(t *testing.T) { info := parseFixture(t, nulp( "# branch.head main", "# branch.ab +0 -2", )) if info.Status != status.StatusBehind { t.Errorf("Status = %v, want behind", info.Status) } } func TestParsePorcelainV2Detached(t *testing.T) { info := parseFixture(t, nulp( "# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863", "# branch.head (detached)", )) if !info.Detached || info.Branch != "(detached)" { t.Errorf("Detached = %v, Branch = %q; want true/(detached)", info.Detached, info.Branch) } if info.Status != status.StatusDetached { t.Errorf("Status = %v, want detached", info.Status) } } func TestParsePorcelainV2RawPaths(t *testing.T) { // With -z, paths are raw: spaces, quotes, and tabs are never escaped. info := parseFixture(t, nulp( "? tab\tname.txt", `? quote"name.txt`, )) if want := []string{"tab\tname.txt", `quote"name.txt`}; !reflect.DeepEqual(info.UntrackedFiles, want) { t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want) } } func TestParsePorcelainV2ToleratesUnknownLines(t *testing.T) { info := parseFixture(t, nulp( "# future.header something", "# branch.head main", "9 custom record", )) if info.Status != status.StatusClean || info.Branch != "main" { t.Errorf("unexpected parse: %+v", info) } } // --- Integration tests against a real git binary --------------------------- func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } } func writeFile(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestExecutorStatusIntegration(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } dir := t.TempDir() repo := filepath.Join(dir, "repo") runGit(t, dir, "init", "-q", "-b", "main", filepath.Base(repo)) runGit(t, repo, "config", "user.email", "t@t") runGit(t, repo, "config", "user.name", "t") writeFile(t, repo, "a.txt", "hello") runGit(t, repo, "add", "a.txt") runGit(t, repo, "commit", "-qm", "init") e := NewExecutor() info, err := e.Status(context.Background(), repo) if err != nil { t.Fatalf("Status: %v", err) } if info.Status != status.StatusClean { t.Errorf("Status = %v, want clean", info.Status) } if info.Branch != "main" { t.Errorf("Branch = %q, want main", info.Branch) } if info.Name != "repo" || info.Path != repo { t.Errorf("Name/Path = %q/%q, want repo/%s", info.Name, info.Path, repo) } // Untracked + modified writeFile(t, repo, "b.txt", "x") writeFile(t, repo, "a.txt", "changed") info, err = e.Status(context.Background(), repo) if err != nil { t.Fatalf("Status: %v", err) } if info.Status != status.StatusModified { t.Errorf("Status = %v, want modified", info.Status) } if len(info.UntrackedFiles) != 1 || len(info.ModifiedFiles) != 1 { t.Errorf("untracked/modified = %d/%d, want 1/1", len(info.UntrackedFiles), len(info.ModifiedFiles)) } // Staged runGit(t, repo, "add", "b.txt") info, err = e.Status(context.Background(), repo) if err != nil { t.Fatalf("Status: %v", err) } if len(info.StagedFiles) != 1 { t.Errorf("staged = %d, want 1", len(info.StagedFiles)) } // Stash runGit(t, repo, "stash", "push", "-q", "-m", "wip") info, err = e.Status(context.Background(), repo) if err != nil { t.Fatalf("Status: %v", err) } if info.StashCount != 1 { t.Errorf("StashCount = %d, want 1", info.StashCount) } // Detached HEAD runGit(t, repo, "checkout", "-q", "--detach") info, err = e.Status(context.Background(), repo) if err != nil { t.Fatalf("Status: %v", err) } if !info.Detached { t.Errorf("Detached = false, want true") } // Bare repository bare := filepath.Join(dir, "bare.git") runGit(t, dir, "init", "-q", "--bare", filepath.Base(bare)) info, err = e.Status(context.Background(), bare) if err != nil { t.Fatalf("Status(bare): %v", err) } if info.Status != status.StatusBare { t.Errorf("bare Status = %v, want bare", info.Status) } // Not a repository if _, err := e.Status(context.Background(), dir); err == nil { t.Fatal("Status(non-repo) succeeded, want error") } }