assertion-cli 0.5.13__tar.gz → 0.5.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/PKG-INFO +31 -5
  2. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/README.md +30 -3
  3. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/api.py +35 -18
  4. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/assertion_cli.egg-info/PKG-INFO +31 -5
  5. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/assertion_cli.egg-info/SOURCES.txt +3 -0
  6. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/assertion_cli.egg-info/requires.txt +0 -1
  7. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/assertion_cli.egg-info/top_level.txt +1 -0
  8. assertion_cli-0.5.15/credentials.py +120 -0
  9. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/git.py +65 -7
  10. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/main.py +100 -5
  11. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/pyproject.toml +2 -3
  12. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/templates/AGENTS.md +2 -2
  13. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/templates/SKILL.md +22 -8
  14. assertion_cli-0.5.15/tests/test_auth.py +129 -0
  15. assertion_cli-0.5.15/tests/test_credentials.py +122 -0
  16. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_git.py +147 -29
  17. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_install.py +9 -0
  18. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_session_start.py +20 -0
  19. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/assertion_cli.egg-info/dependency_links.txt +0 -0
  20. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/assertion_cli.egg-info/entry_points.txt +0 -0
  21. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/bundle.py +0 -0
  22. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/link.py +0 -0
  23. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/models.py +0 -0
  24. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/session.py +0 -0
  25. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/setup.cfg +0 -0
  26. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/templates/__init__.py +0 -0
  27. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_api.py +0 -0
  28. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_bundle.py +0 -0
  29. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_decision.py +0 -0
  30. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_link.py +0 -0
  31. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_main.py +0 -0
  32. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_prompt.py +0 -0
  33. {assertion_cli-0.5.13 → assertion_cli-0.5.15}/tests/test_session.py +0 -0
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.13
3
+ Version: 0.5.15
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: httpx>=0.28.1
8
8
  Requires-Dist: pydantic>=2.12.5
9
- Requires-Dist: python-dotenv>=1.0
10
9
  Requires-Dist: typer>=0.24.1
11
10
  Provides-Extra: test
12
11
  Requires-Dist: pytest>=8.0; extra == "test"
@@ -18,7 +17,8 @@ CLI for the Assertion API.
18
17
 
19
18
  ## Usage
20
19
 
21
- The CLI currently targets a local backend at `http://localhost:8000`.
20
+ The CLI targets `https://app.tryassertion.com` by default; set
21
+ `ASSERTION_BASE_URL` (e.g. `http://localhost:8000`) to point at another backend.
22
22
 
23
23
  Run locally from the workspace:
24
24
 
@@ -34,14 +34,14 @@ uv tool install --reinstall assertion-cli # upgrade in place
34
34
  ```
35
35
 
36
36
  The CLI package declares all of its direct runtime dependencies. At the moment
37
- that set is `httpx`, `pydantic`, `python-dotenv`, and `typer`.
37
+ that set is `httpx`, `pydantic`, and `typer`.
38
38
 
39
39
  After installation:
40
40
 
41
41
  ```bash
42
42
  asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
43
+ asrt login # authenticate this repo (generate a token at Settings → Authentication)
43
44
  asrt session start # per work session: record the diff base (current HEAD)
44
- asrt new # reset local run state for a fresh session
45
45
  asrt stacks
46
46
  asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
47
47
  asrt checkpoint --continue "Implemented Y"
@@ -50,6 +50,32 @@ asrt verify # submit final verification (non-blocking
50
50
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
51
51
  ```
52
52
 
53
+ ## Authentication
54
+
55
+ `asrt login` stores your API token in a global, user-level file —
56
+ `~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
57
+
58
+ ```json
59
+ { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
60
+ ```
61
+
62
+ Run it from inside the repo it should authenticate. It validates the token and
63
+ confirms the repo's `origin` remote is connected to a stack in the token's
64
+ workspace before saving, so each repo can target a different workspace. Because
65
+ entries are keyed by path, moving or renaming the repo folder means logging in
66
+ again (which adds a new entry). `asrt logout` removes the current repo's entry;
67
+ `asrt whoami` shows which token is in effect.
68
+
69
+ Token resolution at call time, in order:
70
+
71
+ 1. the entry for the current repo path in `~/.assertion/credentials.json`;
72
+ 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
73
+ with no remote that `asrt login` won't store);
74
+
75
+ with a clear "run `asrt login`" error when neither is present. The API base URL
76
+ defaults to `https://app.tryassertion.com`; override it with `ASSERTION_BASE_URL`
77
+ for a local backend.
78
+
53
79
  ## Publishing a new version
