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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a

## Unreleased

### Fixed

- **A profile's sticky branch no longer follows `--url` to another server**
([#171]). When `--url`/`NSC_URL` targets a different server from the
profile's, the profile `branch:` is ignored instead of failing with
`not_found` on the other server, and a one-line stderr notice says so.
`--branch` and `NSC_BRANCH` still apply. `nsc branch use` / `branch create
--use` now refuse (exit 12) to write or clear the profile's branch while a
URL override points at another server.

## v1.8.2 — 2026-10-23

Patch release. An invalid output format is now a usage error instead of a crash.
Expand Down Expand Up @@ -121,6 +131,7 @@ notices, and a fix for `nsc login` reporting the wrong user.
[#1]: https://github.com/thomaschristory/netbox-super-cli/issues/1
[#166]: https://github.com/thomaschristory/netbox-super-cli/issues/166
[#170]: https://github.com/thomaschristory/netbox-super-cli/issues/170
[#171]: https://github.com/thomaschristory/netbox-super-cli/issues/171
[#179]: https://github.com/thomaschristory/netbox-super-cli/issues/179
[#173]: https://github.com/thomaschristory/netbox-super-cli/issues/173
[#150]: https://github.com/thomaschristory/netbox-super-cli/issues/150
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/branching.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ The active branch comes from the first of these that is set:
`--branch main` or `NSC_BRANCH=main` forces main for that invocation even when
the profile has a sticky branch.

A profile's sticky branch belongs to that profile's server. When `--url` or
`NSC_URL` points at a different server, it is ignored, with a one-line notice
on stderr (`--branch` and `NSC_BRANCH` still apply). `nsc branch use` and
`nsc branch create --use` refuse to change the profile's branch while a URL
override points elsewhere.

`--branch` is a global option and must come **before** the subcommand
(`nsc --branch X dcim sites list`). After the subcommand, `--branch` belongs to
that command, for example the Branching plugin's own `changes list` filter, and
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/managing-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ Override precedence (highest first): CLI flag > env var > config profile.
The same precedence applies to the NetBox Branching branch: `--branch NAME` >
`NSC_BRANCH` > the profile's `branch:` key. `nsc branch use NAME` writes that
key, and `nsc branch use --main` removes it. `--branch main` forces main for
one command. See [NetBox Branching](branching.md).
one command. The profile's `branch:` is ignored when `--url`/`NSC_URL` points
at a different server. See [NetBox Branching](branching.md).

```yaml
profiles:
Expand Down
35 changes: 24 additions & 11 deletions nsc/cli/branch_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from nsc.branching.models import Branch, choice_value
from nsc.branching.resolve import get_branch, require_ready
from nsc.cache.store import ADHOC_PROFILE
from nsc.cli.runtime import MAIN_BRANCH, RuntimeContext, emit_envelope, map_error
from nsc.cli.runtime import MAIN_BRANCH, RuntimeContext, emit_envelope, map_error, same_server
from nsc.config.models import OutputFormat
from nsc.http.client import NetBoxClient
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
Expand Down Expand Up @@ -165,17 +165,30 @@ def _write_profile_branch(runtime: RuntimeContext, value: str | None) -> None:


def _require_saved_profile(runtime: RuntimeContext, name: str | None) -> None:
if runtime.resolved_profile.name != ADHOC_PROFILE:
return
profile = runtime.resolved_profile
hint = name or "<name>"
_fail(
runtime,
ErrorEnvelope(
error="no saved profile to store the branch on; "
f"set NSC_BRANCH={hint} or pass --branch {hint}",
type=ErrorType.CONFIG,
),
)
if profile.name == ADHOC_PROFILE:
_fail(
runtime,
ErrorEnvelope(
error="no saved profile to store the branch on; "
f"set NSC_BRANCH={hint} or pass --branch {hint}",
type=ErrorType.CONFIG,
),
)
saved = runtime.config.profiles.get(profile.name)
if saved is not None and not same_server(str(profile.url), saved.url):
# A sticky branch is per profile server; storing one picked on another
# server would apply it to the wrong NetBox.
_fail(
runtime,
ErrorEnvelope(
error=f"--url/NSC_URL targets {profile.url}, not profile {profile.name}'s "
f"server {saved.url}; drop the URL override, or use --branch {hint} / "
f"NSC_BRANCH={hint} for this server",
type=ErrorType.CONFIG,
),
)


def _use(runtime: RuntimeContext, branch: Branch) -> None:
Expand Down
7 changes: 7 additions & 0 deletions nsc/cli/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from nsc.cli.runtime import (
CLIOverrides,
RuntimeContext,
ignored_profile_branch,
resolve_branch_selection,
resolve_color,
resolve_object_colors,
Expand Down Expand Up @@ -48,6 +49,12 @@ def build_runtime_context(state: GlobalState) -> RuntimeContext:
force_refresh=state.overrides.refresh_schema,
)
branch = resolve_branch_selection(state.config, state.overrides, env=os.environ)
ignored = ignored_profile_branch(state.config, state.overrides, env=os.environ)
if ignored is not None and branch is None:
print(
f"⎇ profile branch {ignored} ignored: --url/NSC_URL targets another server",
file=sys.stderr,
)
client = NetBoxClient(
profile, # type: ignore[arg-type]
debug=state.debug,
Expand Down
31 changes: 27 additions & 4 deletions nsc/cli/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from collections.abc import Iterable, Iterator, Mapping
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, HttpUrl, SkipValidation
from pydantic import BaseModel, ConfigDict, HttpUrl, SkipValidation, ValidationError

from nsc.auth.keyring_store import KeyringStoreError, resolve_token
from nsc.branching.errors import (
Expand Down Expand Up @@ -171,19 +171,34 @@ def resolve_branch_selection(
overrides: CLIOverrides,
env: Mapping[str, str],
) -> BranchSelection | None:
name = overrides.profile or env.get("NSC_PROFILE") or config.default_profile
base = config.profiles.get(name) if name else None
base, _ = _select_base_profile(config, overrides, env)
profile_branch = base.branch if base else None
if ignored_profile_branch(config, overrides, env) is not None:
profile_branch = None
candidates: list[tuple[str | None, Literal["flag", "env", "profile"]]] = [
(overrides.branch, "flag"),
(env.get("NSC_BRANCH"), "env"),
(base.branch if base else None, "profile"),
(profile_branch, "profile"),
]
for value, source in candidates:
if value:
return None if value == MAIN_BRANCH else BranchSelection(name=value, source=source)
return None


def ignored_profile_branch(
config: Config, overrides: CLIOverrides, env: Mapping[str, str]
) -> str | None:
"""The profile's sticky branch, when --url/NSC_URL points at another server."""
base, _ = _select_base_profile(config, overrides, env)
if base is None or not base.branch:
return None
url_override = _first_set(overrides.url, env.get("NSC_URL"))
if url_override is None or same_server(url_override, base.url):
return None
return base.branch


def _select_base_profile(
config: Config, overrides: CLIOverrides, env: Mapping[str, str]
) -> tuple[Profile | None, str]:
Expand All @@ -195,6 +210,14 @@ def _select_base_profile(
return config.profiles[name], name


def same_server(raw: str, url: HttpUrl) -> bool:
try:
parsed = HttpUrl(raw)
except ValidationError:
return False
return str(parsed).rstrip("/") == str(url).rstrip("/")


def _first_set(*values: str | None) -> str | None:
for v in values:
if v is not None and v != "":
Expand Down
42 changes: 40 additions & 2 deletions tests/cli/test_branch_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ def _home(monkeypatch: pytest.MonkeyPatch, fixture_profile_yaml: Path) -> Path:
return fixture_profile_yaml


def _mock_schema(router: respx.Router) -> None:
def _mock_schema(router: respx.Router, base: str = NB) -> None:
bundled = next(Path("nsc/schemas/bundled").glob("*.json*"))
body = gzip.decompress(bundled.read_bytes())
router.get(f"{NB}/api/schema/?format=json").respond(
router.get(f"{base}/api/schema/?format=json").respond(
200, content=body, headers={"content-type": "application/json"}
)

Expand Down Expand Up @@ -289,3 +289,41 @@ def test_saved_filter_branch_failure_honours_json_output(
assert envelope["type"] == "config"
assert envelope["details"]["branch"] == "my-change"
assert not sites.called


OTHER = "https://other.example"


@pytest.mark.httpx2(assert_all_called=False)
@pytest.mark.parametrize("args", [["use", "--main"], ["use", "my-change"]])
def test_use_refuses_when_url_targets_another_server(
httpx2_mock: respx.Router, _home: Path, args: list[str]
) -> None:
_mock_schema(httpx2_mock, OTHER)
_set_profile_branch(_home, "keep-me")
before = (_home / "config.yaml").read_text()
result = _invoke("--url", OTHER, "branch", *args, "--output", "json")
assert result.exit_code == 12, result.output
assert OTHER in json.loads(result.stdout)["error"]
assert (_home / "config.yaml").read_text() == before


@pytest.mark.httpx2(assert_all_called=False)
def test_create_use_refuses_before_creating_on_another_server(
httpx2_mock: respx.Router, _home: Path
) -> None:
_mock_schema(httpx2_mock, OTHER)
create = httpx2_mock.post(f"{OTHER}/api/plugins/branching/branches/").respond(201, json={})
result = _invoke("--url", OTHER, "branch", "create", "x", "--use", "--output", "json")
assert result.exit_code == 12, result.output
assert not create.called


@pytest.mark.httpx2(assert_all_called=False)
def test_ignored_profile_branch_prints_notice(httpx2_mock: respx.Router, _home: Path) -> None:
_mock_schema(httpx2_mock, OTHER)
_set_profile_branch(_home, "keep-me")
result = _invoke("--url", OTHER, "branch", "current", "--output", "json")
assert result.exit_code == 0, result.output
assert "keep-me" in result.stderr
assert "ignored" in result.stderr
61 changes: 60 additions & 1 deletion tests/cli/test_runtime_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
from nsc.cli import globals as globals_mod
from nsc.cli.app import _extract_global_overrides, _first_non_option
from nsc.cli.globals import GlobalState, build_runtime_context
from nsc.cli.runtime import BranchSelection, CLIOverrides, map_error, resolve_branch_selection
from nsc.cli.runtime import (
BranchSelection,
CLIOverrides,
ignored_profile_branch,
map_error,
resolve_branch_selection,
)
from nsc.config.loader import load_config
from nsc.config.models import Config, Profile
from nsc.model.command_model import CommandModel
Expand Down Expand Up @@ -155,3 +161,56 @@ def test_branch_before_subcommand_is_global_and_other_flags_still_scan() -> None
def test_first_non_option_skips_branch_value() -> None:
assert _first_non_option(["--branch", "x", "profiles", "list"]) == "profiles"
assert _first_non_option(["--branch=x", "profiles", "list"]) == "profiles"


@pytest.mark.parametrize(
("overrides", "env"),
[
(CLIOverrides(url="https://other.example"), {}),
(CLIOverrides(), {"NSC_URL": "https://other.example/"}),
],
)
def test_profile_branch_ignored_when_url_targets_another_server(
overrides: CLIOverrides, env: dict[str, str]
) -> None:
assert resolve_branch_selection(_cfg(), overrides, env=env) is None


@pytest.mark.parametrize("url", ["https://nb.example", "https://nb.example/", "https://NB.example"])
def test_profile_branch_kept_when_url_override_matches_profile(url: str) -> None:
sel = resolve_branch_selection(_cfg(), CLIOverrides(url=url), env={})
assert sel == BranchSelection(name="prof-branch", source="profile")


def test_explicit_branch_still_applies_with_url_override() -> None:
other = CLIOverrides(url="https://other.example")
assert resolve_branch_selection(_cfg(), other, env={"NSC_BRANCH": "e"}) == BranchSelection(
name="e", source="env"
)
with_flag = CLIOverrides(url="https://other.example", branch="f")
assert resolve_branch_selection(_cfg(), with_flag, env={}) == BranchSelection(
name="f", source="flag"
)


def _prefixed_cfg() -> Config:
return Config(
default_profile="p",
profiles={"p": Profile(name="p", url="https://nb.example/netbox", token="t", branch="pb")},
)


def test_profile_branch_with_path_prefix_matches_only_same_prefix() -> None:
same = CLIOverrides(url="https://nb.example/netbox/")
assert resolve_branch_selection(_prefixed_cfg(), same, env={}) == BranchSelection(
name="pb", source="profile"
)
root = CLIOverrides(url="https://nb.example")
assert resolve_branch_selection(_prefixed_cfg(), root, env={}) is None


def test_ignored_profile_branch_is_reported() -> None:
other = CLIOverrides(url="https://other.example")
assert ignored_profile_branch(_cfg(), other, env={}) == "prof-branch"
assert ignored_profile_branch(_cfg(), CLIOverrides(), env={}) is None
assert ignored_profile_branch(_cfg(branch=None), other, env={}) is None
Loading