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
27 changes: 25 additions & 2 deletions node/src/commands/backend/run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from "commander";
import ora from "ora";
import { detectRepo } from "../../utils/git.js";
import { detectRepo, git, remoteBranchSha } from "../../utils/git.js";
import {
API,
resolveKey,
Expand Down Expand Up @@ -96,8 +96,9 @@ export async function runRemoteScan(opts: RunOpts): Promise<void> {
const ghToken = opts.githubToken || process.env.RAFTER_GITHUB_TOKEN;
let repo: string | undefined, branch: string | undefined;
let detectedProvider: string | undefined, detectedRepoUrl: string | undefined;
let localBranch: boolean | undefined;
try {
({ repo, branch, provider: detectedProvider, repo_url: detectedRepoUrl } = detectRepo({
({ repo, branch, provider: detectedProvider, repo_url: detectedRepoUrl, local_branch: localBranch } = detectRepo({
repo: opts.repo,
branch: opts.branch,
quiet: opts.quiet,
Expand All @@ -111,6 +112,28 @@ export async function runRemoteScan(opts: RunOpts): Promise<void> {
process.exit(EXIT_GENERAL_ERROR);
}

// The backend clones the remote, so an auto-detected branch that was never
// pushed can only fail there. Say so now instead of queueing that scan.
if (localBranch) {
const remoteSha = remoteBranchSha(branch!);
if (remoteSha === null) {
console.error(
`Branch "${branch}" does not exist on the remote (origin). Rafter scans the remote ` +
"repository: push the branch first, or pass --branch to scan one that exists."
);
process.exit(EXIT_GENERAL_ERROR);
}
if (remoteSha && !opts.quiet) {
let head: string | undefined;
try { head = git("rev-parse HEAD"); } catch { head = undefined; }
if (head && head !== remoteSha) {
console.error(
`Note: local HEAD differs from origin/${branch}; the scan covers the pushed commit ${remoteSha.slice(0, 7)}.`
);
}
}
}

// Explicit flags override inferred values.
const provider = opts.provider ?? detectedProvider;
const repoUrl = opts.repoUrl ?? detectedRepoUrl;
Expand Down
34 changes: 32 additions & 2 deletions node/src/utils/git.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
import { execSync } from "child_process";
import { execFileSync, execSync } from "child_process";

export function git(cmd: string): string {
return execSync(`git ${cmd}`, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
}

/**
* Look up `branch` on the `origin` remote.
*
* Returns the remote commit SHA, `null` when the remote answered and has no
* such branch, or `undefined` when it could not be asked (offline, auth
* failure, timeout). Callers treat `undefined` as unknown and carry on.
*/
export function remoteBranchSha(branch: string, cwd?: string): string | null | undefined {
try {
const out = execFileSync(
"git",
["ls-remote", "--exit-code", "--heads", "origin", `refs/heads/${branch}`],
{
cwd,
stdio: ["ignore", "pipe", "ignore"],
timeout: 15_000,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
}
).toString();
return out.split(/\s+/)[0] || undefined;
} catch (e: any) {
// --exit-code: status 2 means the remote has no matching ref.
return e?.status === 2 ? null : undefined;
}
}

/**
* Return the current branch name.
*
Expand Down Expand Up @@ -155,6 +181,8 @@ export interface DetectedRepo {
branch?: string;
provider?: Provider;
repo_url?: string;
/** Both repo and branch came from the local checkout (origin + HEAD). */
local_branch?: boolean;
}

const AUTO_DETECT_FAILURE =
Expand Down Expand Up @@ -191,6 +219,7 @@ export function detectRepo(opts: { repo?: string; branch?: string; quiet?: boole
// A rejection from parseRemote (unrecognized host) is deliberately NOT
// swallowed into the generic message below — it names the offending
// remote, which is the actionable part.
const repoFromOrigin = !repoSlug;
if (!repoSlug) {
let remoteUrl: string;
try {
Expand All @@ -204,12 +233,13 @@ export function detectRepo(opts: { repo?: string; branch?: string; quiet?: boole
repoUrl = inferred.repoUrl;
}

const localBranch = repoFromOrigin && !branch;
if (!branch) {
branch = safeBranch(git);
}

if ((!opts.repo || !opts.branch) && !opts.quiet) {
console.error(`Repo auto-detected: ${repoSlug} @ ${branch} (note: scanning remote)`);
}
return { repo: repoSlug, branch, provider, repo_url: repoUrl };
return { repo: repoSlug, branch, provider, repo_url: repoUrl, local_branch: localBranch };
}
36 changes: 36 additions & 0 deletions node/tests/remote-branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { remoteBranchSha } from "../src/utils/git.js";

// `rafter run` scans the remote, so it must tell an unpushed local branch
// (remote answers, branch absent) apart from a pushed one and from a remote
// it cannot reach. Real git against a local bare origin, no network.
describe("remoteBranchSha", () => {
it("returns the SHA for a pushed branch, null for an unpushed one, undefined when origin is unreachable", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-remote-branch-"));
try {
const g = (cwd: string, ...args: string[]) =>
execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
const bare = path.join(root, "origin.git");
const work = path.join(root, "work");
fs.mkdirSync(work);
g(root, "init", "-q", "--bare", bare);
g(work, "init", "-q", "-b", "main");
g(work, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init");
g(work, "remote", "add", "origin", bare);
g(work, "push", "-q", "origin", "main");
g(work, "checkout", "-q", "-b", "task/unpushed");

expect(remoteBranchSha("main", work)).toBe(g(work, "rev-parse", "main"));
expect(remoteBranchSha("task/unpushed", work)).toBeNull();

g(work, "remote", "set-url", "origin", path.join(root, "missing.git"));
expect(remoteBranchSha("main", work)).toBeUndefined();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
26 changes: 25 additions & 1 deletion python/rafter_cli/commands/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
resolve_key,
write_payload,
)
from ..utils.git import detect_repo
from ..utils.git import _run, branch_from_env, detect_repo, remote_branch_sha


def _plus_approval_gate_enabled() -> bool:
Expand Down Expand Up @@ -408,6 +408,30 @@ def _do_remote_scan(
if not (repo and branch) and not quiet:
print(f"Repo auto-detected: {repo_slug} @ {branch_name} (note: scanning remote)", file=sys.stderr)

# The backend clones the remote, so an auto-detected branch that was never
# pushed can only fail there. Say so now instead of queueing that scan.
# A repo_url is inferred only when the slug came from the origin remote.
if detected_repo_url and not branch and not branch_from_env():
remote_sha = remote_branch_sha(branch_name)
if remote_sha is None:
print(
f'Branch "{branch_name}" does not exist on the remote (origin). Rafter scans '
"the remote repository: push the branch first, or pass --branch to scan one that exists.",
file=sys.stderr,
)
raise typer.Exit(code=EXIT_GENERAL_ERROR)
if remote_sha and not quiet:
try:
head = _run(["git", "rev-parse", "HEAD"])
except Exception:
head = None
if head and head != remote_sha:
print(
f"Note: local HEAD differs from origin/{branch_name}; "
f"the scan covers the pushed commit {remote_sha[:7]}.",
file=sys.stderr,
)

# Explicit flags override inferred values.
resolved_provider = provider or detected_provider
resolved_repo_url = repo_url or detected_repo_url
Expand Down
44 changes: 39 additions & 5 deletions python/rafter_cli/utils/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,44 @@ def is_inside_repo() -> bool:
return False


def remote_branch_sha(branch: str, cwd: str | None = None) -> str | None | bool:
"""Look up ``branch`` on the ``origin`` remote.

Returns the remote commit SHA, ``None`` when the remote answered and has
no such branch, or ``False`` when it could not be asked (offline, auth
failure, timeout). Callers treat ``False`` as unknown and carry on.
"""
import os

try:
out = subprocess.run(
["git", "ls-remote", "--exit-code", "--heads", "origin", f"refs/heads/{branch}"],
cwd=cwd,
capture_output=True,
text=True,
timeout=15,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
except (OSError, subprocess.TimeoutExpired):
return False
if out.returncode == 2: # --exit-code: no matching ref on the remote
return None
if out.returncode != 0:
return False
return out.stdout.split()[0] if out.stdout.split() else False


def branch_from_env() -> str | None:
"""The branch a CI provider names in its environment, if any."""
import os

return (
os.getenv("GITHUB_REF_NAME")
or os.getenv("CI_COMMIT_BRANCH")
or os.getenv("CI_BRANCH")
)


def safe_branch() -> str:
"""Return the current branch name.

Expand Down Expand Up @@ -178,11 +216,7 @@ def detect_repo(
import os

repo_env = os.getenv("GITHUB_REPOSITORY") or os.getenv("CI_REPOSITORY")
branch_env = (
os.getenv("GITHUB_REF_NAME")
or os.getenv("CI_COMMIT_BRANCH")
or os.getenv("CI_BRANCH")
)
branch_env = branch_from_env()
repo_slug = repo or repo_env
branch_name = branch or branch_env
provider: str | None = None
Expand Down
31 changes: 31 additions & 0 deletions python/tests/test_git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
get_git_root,
provider_for_host,
infer_remote,
remote_branch_sha,
)


Expand Down Expand Up @@ -385,3 +386,33 @@ def test_raises_when_not_in_repo_and_no_env(self, monkeypatch):
with patch("rafter_cli.utils.git.is_inside_repo", return_value=False):
with pytest.raises(RuntimeError, match="Could not auto-detect"):
detect_repo()


# ── remote_branch_sha (real git, local bare origin) ─────────────────


def test_remote_branch_sha_tells_unpushed_from_pushed_and_unreachable(tmp_path):
"""`rafter run` scans the remote, so it must tell an unpushed local branch
(remote answers, branch absent) apart from a pushed one and from a remote
it cannot reach."""

def g(cwd, *args):
return subprocess.check_output(
["git", *args], cwd=cwd, text=True, stderr=subprocess.DEVNULL
).strip()

bare = tmp_path / "origin.git"
work = tmp_path / "work"
work.mkdir()
g(tmp_path, "init", "-q", "--bare", str(bare))
g(work, "init", "-q", "-b", "main")
g(work, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init")
g(work, "remote", "add", "origin", str(bare))
g(work, "push", "-q", "origin", "main")
g(work, "checkout", "-q", "-b", "task/unpushed")

assert remote_branch_sha("main", cwd=str(work)) == g(work, "rev-parse", "main")
assert remote_branch_sha("task/unpushed", cwd=str(work)) is None

g(work, "remote", "set-url", "origin", str(tmp_path / "missing.git"))
assert remote_branch_sha("main", cwd=str(work)) is False
6 changes: 6 additions & 0 deletions python/tests/test_scan_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ def test_provider_without_any_repo_url_is_omitted(self, _mock_repo, mock_post):
assert "provider" not in body
assert "repo_url" not in body

# Auto-detected branch: keep the remote-branch lookup off the network.
@patch("rafter_cli.commands.backend.remote_branch_sha", new=lambda *a, **k: False)
@patch("rafter_cli.commands.backend.api_post")
@patch(
"rafter_cli.commands.backend.detect_repo",
Expand All @@ -224,6 +226,8 @@ def test_inferred_gitlab_provider_flows_into_body(self, _mock_repo, mock_post):
assert body["provider"] == "gitlab"
assert body["repo_url"] == "https://gitlab.com/group/project"

# Auto-detected branch: keep the remote-branch lookup off the network.
@patch("rafter_cli.commands.backend.remote_branch_sha", new=lambda *a, **k: False)
@patch("rafter_cli.commands.backend.api_post")
@patch(
"rafter_cli.commands.backend.detect_repo",
Expand Down Expand Up @@ -305,6 +309,8 @@ def test_prints_scan_id_when_not_quiet(self, _mock_repo, mock_post, capsys):
err = capsys.readouterr().err
assert "s-xyz" in err

# Auto-detected branch: keep the remote-branch lookup off the network.
@patch("rafter_cli.commands.backend.remote_branch_sha", new=lambda *a, **k: False)
@patch("rafter_cli.commands.backend.api_post")
@patch("rafter_cli.commands.backend.detect_repo", return_value=("owner/repo", "main", "github", "https://github.com/owner/repo"))
def test_auto_detect_message_when_not_explicit(self, _mock_repo, mock_post, capsys):
Expand Down
2 changes: 1 addition & 1 deletion shared-docs/CLI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Trigger a new security scan for a repository.

- `-k, --api-key TEXT` — API key. Resolution order: this flag → `RAFTER_API_KEY` env → `backend.apiKey` in global config (see `rafter agent config`)
- `-r, --repo TEXT` — org/repo (default: auto-detected from git remote; errors if the remote's host isn't a recognized GitHub/GitLab/Bitbucket/Gitea host — pass this flag explicitly for anything else)
- `-b, --branch TEXT` — branch (default: current branch; errors on a detached HEAD instead of submitting a commit SHA or guessing 'main' — pass this flag explicitly)
- `-b, --branch TEXT` — branch (default: current branch; errors on a detached HEAD instead of submitting a commit SHA or guessing 'main' — pass this flag explicitly). When both repo and branch are auto-detected, rafter asks `origin` whether the branch exists and exits `1` if it does not, since the scan runs against the remote; if `origin` cannot be reached it proceeds. When the pushed commit differs from local `HEAD` it notes that the scan covers the pushed commit
- `-f, --format [json|md]` — output format (default: md)
- `-m, --mode [fast|plus]` — scan mode (default: fast). Fast runs SAST, secret detection, and dependency checks. Plus adds agentic deep-dive analysis that examines your codebase the way a professional cybersecurity auditor would — tracing data flows and reasoning about business logic on top of the full SAST/SCA toolchain. **Plus is a paid tier that consumes credits.**
- `--github-token TEXT` — GitHub PAT for private repos (or `RAFTER_GITHUB_TOKEN` env var)
Expand Down
Loading