54
80
 
55
81
  1. **Bump the version** in `cli/pyproject.toml`.
@@ -4,7 +4,8 @@ CLI for the Assertion API.
4
4
 
5
5
  ## Usage
6
6
 
7
- The CLI currently targets a local backend at `http://localhost:8000`.
7
+ The CLI targets `https://app.tryassertion.com` by default; set
8
+ `ASSERTION_BASE_URL` (e.g. `http://localhost:8000`) to point at another backend.
8
9
 
9
10
  Run locally from the workspace:
10
11
 
@@ -20,14 +21,14 @@ uv tool install --reinstall assertion-cli # upgrade in place
20
21
  ```
21
22
 
22
23
  The CLI package declares all of its direct runtime dependencies. At the moment
23
- that set is `httpx`, `pydantic`, `python-dotenv`, and `typer`.
24
+ that set is `httpx`, `pydantic`, and `typer`.
24
25
 
25
26
  After installation:
26
27
 
27
28
  ```bash
28
29
  asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
30
+ asrt login # authenticate this repo (generate a token at Settings → Authentication)
29
31
  asrt session start # per work session: record the diff base (current HEAD)
30
- asrt new # reset local run state for a fresh session
31
32
  asrt stacks
32
33
  asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
33
34
  asrt checkpoint --continue "Implemented Y"
@@ -36,6 +37,32 @@ asrt verify # submit final verification (non-blocking
36
37
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
37
38
  ```
38
39
 
40
+ ## Authentication
41
+
42
+ `asrt login` stores your API token in a global, user-level file —
43
+ `~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
44
+
45
+ ```json
46
+ { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
47
+ ```
48
+
49
+ Run it from inside the repo it should authenticate. It validates the token and
50
+ confirms the repo's `origin` remote is connected to a stack in the token's
51
+ workspace before saving, so each repo can target a different workspace. Because
52
+ entries are keyed by path, moving or renaming the repo folder means logging in
53
+ again (which adds a new entry). `asrt logout` removes the current repo's entry;
54
+ `asrt whoami` shows which token is in effect.
55
+
56
+ Token resolution at call time, in order:
57
+
58
+ 1. the entry for the current repo path in `~/.assertion/credentials.json`;
59
+ 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
60
+ with no remote that `asrt login` won't store);
61
+
62
+ with a clear "run `asrt login`" error when neither is present. The API base URL
63
+ defaults to `https://app.tryassertion.com`; override it with `ASSERTION_BASE_URL`
64
+ for a local backend.
65
+
39
66
  ## Publishing a new version
40
67
 
41
68
  1. **Bump the version** in `cli/pyproject.toml`.
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from pathlib import Path
4
5
  from typing import Any, TypeVar
5
6
 
6
7
  import httpx
7
8
  import typer
8
- from dotenv import find_dotenv, load_dotenv
9
9
  from pydantic import BaseModel, ValidationError
10
10
 
