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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.DS_Store
dist/
bin/
stack

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope stack ignore rule to repository root

The new .gitignore entry stack matches any path segment named stack, not just the root build artifact, so it also ignores new files under source directories like cmd/stack/ and internal/stack/. This makes legitimate code additions in those packages disappear from normal git status / git add . flows unless forced, which is a maintainability regression introduced by this commit.

Useful? React with 👍 / 👎.

coverage.out
*.test
*.out
Expand Down
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Repository Agent Instructions

This file adds repo-specific guidance for work in this repository. Follow it in
addition to the global baseline instructions.

## Toolchain

- Use `mise` for Go commands in this repo.
- Prefer `mise exec -- gofmt -w <files>` for formatting Go files.
- Prefer `mise exec -- go test ./...` for the main test suite.
- Prefer `mise exec -- go build ./...` for a repo-wide build check.
- Do not assume `go` or `gofmt` are on `PATH`; use `mise exec -- ...`.

## Verification

- Treat `mise exec -- go test ./...` and `mise exec -- go build ./...` as the
default verification loop for code changes.
- If you change queue, restack, submit, sync, or recovery behavior, read
[docs/testing.md](docs/testing.md) and use the strongest relevant loop from
that doc instead of stopping at unit tests.
- State clearly what was verified and what remains unverified if a stronger loop
is unavailable.

## Commits And Releases

- Use Conventional Commits for commit subjects.
- Keep pull request titles conventional as well; the release flow depends on
it.
- Before touching release behavior or release automation, read
[docs/releasing.md](docs/releasing.md).

## Working Tree Hygiene

- Do not commit local build outputs such as the root `stack` binary.
- If a generated or built artifact appears repeatedly during normal work, add it
to `.gitignore` in the same change that introduces the behavior.

2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ stack init --trunk main --remote origin
if trunk == "" {
trunk = repoView.DefaultBranchRef.Name
}
} else {
repo, _ = runtime.Git.RemoteRepoSlug(runtime.Context, chooseString(remote, "origin"))
}

if trunk == "" {
Expand Down
26 changes: 26 additions & 0 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,32 @@ func TestVersionCommandPrintsBuildInfo(t *testing.T) {
}
}

func TestInitFallsBackToRemoteRepoWhenGHRepoViewFails(t *testing.T) {
repo := testutil.SetupGitRepo(t)
testutil.Run(t, repo, "git", "remote", "add", "origin", "git@github.com-ln:acme/new-repo.git")
runtime := newTestRuntime(repo)

ghStubDir := t.TempDir()
ghStubPath := filepath.Join(ghStubDir, "gh")
if err := os.WriteFile(ghStubPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatalf("write gh stub: %v", err)
}
t.Setenv("PATH", ghStubDir+string(os.PathListSeparator)+os.Getenv("PATH"))

output := executeCommand(t, runtime, "init", "--remote", "origin", "--trunk", "main")
if !strings.Contains(output, "repo: acme/new-repo") {
t.Fatalf("expected init output to include remote repo slug, got %q", output)
}

state, err := runtime.Store.ReadState(runtime.Context)
if err != nil {
t.Fatalf("read state: %v", err)
}
if state.Repo != "acme/new-repo" {
t.Fatalf("expected state repo acme/new-repo, got %q", state.Repo)
}
}

func TestQueueMergesHealthyBottomBranch(t *testing.T) {
repo := testutil.SetupGitRepo(t)
remote := filepath.Join(t.TempDir(), "remote.git")
Expand Down
64 changes: 64 additions & 0 deletions internal/git/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -191,6 +192,15 @@ func (c *Client) RemoteURL(ctx context.Context, remote string) (string, error) {
return c.output(ctx, "remote", "get-url", remote)
}

func (c *Client) RemoteRepoSlug(ctx context.Context, remote string) (string, error) {
remoteURL, err := c.RemoteURL(ctx, remote)
if err != nil {
return "", err
}

return parseRemoteRepoSlug(remoteURL), nil
}

func (c *Client) RangeDiff(ctx context.Context, oldBase string, newBranch string) (string, error) {
return c.output(ctx, "range-diff", fmt.Sprintf("%s...%s", oldBase, newBranch))
}
Expand Down Expand Up @@ -232,3 +242,57 @@ func (c *Client) runWithEnv(ctx context.Context, env []string, args ...string) (

return stdout.String(), nil
}

func parseRemoteRepoSlug(remoteURL string) string {
trimmed := strings.TrimSpace(remoteURL)
if trimmed == "" {
return ""
}

if slug, ok := parseSCPRepoSlug(trimmed); ok {
return slug
}

if parsed, err := url.Parse(trimmed); err == nil && parsed.Scheme != "" {
return repoSlugFromPath(parsed.Path)
Comment thread
roodboi marked this conversation as resolved.
}

return ""
}

func parseSCPRepoSlug(remote string) (string, bool) {
if strings.Contains(remote, "://") {
return "", false
}

scpPrefix, scpPath, found := strings.Cut(remote, ":")
if !found || scpPrefix == "" || scpPath == "" {
return "", false
}

slug := repoSlugFromPath(scpPath)
if slug == "" {
return "", false
}

return slug, true
}

func repoSlugFromPath(path string) string {
trimmed := strings.Trim(strings.TrimSpace(path), "/")
trimmed = strings.TrimSuffix(trimmed, ".git")
if trimmed == "" {
return ""
}

parts := strings.Split(trimmed, "/")
if len(parts) != 2 {
return ""
}

if parts[0] == "" || parts[1] == "" {
return ""
}

return strings.Join(parts, "/")
}
16 changes: 16 additions & 0 deletions internal/git/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ func TestRebaseContinueDoesNotRequireInteractiveEditor(t *testing.T) {
}
}

func TestRemoteRepoSlugParsesUserlessSCPAlias(t *testing.T) {
t.Parallel()

repo := setupGitRepo(t)
run(t, repo, "git", "remote", "add", "origin", "github-work:acme/new-repo.git")

client := stackgit.NewClient(repo)
slug, err := client.RemoteRepoSlug(context.Background(), "origin")
if err != nil {
t.Fatalf("remote repo slug: %v", err)
}
if slug != "acme/new-repo" {
t.Fatalf("expected acme/new-repo, got %q", slug)
}
}

func setupGitRepo(t *testing.T) string {
t.Helper()

Expand Down
Loading