diff --git a/internal/api/builds.go b/internal/api/builds.go index f7000a6..a9219e1 100644 --- a/internal/api/builds.go +++ b/internal/api/builds.go @@ -120,7 +120,7 @@ type RunBuildOptions struct { AgentID int Tags []string PersonalChangeID string - Revision string // Base revision (commit SHA) for personal builds + Revision string // Base revision (commit SHA or changelist number) for personal builds } // RunBuild runs a new build with full options @@ -184,7 +184,7 @@ func (c *Client) RunBuild(buildTypeID string, opts RunBuildOptions) (*Build, err if opts.Revision != "" { vcsBranch := opts.Branch - if vcsBranch != "" && !strings.HasPrefix(vcsBranch, "refs/") { + if vcsBranch != "" && !strings.HasPrefix(vcsBranch, "refs/") && !strings.HasPrefix(vcsBranch, "//") { vcsBranch = "refs/heads/" + vcsBranch } req.Revisions = &Revisions{ diff --git a/internal/api/perforce_test.go b/internal/api/perforce_test.go new file mode 100644 index 0000000..eddde42 --- /dev/null +++ b/internal/api/perforce_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package api_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPerforceUploadDiffChanges(T *testing.T) { + T.Parallel() + + patch := []byte(`--- a/depot/main/test.txt ++++ b/depot/main/test.txt +@@ -1 +1 @@ +-Hello from Perforce ++Hello from Perforce - modified in personal build +`) + + changeID, err := client.UploadDiffChanges(patch, "Perforce personal build test") + require.NoError(T, err) + assert.NotEmpty(T, changeID) + T.Logf("Uploaded Perforce diff as change ID: %s", changeID) +} diff --git a/internal/cmd/git_helpers_test.go b/internal/cmd/git_helpers_test.go index 4be90eb..97bcfa7 100644 --- a/internal/cmd/git_helpers_test.go +++ b/internal/cmd/git_helpers_test.go @@ -360,7 +360,7 @@ func TestLoadLocalChanges(t *testing.T) { _, err := loadLocalChanges("git") assert.Error(t, err) - assert.Contains(t, err.Error(), "no uncommitted changes") + assert.Contains(t, err.Error(), "no local changes found") }) t.Run("git source not in repo", func(t *testing.T) { @@ -370,7 +370,7 @@ func TestLoadLocalChanges(t *testing.T) { _, err := loadLocalChanges("git") assert.Error(t, err) - assert.Contains(t, err.Error(), "not a git repository") + assert.Contains(t, err.Error(), "no supported VCS detected") }) t.Run("file source", func(t *testing.T) { diff --git a/internal/cmd/run_analysis.go b/internal/cmd/run_analysis.go index 5a2c1d8..d037312 100644 --- a/internal/cmd/run_analysis.go +++ b/internal/cmd/run_analysis.go @@ -60,17 +60,19 @@ func runRunChanges(runID string, opts *runChangesOptions) error { fmt.Printf("CHANGES (%d %s)\n\n", changes.Count, english.PluralWord(changes.Count, "commit", "commits")) - var firstSHA, lastSHA string + vcs := DetectVCS() + if vcs == nil { + vcs = &GitProvider{} + } + + var firstRev, lastRev string for i, c := range changes.Change { if i == 0 { - lastSHA = c.Version + lastRev = c.Version } - firstSHA = c.Version + firstRev = c.Version - sha := c.Version - if len(sha) > 7 { - sha = sha[:7] - } + rev := vcs.FormatRevision(c.Version) date := "" if c.Date != "" { @@ -79,7 +81,7 @@ func runRunChanges(runID string, opts *runChangesOptions) error { } } - fmt.Printf("%s %s %s\n", output.Yellow(sha), output.Faint(c.Username), output.Faint(date)) + fmt.Printf("%s %s %s\n", output.Yellow(rev), output.Faint(c.Username), output.Faint(date)) comment := strings.TrimSpace(c.Comment) if idx := strings.Index(comment, "\n"); idx > 0 { @@ -104,16 +106,8 @@ func runRunChanges(runID string, opts *runChangesOptions) error { fmt.Println() } - if firstSHA != "" && lastSHA != "" && firstSHA != lastSHA { - first := firstSHA - last := lastSHA - if len(first) > 7 { - first = first[:7] - } - if len(last) > 7 { - last = last[:7] - } - fmt.Printf("%s git diff %s^..%s\n", output.Faint("# For full diff:"), first, last) + if firstRev != "" && lastRev != "" && firstRev != lastRev { + fmt.Printf("%s %s\n", output.Faint("# For full diff:"), vcs.DiffHint(firstRev, lastRev)) } return nil diff --git a/internal/cmd/run_lifecycle.go b/internal/cmd/run_lifecycle.go index 2e1278a..a956df8 100644 --- a/internal/cmd/run_lifecycle.go +++ b/internal/cmd/run_lifecycle.go @@ -70,7 +70,7 @@ func newRunStartCmd() *cobra.Command { cmd.Flags().StringVarP(&opts.comment, "comment", "m", "", "Run comment") cmd.Flags().StringSliceVarP(&opts.tags, "tag", "t", nil, "Run tags (can be repeated)") cmd.Flags().BoolVar(&opts.personal, "personal", false, "Run as personal build") - localChangesFlag := cmd.Flags().VarPF(&localChangesValue{val: &opts.localChanges}, "local-changes", "l", "Include local changes (git, -, or path; default: git)") + localChangesFlag := cmd.Flags().VarPF(&localChangesValue{val: &opts.localChanges}, "local-changes", "l", "Include local changes (git, p4, auto, -, or path; default: git)") localChangesFlag.NoOptDefVal = "git" cmd.Flags().BoolVar(&opts.noPush, "no-push", false, "Skip auto-push of branch to remote") cmd.Flags().BoolVar(&opts.cleanSources, "clean", false, "Clean sources before run") @@ -138,37 +138,49 @@ func runRunStart(jobID string, opts *runStartOptions) error { } var headCommit string - if opts.localChanges != "" && opts.branch == "" { - if !isGitRepo() { - return tcerrors.WithSuggestion( - "not a git repository", - "Run this command from within a git repository, or specify --branch explicitly", - ) - } - branch, err := getCurrentBranch() - if err != nil { - return err + var personalChangeID string + var localPatch []byte + + if opts.localChanges != "" { + vcs := DetectVCS() + + if opts.branch == "" { + if vcs == nil { + return tcerrors.WithSuggestion( + "no supported VCS detected", + "Run this command from within a git repository or Perforce workspace, or specify --branch explicitly", + ) + } + branch, err := vcs.GetCurrentBranch() + if err != nil { + return err + } + opts.branch = branch + output.Info("Using current %s branch: %s", vcs.Name(), branch) } - opts.branch = branch - output.Info("Using current branch: %s", branch) - } - if opts.localChanges != "" && !opts.noPush { - if !branchExistsOnRemote(opts.branch) { + if !opts.noPush && vcs != nil && !vcs.BranchExistsOnRemote(opts.branch) { output.Info("Pushing branch to remote...") - if err := pushBranch(opts.branch); err != nil { + if err := vcs.PushBranch(opts.branch); err != nil { return err } output.Success("Branch pushed to remote") } - } - if opts.localChanges != "" { - commit, err := getHeadCommit() + if vcs != nil { + commit, err := vcs.GetHeadRevision() + if err != nil { + return err + } + headCommit = commit + } + + patch, err := loadLocalChanges(opts.localChanges) if err != nil { return err } - headCommit = commit + localPatch = patch + opts.personal = true } client, err := getClient() @@ -176,30 +188,21 @@ func runRunStart(jobID string, opts *runStartOptions) error { return err } - var personalChangeID string - if opts.localChanges != "" { - patch, err := loadLocalChanges(opts.localChanges) - if err != nil { - return err - } - + if localPatch != nil { output.Info("Uploading local changes...") description := opts.comment if description == "" { description = "Personal build with local changes" } - - changeID, err := client.UploadDiffChanges(patch, description) + changeID, err := client.UploadDiffChanges(localPatch, description) if err != nil { return fmt.Errorf("failed to upload changes: %w", err) } personalChangeID = changeID output.Success("Uploaded changes (ID: %s)", changeID) - - opts.personal = true } - build, err := client.RunBuild(jobID, api.RunBuildOptions{ + buildOpts := api.RunBuildOptions{ Branch: opts.branch, Params: opts.params, SystemProps: opts.systemProps, @@ -214,7 +217,9 @@ func runRunStart(jobID string, opts *runStartOptions) error { Tags: opts.tags, PersonalChangeID: personalChangeID, Revision: headCommit, - }) + } + + build, err := client.RunBuild(jobID, buildOpts) if err != nil { return err } @@ -518,24 +523,6 @@ func (v *localChangesValue) Type() string { func loadLocalChanges(source string) ([]byte, error) { switch source { - case "git": - if !isGitRepo() { - return nil, tcerrors.WithSuggestion( - "not a git repository", - "Run this command from within a git repository, or use --local-changes to specify a diff file", - ) - } - patch, err := getGitDiff() - if err != nil { - return nil, err - } - if len(patch) == 0 { - return nil, tcerrors.WithSuggestion( - "no uncommitted changes found", - "Make some changes to your files before running a personal build, or use --local-changes to specify a diff file", - ) - } - return patch, nil case "-": patch, err := io.ReadAll(os.Stdin) if err != nil { @@ -548,6 +535,8 @@ func loadLocalChanges(source string) ([]byte, error) { ) } return patch, nil + case "git", "p4", "perforce", "auto": + return loadVCSDiff(source) default: patch, err := os.ReadFile(source) if err != nil { @@ -569,6 +558,32 @@ func loadLocalChanges(source string) ([]byte, error) { } } +func loadVCSDiff(source string) ([]byte, error) { + var vcs VCSProvider + if source == "auto" { + vcs = DetectVCS() + } else if p := DetectVCSByName(source); p != nil && p.IsAvailable() { + vcs = p + } + if vcs == nil { + return nil, tcerrors.WithSuggestion( + "no supported VCS detected", + "Run this command from within a git repository or Perforce workspace, or use --local-changes ", + ) + } + patch, err := vcs.GetLocalDiff() + if err != nil { + return nil, err + } + if len(patch) == 0 { + return nil, tcerrors.WithSuggestion( + "no local changes found", + "Make some changes before running a personal build, or use --local-changes ", + ) + } + return patch, nil +} + func getGitDiff() ([]byte, error) { untrackedFiles, err := getUntrackedFiles() if err != nil { diff --git a/internal/cmd/vcs_git.go b/internal/cmd/vcs_git.go new file mode 100644 index 0000000..9b41bd2 --- /dev/null +++ b/internal/cmd/vcs_git.go @@ -0,0 +1,31 @@ +package cmd + +import "fmt" + +type GitProvider struct{} + +func (g *GitProvider) Name() string { return "git" } +func (g *GitProvider) IsAvailable() bool { return isGitRepo() } +func (g *GitProvider) GetCurrentBranch() (string, error) { return getCurrentBranch() } +func (g *GitProvider) GetHeadRevision() (string, error) { return getHeadCommit() } +func (g *GitProvider) GetLocalDiff() ([]byte, error) { return getGitDiff() } +func (g *GitProvider) BranchExistsOnRemote(b string) bool { return branchExistsOnRemote(b) } +func (g *GitProvider) PushBranch(b string) error { return pushBranch(b) } + +func (g *GitProvider) FormatRevision(rev string) string { + if len(rev) > 7 { + return rev[:7] + } + return rev +} + +func (g *GitProvider) DiffHint(firstRev, lastRev string) string { + first, last := firstRev, lastRev + if len(first) > 7 { + first = first[:7] + } + if len(last) > 7 { + last = last[:7] + } + return fmt.Sprintf("git diff %s^..%s", first, last) +} diff --git a/internal/cmd/vcs_perforce.go b/internal/cmd/vcs_perforce.go new file mode 100644 index 0000000..1889064 --- /dev/null +++ b/internal/cmd/vcs_perforce.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + tcerrors "github.com/JetBrains/teamcity-cli/internal/errors" +) + +const p4Timeout = 10 * time.Second + +func p4Output(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), p4Timeout) + defer cancel() + return exec.CommandContext(ctx, "p4", args...).Output() +} + +func p4ZtagField(output []byte, field string) string { + prefix := "... " + field + " " + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimPrefix(line, prefix) + } + } + return "" +} + +type PerforceProvider struct{} + +func (p *PerforceProvider) Name() string { return "perforce" } + +func (p *PerforceProvider) IsAvailable() bool { + out, err := p4Output("info") + if err != nil { + return false + } + s := string(out) + return !strings.Contains(s, "Client unknown") && strings.Contains(s, "Client root:") +} + +func (p *PerforceProvider) GetCurrentBranch() (string, error) { + clientName, err := getPerforceClientName() + if err != nil { + return "", err + } + + out, err := p4Output("-ztag", "client", "-o", clientName) + if err != nil { + return "", tcerrors.WithSuggestion( + "failed to get Perforce client spec", + "Ensure p4 is configured and you are in a valid workspace", + ) + } + + if stream := p4ZtagField(out, "Stream"); stream != "" { + return stream, nil + } + if view := p4ZtagField(out, "View0"); view != "" { + if parts := strings.Fields(view); len(parts) >= 1 { + return strings.TrimSuffix(parts[0], "/..."), nil + } + } + + return "", tcerrors.WithSuggestion( + "could not determine Perforce stream or depot path", + "Ensure your workspace is mapped to a stream or depot path", + ) +} + +func (p *PerforceProvider) GetHeadRevision() (string, error) { + clientName, err := getPerforceClientName() + if err != nil { + return "", err + } + + out, err := p4Output("changes", "-m1", "-s", "submitted", "@"+clientName) + if err != nil { + return "", tcerrors.WithSuggestion( + "failed to get Perforce changelist", + "Ensure you have submitted changes in your workspace", + ) + } + + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) >= 2 && fields[0] == "Change" { + return fields[1], nil + } + + return "", tcerrors.WithSuggestion( + "no submitted changelists found", + "Submit at least one changelist, or specify --branch explicitly", + ) +} + +func (p *PerforceProvider) GetLocalDiff() ([]byte, error) { + openOut, err := p4Output("opened") + if err != nil { + return nil, tcerrors.WithSuggestion( + "failed to list opened files", + "Ensure you are in a Perforce workspace with pending changes", + ) + } + if strings.TrimSpace(string(openOut)) == "" { + return nil, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), p4Timeout) + defer cancel() + out, err := exec.CommandContext(ctx, "p4", "diff", "-du").Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return out, nil // p4 diff returns exit 1 when differences exist + } + return nil, tcerrors.WithSuggestion( + "failed to generate Perforce diff", + "Ensure you have pending changes in your workspace", + ) + } + return out, nil +} + +func (p *PerforceProvider) BranchExistsOnRemote(_ string) bool { return true } +func (p *PerforceProvider) PushBranch(_ string) error { return nil } +func (p *PerforceProvider) FormatRevision(rev string) string { return rev } + +func (p *PerforceProvider) DiffHint(firstRev, lastRev string) string { + return fmt.Sprintf("p4 changes -l @%s,@%s", firstRev, lastRev) +} + +func getPerforceClientName() (string, error) { + out, err := p4Output("-ztag", "info") + if err != nil { + return "", tcerrors.WithSuggestion( + "failed to get Perforce info", + "Ensure p4 is installed and P4PORT/P4USER/P4CLIENT are configured", + ) + } + if name := p4ZtagField(out, "clientName"); name != "" { + return name, nil + } + return "", tcerrors.WithSuggestion( + "no Perforce client found", + "Set P4CLIENT or run 'p4 set P4CLIENT='", + ) +} diff --git a/internal/cmd/vcs_provider.go b/internal/cmd/vcs_provider.go new file mode 100644 index 0000000..56d5c91 --- /dev/null +++ b/internal/cmd/vcs_provider.go @@ -0,0 +1,34 @@ +package cmd + +// VCSProvider abstracts version control operations used by the CLI. +type VCSProvider interface { + Name() string + IsAvailable() bool + GetCurrentBranch() (string, error) + GetHeadRevision() (string, error) + GetLocalDiff() ([]byte, error) + BranchExistsOnRemote(branch string) bool + PushBranch(branch string) error + FormatRevision(rev string) string + DiffHint(firstRev, lastRev string) string +} + +func DetectVCS() VCSProvider { + for _, p := range []VCSProvider{&GitProvider{}, &PerforceProvider{}} { + if p.IsAvailable() { + return p + } + } + return nil +} + +func DetectVCSByName(name string) VCSProvider { + switch name { + case "git": + return &GitProvider{} + case "p4", "perforce": + return &PerforceProvider{} + default: + return nil + } +} diff --git a/internal/cmd/vcs_provider_test.go b/internal/cmd/vcs_provider_test.go new file mode 100644 index 0000000..df5b705 --- /dev/null +++ b/internal/cmd/vcs_provider_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGitProvider_Name(t *testing.T) { + assert.Equal(t, "git", (&GitProvider{}).Name()) +} + +func TestGitProvider_FormatRevision(t *testing.T) { + p := &GitProvider{} + assert.Equal(t, "abc1234", p.FormatRevision("abc12345678901234567890123456789012345678")) + assert.Equal(t, "abc", p.FormatRevision("abc")) + assert.Equal(t, "abc1234", p.FormatRevision("abc1234")) +} + +func TestGitProvider_DiffHint(t *testing.T) { + assert.Equal(t, "git diff abc1234^..def1234", (&GitProvider{}).DiffHint("abc1234567890", "def1234567890")) +} + +func TestGitProvider_IsAvailable(t *testing.T) { + t.Run("in git repo", func(t *testing.T) { + dir := setupGitRepo(t) + restore := chdir(t, dir) + defer restore() + assert.True(t, (&GitProvider{}).IsAvailable()) + }) + + t.Run("not in git repo", func(t *testing.T) { + dir := t.TempDir() + restore := chdir(t, dir) + defer restore() + assert.False(t, (&GitProvider{}).IsAvailable()) + }) +} + +func TestGitProvider_GetCurrentBranch(t *testing.T) { + dir := setupGitRepo(t) + restore := chdir(t, dir) + defer restore() + + createFile(t, dir, "test.txt", "content") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "initial") + + branch, err := (&GitProvider{}).GetCurrentBranch() + assert.NoError(t, err) + assert.Contains(t, []string{"main", "master"}, branch) +} + +func TestGitProvider_GetHeadRevision(t *testing.T) { + dir := setupGitRepo(t) + restore := chdir(t, dir) + defer restore() + + createFile(t, dir, "test.txt", "content") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "initial") + + rev, err := (&GitProvider{}).GetHeadRevision() + assert.NoError(t, err) + assert.Regexp(t, "^[0-9a-f]{40}$", rev) +} + +func TestGitProvider_GetLocalDiff(t *testing.T) { + dir := setupGitRepo(t) + restore := chdir(t, dir) + defer restore() + + createFile(t, dir, "test.txt", "content") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "initial") + createFile(t, dir, "test.txt", "modified") + + diff, err := (&GitProvider{}).GetLocalDiff() + assert.NoError(t, err) + assert.Contains(t, string(diff), "modified") +} + +func TestPerforceProvider_Name(t *testing.T) { + assert.Equal(t, "perforce", (&PerforceProvider{}).Name()) +} + +func TestPerforceProvider_FormatRevision(t *testing.T) { + p := &PerforceProvider{} + assert.Equal(t, "12345", p.FormatRevision("12345")) + assert.Equal(t, "1", p.FormatRevision("1")) +} + +func TestPerforceProvider_DiffHint(t *testing.T) { + assert.Equal(t, "p4 changes -l @100,@200", (&PerforceProvider{}).DiffHint("100", "200")) +} + +func TestPerforceProvider_PushBranch(t *testing.T) { + assert.NoError(t, (&PerforceProvider{}).PushBranch("//depot/main")) +} + +func TestPerforceProvider_IsAvailable(t *testing.T) { + dir := t.TempDir() + restore := chdir(t, dir) + defer restore() + // Just exercise the code path; result depends on whether p4 is installed + _ = (&PerforceProvider{}).IsAvailable() +} + +func TestDetectVCS_Git(t *testing.T) { + dir := setupGitRepo(t) + restore := chdir(t, dir) + defer restore() + + vcs := DetectVCS() + assert.NotNil(t, vcs) + assert.Equal(t, "git", vcs.Name()) +} + +func TestDetectVCS_NoVCS(t *testing.T) { + dir := t.TempDir() + restore := chdir(t, dir) + defer restore() + assert.Nil(t, DetectVCS()) +} + +func TestDetectVCSByName(t *testing.T) { + for _, tc := range []struct { + name string + expected string + }{ + {"git", "git"}, + {"p4", "perforce"}, + {"perforce", "perforce"}, + } { + t.Run(tc.name, func(t *testing.T) { + vcs := DetectVCSByName(tc.name) + assert.NotNil(t, vcs) + assert.Equal(t, tc.expected, vcs.Name()) + }) + } + + t.Run("unknown", func(t *testing.T) { + assert.Nil(t, DetectVCSByName("svn")) + }) +}