11
+ from credentials import API_TOKEN_ENV, resolve_token
12
+ from git import find_git_root_or_none
11
13
  from models import (
12
14
  CheckpointResponse,
13
15
  DecisionResponse,
@@ -24,28 +26,35 @@ T = TypeVar("T", bound=BaseModel)
24
26
  # local backend (or self-hosted users) override with ASSERTION_BASE_URL.
25
27
  DEFAULT_BASE_URL = "https://app.tryassertion.com"
26
28
  DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 300.0
27
- API_TOKEN_ENV = "ASSERTION_API_TOKEN"
28
-
29
- # Walk up from cwd looking for a .env so the user can drop their token in the
30
- # repo root once and forget it. override=False means a real shell `export`
31
- # still wins over the file — useful for CI.
32
- _dotenv_path = find_dotenv(usecwd=True)
33
- if _dotenv_path:
34
- load_dotenv(_dotenv_path, override=False)
35
29
 
36
30
 
37
31
  def _base_url() -> str:
38
32
  return os.environ.get("ASSERTION_BASE_URL", DEFAULT_BASE_URL)
39
33
 
40
34
 
35
+ def _resolve_token() -> str | None:
36
+ """Token for the current repo, else the shell env-var backup, else None.
37
+
38
+ The stored credential (keyed by the exact current git root) is primary; a
39
+ real `export ASSERTION_API_TOKEN` only fills in when no entry matches — e.g.
40
+ in CI, or a repo that was moved/renamed and never re-authenticated.
41
+ """
42
+ token = resolve_token(find_git_root_or_none(Path.cwd()))
43
+ if token:
44
+ return token
45
+ env_token = (os.environ.get(API_TOKEN_ENV) or "").strip()
46
+ return env_token or None
47
+
48
+
41
49
  def _auth_headers() -> dict[str, str]:
42
- token = (os.environ.get(API_TOKEN_ENV) or "").strip()
50
+ token = _resolve_token()
43
51
  if not token:
52
+ repo_root = find_git_root_or_none(Path.cwd())
53
+ where = f" for {repo_root}" if repo_root else ""
44
54
  typer.echo(
45
- f"Missing {API_TOKEN_ENV}. Generate a token at Settings → Authentication "
46
- "in the dashboard, then save it in a .env at your repo root:\n\n"
47
- f" echo '{API_TOKEN_ENV}=alk_...' >> .env\n\n"
48
- "(Or export it in your shell.)",
55
+ f"No saved Assertion token{where}. Run `asrt login` to authenticate "
56
+ "this repo (if you moved or renamed the folder, log in again).\n"
57
+ f"Alternatively, export {API_TOKEN_ENV} in your shell as a backup.",
49
58
  err=True,
50
59
  )
51
60
  raise typer.Exit(code=1)
@@ -68,8 +77,16 @@ def _summarize_error(response: httpx.Response) -> str:
68
77
 
69
78
 
70
79
  class AssertionClient:
71
- def __init__(self) -> None:
80
+ def __init__(self, *, token: str | None = None) -> None:
72
81
  self.base_url = _base_url()
82
+ # An explicit token lets `asrt login` validate a credential before it is
83
+ # saved; otherwise headers resolve from the store / env-var backup.
84
+ self._token_override = token.strip() if token else None
85
+
86
+ def _headers(self) -> dict[str, str]:
87
+ if self._token_override:
88
+ return {"Authorization": f"Bearer {self._token_override}"}
89
+ return _auth_headers()
73
90
 
74
91
  def _request(
75
92
  self,
@@ -81,7 +98,7 @@ class AssertionClient:
81
98
  **kwargs: Any,
82
99
  ) -> T:
83
100
  url = f"{self.base_url}{path}"
84
- headers = {**_auth_headers(), **kwargs.pop("headers", {})}
101
+ headers = {**self._headers(), **kwargs.pop("headers", {})}
85
102
  try:
86
103
  response = httpx.request(
87
104
  method, url, timeout=timeout, headers=headers, **kwargs
@@ -119,7 +136,7 @@ class AssertionClient:
119
136
  **kwargs: Any,
120
137
  ) -> DecisionResponse:
121
138
  url = f"{self.base_url}{path}"
122
- headers = {**_auth_headers(), **kwargs.pop("headers", {})}
139
+ headers = {**self._headers(), **kwargs.pop("headers", {})}
123
140
  try:
124
141
  response = httpx.request(
125
142
  method, url, timeout=timeout, headers=headers, **kwargs
@@ -145,7 +162,7 @@ class AssertionClient:
145
162
 
146
163
  url = f"{self.base_url}/api/v0/agent/stacks"
147
164
  try:
148
- response = httpx.get(url, timeout=10.0, headers=_auth_headers())
165
+ response = httpx.get(url, timeout=10.0, headers=self._headers())
149
166
  response.raise_for_status()
150
167
  except httpx.HTTPStatusError as exc:
151
168
  summary = _summarize_error(exc.response)
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.13
3
+ Version: 0.5.15
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: httpx>=0.28.1
8
8
  Requires-Dist: pydantic>=2.12.5
9
- Requires-Dist: python-dotenv>=1.0
10
9
  Requires-Dist: typer>=0.24.1
11
10
  Provides-Extra: test
12
11
  Requires-Dist: pytest>=8.0; extra == "test"
@@ -18,7 +17,8 @@ CLI for the Assertion API.
18
17
 
19
18
  ## Usage
20
19
 
21
- The CLI currently targets a local backend at `http://localhost:8000`.
20
+ The CLI targets `https://app.tryassertion.com` by default; set
21
+ `ASSERTION_BASE_URL` (e.g. `http://localhost:8000`) to point at another backend.
22
22
 
23
23
  Run locally from the workspace:
24
24
 
@@ -34,14 +34,14 @@ uv tool install --reinstall assertion-cli # upgrade in place
34
34
  ```
35
35
 
36
36
  The CLI package declares all of its direct runtime dependencies. At the moment
37
- that set is `httpx`, `pydantic`, `python-dotenv`, and `typer`.
37
+ that set is `httpx`, `pydantic`, and `typer`.
38
38
 
39
39
  After installation:
40
40
 
41
41
  ```bash
42
42
  asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
43
+ asrt login # authenticate this repo (generate a token at Settings → Authentication)
43
44
  asrt session start # per work session: record the diff base (current HEAD)
44
- asrt new # reset local run state for a fresh session
45
45
  asrt stacks
46
46
  asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
47
47
  asrt checkpoint --continue "Implemented Y"
@@ -50,6 +50,32 @@ asrt verify # submit final verification (non-blocking
50
50
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
51
51
  ```
52
52
 
53
+ ## Authentication
54
+
55
+ `asrt login` stores your API token in a global, user-level file —
56
+ `~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
57
+
58
+ ```json
59
+ { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
60
+ ```
61
+
62
+ Run it from inside the repo it should authenticate. It validates the token and
63
+ confirms the repo's `origin` remote is connected to a stack in the token's
64
+ workspace before saving, so each repo can target a different workspace. Because
65
+ entries are keyed by path, moving or renaming the repo folder means logging in
66
+ again (which adds a new entry). `asrt logout` removes the current repo's entry;
67
+ `asrt whoami` shows which token is in effect.
68
+
69
+ Token resolution at call time, in order:
70
+
71
+ 1. the entry for the current repo path in `~/.assertion/credentials.json`;
72
+ 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
73
+ with no remote that `asrt login` won't store);
74
+
75
+ with a clear "run `asrt login`" error when neither is present. The API base URL
76
+ defaults to `https://app.tryassertion.com`; override it with `ASSERTION_BASE_URL`
77
+ for a local backend.
78
+
53
79
  ## Publishing a new version
54
80
 
55
81
  1. **Bump the version** in `cli/pyproject.toml`.
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  api.py
3
3
  bundle.py
4
+ credentials.py
4
5
  git.py
5
6
  link.py
6
7
  main.py
@@ -17,7 +18,9 @@ templates/AGENTS.md
17
18
  templates/SKILL.md
18
19
  templates/__init__.py
19
20
  tests/test_api.py
21
+ tests/test_auth.py
20
22
  tests/test_bundle.py
23
+ tests/test_credentials.py
21
24
  tests/test_decision.py
22
25
  tests/test_git.py
23
26
  tests/test_install.py
@@ -1,6 +1,5 @@
1
1
  httpx>=0.28.1
2
2
  pydantic>=2.12.5
3
- python-dotenv>=1.0
4
3
  typer>=0.24.1
5
4
 
6
5
  [test]
@@ -1,6 +1,7 @@
1
1
  api
2
2
  assertion_cli_templates
3
3
  bundle
4
+ credentials
4
5
  git
5
6
  link
6
7
  main
@@ -0,0 +1,120 @@
1
+ """Global, user-level token store at ``~/.assertion/credentials.json``.
2
+
3
+ The store is a map of canonicalized git-root path -> API token, so each repo
4
+ resolves to its own workspace token:
5
+
6
+ {"version": 1, "repos": {"/abs/path/to/repo": "alk_..."}}
7
+
8
+ This module is deliberately git-free and pure stdlib — callers pass in the repo
9
+ root (resolved via ``git.py``) and handle their own error reporting. Token
10
+ resolution precedence (repo entry first, then the env-var backup) lives in
11
+ ``api.py``; this module only owns on-disk reads and writes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+
20
+ API_TOKEN_ENV = "ASSERTION_API_TOKEN"
21
+
22
+ CREDENTIALS_FILE_NAME = "credentials.json"
23
+ CREDENTIALS_VERSION = 1
24
+
25
+
26
+ def assertion_home() -> Path:
27
+ """Directory holding global Assertion credentials (``~/.assertion``)."""
28
+ return Path.home() / ".assertion"
29
+
30
+
31
+ def credentials_path() -> Path:
32
+ return assertion_home() / CREDENTIALS_FILE_NAME
33
+
34
+
35
+ def _empty_store() -> dict:
36
+ return {"version": CREDENTIALS_VERSION, "repos": {}}
37
+
38
+
39
+ def load_store() -> dict:
40
+ """Return the credentials store, tolerating a missing or corrupt file.
41
+
42
+ A malformed file is treated as empty rather than crashing every command —
43
+ the user can re-run ``asrt login`` to repair it.
44
+ """
45
+ path = credentials_path()
46
+ if not path.exists():
47
+ return _empty_store()
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ except (json.JSONDecodeError, OSError, ValueError):
51
+ return _empty_store()
52
+ if not isinstance(data, dict):
53
+ return _empty_store()
54
+ data.setdefault("version", CREDENTIALS_VERSION)
55
+ if not isinstance(data.get("repos"), dict):
56
+ data["repos"] = {}
57
+ return data
58
+
59
+
60
+ def _repo_key(repo_root: Path) -> str:
61
+ """Canonical store key for a repo root (absolute, symlinks resolved)."""
62
+ return str(repo_root.resolve())
63
+
64
+
65
+ def _write_store(store: dict) -> Path:
66
+ home = assertion_home()
67
+ home.mkdir(parents=True, exist_ok=True)
68
+ try:
69
+ home.chmod(0o700)
70
+ except OSError:
71
+ pass
72
+ path = credentials_path()
73
+ path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
74
+ try:
75
+ path.chmod(0o600)
76
+ except OSError:
77
+ pass
78
+ return path
79
+
80
+
81
+ def save_token(token: str, *, repo_root: Path) -> Path:
82
+ """Persist ``token`` for ``repo_root`` and return the credentials file path."""
83
+ store = load_store()
84
+ store["repos"][_repo_key(repo_root)] = token
85
+ return _write_store(store)
86
+
87
+
88
+ def remove_token(*, repo_root: Path) -> bool:
89
+ """Delete the entry for ``repo_root``. Return ``True`` if one was removed."""
90
+ store = load_store()
91
+ key = _repo_key(repo_root)
92
+ if key in store["repos"]:
93
+ del store["repos"][key]
94
+ _write_store(store)
95
+ return True
96
+ return False
97
+
98
+
99
+ def resolve_token(repo_root: Path | None) -> str | None:
100
+ """Return the stored token for ``repo_root`` (exact path match), else ``None``.
101
+
102
+ No fuzzy or parent-path matching: a moved or never-authenticated repo simply
103
+ misses, and the caller falls back to the env-var backup.
104
+ """
105
+ if repo_root is None:
106
+ return None
107
+ token = load_store()["repos"].get(_repo_key(repo_root))
108
+ return token or None
109
+
110
+
111
+ def describe_source(repo_root: Path | None) -> str | None:
112
+ """Describe where the active token comes from, in resolution order.
113
+
114
+ Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
115
+ """
116
+ if repo_root is not None and resolve_token(repo_root) is not None:
117
+ return f"the saved token for {_repo_key(repo_root)}"
118
+ if (os.environ.get(API_TOKEN_ENV) or "").strip():
119
+ return f"the {API_TOKEN_ENV} environment variable"
120
+ return None
@@ -26,7 +26,12 @@ def run_git_command(
26
26
  return completed.stdout if preserve_stdout else completed.stdout.strip()
27
27
 
28
28
 
29
- def find_git_root(start_path: Path) -> Path:
29
+ def find_git_root_or_none(start_path: Path) -> Path | None:
30
+ """Return the repo root for *start_path*, or ``None`` if it isn't in a repo.
31
+
32
+ Token resolution must degrade gracefully (to the env-var backup) outside a
33
+ git repo, so it needs a non-fatal variant of ``find_git_root``.
34
+ """
30
35
  completed = subprocess.run(
31
36
  ["git", "rev-parse", "--show-toplevel"],
32
37
  cwd=start_path,
@@ -35,10 +40,61 @@ def find_git_root(start_path: Path) -> Path:
35
40
  check=False,
36
41
  )
37
42
  if completed.returncode != 0:
38
- exit_with_error("Current directory is not inside a git repository.")
43
+ return None
39
44
  return Path(completed.stdout.strip())
40
45
 
41
46
 
47
+ def find_git_root(start_path: Path) -> Path:
48
+ root = find_git_root_or_none(start_path)
49
+ if root is None:
50
+ exit_with_error("Current directory is not inside a git repository.")
51
+ return root
52
+
53
+
54
+ def _parse_github_full_name(url: str) -> str | None:
55
+ """Reduce a git remote URL to its GitHub ``owner/repo`` full name.
56
+
57
+ Handles the three common origin forms and returns ``None`` when the URL has
58
+ no ``owner/repo`` tail:
59
+
60
+ git@github.com:acme/mvp.git -> acme/mvp
61
+ https://github.com/acme/mvp.git -> acme/mvp
62
+ ssh://git@github.com/acme/mvp.git -> acme/mvp
63
+ """
64
+ text = url.strip()
65
+ if not text:
66
+ return None
67
+
68
+ if "://" in text:
69
+ # scheme://[user@]host/owner/repo — drop scheme + host.
70
+ path = text.split("://", 1)[1]
71
+ path = path.split("/", 1)[1] if "/" in path else ""
72
+ elif ":" in text and ("/" not in text or text.index(":") < text.index("/")):
73
+ # scp-like: [user@]host:owner/repo (colon precedes any path slash).
74
+ path = text.split(":", 1)[1]
75
+ else:
76
+ path = text
77
+
78
+ path = path.removesuffix(".git").strip("/")
79
+ parts = [p for p in path.split("/") if p]
80
+ if len(parts) < 2:
81
+ return None
82
+ return f"{parts[-2]}/{parts[-1]}"
83
+
84
+
85
+ def current_repo_full_name(repo_root: Path) -> str | None:
86
+ """Return the repo's GitHub ``owner/repo``, or ``None`` if it has no origin.
87
+
88
+ Used by ``asrt login`` to match the current checkout against the workspace's
89
+ stacks (whose ``repo`` field is the GitHub full name).
90
+ """
91
+ try:
92
+ url = run_git_command(repo_root, ["remote", "get-url", "origin"])
93
+ except RuntimeError:
94
+ return None
95
+ return _parse_github_full_name(url)
96
+
97
+
42
98
  def get_head_sha(repo_root: Path) -> str:
43
99
  try:
44
100
  return run_git_command(repo_root, ["rev-parse", "HEAD"])
@@ -59,7 +115,8 @@ def get_upstream_ref(repo_root: Path) -> str | None:
59
115
  # 1. Configured upstream (git branch -u).
60
116
  try:
61
117
  return run_git_command(
62
- repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]
118
+ repo_root,
119
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
63
120
  )
64
121
  except RuntimeError:
65
122
  pass
@@ -83,15 +140,16 @@ def get_upstream_ref(repo_root: Path) -> str | None:
83
140
  merged = run_git_command(
84
141
  repo_root,
85
142
  [
86
- "branch", "-r", "--merged", "HEAD",
143
+ "branch",
144
+ "-r",
145
+ "--merged",
146
+ "HEAD",
87
147
  "--format=%(refname:short)",
88
148
  ],
89
149
  )
90
150
  ancestors = [r for r in merged.splitlines() if r]
91
151
  if ancestors:
92
- return run_git_command(
93
- repo_root, ["merge-base", "HEAD", *ancestors]
94
- )
152
+ return run_git_command(repo_root, ["merge-base", "HEAD", *ancestors])
95
153
  except RuntimeError:
96
154
  pass
97
155