superpowers-manager 0.1.6 → 0.2.0

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.
@@ -36,6 +36,10 @@ spw_generated_commit_or_empty() (
36
36
  spw_metadata_commit_lenient_or_empty "$generated_metadata"
37
37
  )
38
38
 
39
+ spw_probe_field() {
40
+ printf '%s\n' "$1" | awk -F= -v key="$2" '$1 == key { print $2 }'
41
+ }
42
+
39
43
  spw_require_no_legacy_state() {
40
44
  identity_state="$1"
41
45
  case "$identity_state" in
@@ -55,6 +59,16 @@ spw_require_no_legacy_state() {
55
59
  esac
56
60
  }
57
61
 
62
+ spw_require_managed_update_control() {
63
+ case "$1" in
64
+ managed) return 0 ;;
65
+ unsupported)
66
+ spw_die "adapter cannot guarantee manager-controlled updates"
67
+ ;;
68
+ *) spw_die "unknown adapter update-control capability: $1" ;;
69
+ esac
70
+ }
71
+
58
72
  spw_report_legacy_state() {
59
73
  case "$1" in
60
74
  legacy|both)
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import errno
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import secrets
11
+ import stat
12
+ import sys
13
+ from urllib.parse import urlsplit
14
+
15
+
16
+ PINNED_KEYS = {
17
+ "schema_version", "mode", "source", "requested_ref", "resolved_ref", "commit"
18
+ }
19
+ TRACK_LATEST_KEYS = {"schema_version", "mode", "source"}
20
+ TAG_RE = re.compile(
21
+ r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
22
+ r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
23
+ r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?\Z"
24
+ )
25
+ COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z")
26
+ COMMIT_INPUT_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
27
+ MAX_JSON_NESTING = 256
28
+
29
+
30
+ class SelectionError(Exception):
31
+ """A controlled selection-state validation or persistence failure."""
32
+
33
+
34
+ def require_object(raw: object, label: str) -> dict[str, object]:
35
+ if not isinstance(raw, dict):
36
+ raise SelectionError(f"{label} must be a JSON object")
37
+ return raw
38
+
39
+
40
+ def require_exact_keys(record: dict[str, object], expected: set[str]) -> None:
41
+ actual = set(record)
42
+ if actual != expected:
43
+ missing = sorted(expected - actual)
44
+ unknown = sorted(actual - expected)
45
+ details = []
46
+ if missing:
47
+ details.append("missing " + ", ".join(missing))
48
+ if unknown:
49
+ details.append("unknown " + ", ".join(unknown))
50
+ raise SelectionError("selection state keys are invalid: " + "; ".join(details))
51
+
52
+
53
+ def require_single_line_string(value: object, label: str) -> str:
54
+ if not isinstance(value, str) or not value or any(char in value for char in "\r\n\0"):
55
+ raise SelectionError(f"{label} must be a non-empty single-line string")
56
+ return value
57
+
58
+
59
+ def validate_source(raw: object) -> str:
60
+ source = require_single_line_string(raw, "source")
61
+ try:
62
+ parsed = urlsplit(source)
63
+ except (UnicodeError, ValueError) as exc:
64
+ raise SelectionError("source URL is malformed") from exc
65
+ if parsed.scheme.lower() in ("http", "https") and parsed.username is not None:
66
+ raise SelectionError("HTTP(S) source must not include userinfo")
67
+ return source
68
+
69
+
70
+ def validate_pinned_record(
71
+ record: dict[str, object], source: str
72
+ ) -> dict[str, object]:
73
+ requested_ref = require_single_line_string(record["requested_ref"], "requested_ref")
74
+ resolved_ref = require_single_line_string(record["resolved_ref"], "resolved_ref")
75
+ commit = require_single_line_string(record["commit"], "commit")
76
+ if not COMMIT_RE.fullmatch(commit):
77
+ raise SelectionError("commit must be a lowercase 40-hex value")
78
+ if TAG_RE.fullmatch(requested_ref):
79
+ if resolved_ref != requested_ref:
80
+ raise SelectionError("tag resolved_ref must equal requested_ref")
81
+ elif COMMIT_RE.fullmatch(requested_ref):
82
+ if resolved_ref != requested_ref or commit != requested_ref:
83
+ raise SelectionError(
84
+ "raw commit requested_ref, resolved_ref, and commit must be equal"
85
+ )
86
+ else:
87
+ raise SelectionError("requested_ref must be an exact tag or full commit")
88
+ return {
89
+ "schema_version": 1,
90
+ "mode": "pinned",
91
+ "source": source,
92
+ "requested_ref": requested_ref,
93
+ "resolved_ref": resolved_ref,
94
+ "commit": commit,
95
+ }
96
+
97
+
98
+ def validate_record(raw: object) -> dict[str, object]:
99
+ record = require_object(raw, "selection state")
100
+ version = record.get("schema_version")
101
+ if type(version) is not int or version != 1:
102
+ raise SelectionError("schema_version must equal integer 1")
103
+ mode = record.get("mode")
104
+ expected = (
105
+ PINNED_KEYS if mode == "pinned"
106
+ else TRACK_LATEST_KEYS if mode == "track-latest"
107
+ else None
108
+ )
109
+ if expected is None:
110
+ raise SelectionError("mode must be pinned or track-latest")
111
+ require_exact_keys(record, expected)
112
+ source = validate_source(record["source"])
113
+ if mode == "track-latest":
114
+ return {"schema_version": 1, "mode": mode, "source": source}
115
+ return validate_pinned_record(record, source)
116
+
117
+
118
+ def normalize(record: dict[str, object] | None) -> dict[str, str]:
119
+ if record is None:
120
+ return {
121
+ "saved_mode": "none",
122
+ "saved_source": "",
123
+ "saved_requested_ref": "",
124
+ "saved_resolved_ref": "",
125
+ "saved_commit": "",
126
+ }
127
+ return {
128
+ "saved_mode": str(record["mode"]),
129
+ "saved_source": str(record["source"]),
130
+ "saved_requested_ref": str(record.get("requested_ref", "")),
131
+ "saved_resolved_ref": str(record.get("resolved_ref", "")),
132
+ "saved_commit": str(record.get("commit", "")),
133
+ }
134
+
135
+
136
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
137
+ result: dict[str, object] = {}
138
+ for key, value in pairs:
139
+ if key in result:
140
+ raise SelectionError(f"duplicate JSON key: {key}")
141
+ result[key] = value
142
+ return result
143
+
144
+
145
+ def reject_constant(constant: str) -> None:
146
+ raise SelectionError(f"non-standard numeric constant: {constant}")
147
+
148
+
149
+ def nesting_exceeds_limit(value: object) -> bool:
150
+ stack = [(value, 0)]
151
+ while stack:
152
+ current, depth = stack.pop()
153
+ if isinstance(current, dict):
154
+ next_depth = depth + 1
155
+ if next_depth > MAX_JSON_NESTING:
156
+ return True
157
+ stack.extend((child, next_depth) for child in current.values())
158
+ elif isinstance(current, list):
159
+ next_depth = depth + 1
160
+ if next_depth > MAX_JSON_NESTING:
161
+ return True
162
+ stack.extend((child, next_depth) for child in current)
163
+ return False
164
+
165
+
166
+ def describe_path_type(path: Path, label: str) -> os.stat_result | None:
167
+ try:
168
+ info = path.lstat()
169
+ except FileNotFoundError:
170
+ return None
171
+ except OSError as exc:
172
+ raise SelectionError(f"cannot inspect {label} {path}: {exc}") from exc
173
+ if stat.S_ISLNK(info.st_mode):
174
+ raise SelectionError(f"{label} must not be a symlink: {path}")
175
+ return info
176
+
177
+
178
+ def load_json(path: Path) -> object:
179
+ flags = os.O_RDONLY
180
+ if hasattr(os, "O_NOFOLLOW"):
181
+ flags |= os.O_NOFOLLOW
182
+ try:
183
+ fd = os.open(path, flags)
184
+ except OSError as exc:
185
+ raise SelectionError(f"cannot read selection state {path}: {exc}") from exc
186
+ try:
187
+ info = os.fstat(fd)
188
+ if not stat.S_ISREG(info.st_mode):
189
+ raise SelectionError(f"selection state must be a regular file: {path}")
190
+ with os.fdopen(fd, "r", encoding="utf-8") as handle:
191
+ fd = -1
192
+ raw = json.load(
193
+ handle,
194
+ object_pairs_hook=reject_duplicate_keys,
195
+ parse_constant=reject_constant,
196
+ )
197
+ except RecursionError as exc:
198
+ raise SelectionError(f"JSON nesting exceeds limit in {path}") from exc
199
+ except json.JSONDecodeError as exc:
200
+ raise SelectionError(
201
+ f"invalid JSON in {path}: line {exc.lineno} column {exc.colno}: {exc.msg}"
202
+ ) from exc
203
+ except ValueError as exc:
204
+ raise SelectionError(f"invalid JSON in {path}: {exc}") from exc
205
+ except (OSError, UnicodeError) as exc:
206
+ raise SelectionError(f"cannot read selection state {path}: {exc}") from exc
207
+ finally:
208
+ if fd >= 0:
209
+ os.close(fd)
210
+ if nesting_exceeds_limit(raw):
211
+ raise SelectionError(f"JSON nesting exceeds limit in {path}")
212
+ return raw
213
+
214
+
215
+ def load_record(path: Path) -> dict[str, object] | None:
216
+ parent_info = describe_path_type(path.parent, "selection state directory")
217
+ if parent_info is not None and not stat.S_ISDIR(parent_info.st_mode):
218
+ raise SelectionError(
219
+ f"selection state directory must be a directory: {path.parent}"
220
+ )
221
+ info = describe_path_type(path, "selection state")
222
+ if info is None:
223
+ return None
224
+ if not stat.S_ISREG(info.st_mode):
225
+ raise SelectionError(f"selection state must be a regular file: {path}")
226
+ return validate_record(load_json(path))
227
+
228
+
229
+ def write_json_output(path: Path, value: object) -> None:
230
+ try:
231
+ with path.open("w", encoding="utf-8") as handle:
232
+ json.dump(value, handle, indent=2, allow_nan=False)
233
+ handle.write("\n")
234
+ except (OSError, UnicodeError, ValueError) as exc:
235
+ raise SelectionError(f"cannot write normalized selection output {path}: {exc}") from exc
236
+
237
+
238
+ def ensure_state_directory(path: Path) -> None:
239
+ info = describe_path_type(path, "selection state directory")
240
+ if info is not None:
241
+ if not stat.S_ISDIR(info.st_mode):
242
+ raise SelectionError(f"selection state directory must be a directory: {path}")
243
+ return
244
+ old_umask = os.umask(0o077)
245
+ try:
246
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
247
+ except OSError as exc:
248
+ raise SelectionError(f"cannot create selection state directory {path}: {exc}") from exc
249
+ finally:
250
+ os.umask(old_umask)
251
+ info = describe_path_type(path, "selection state directory")
252
+ if info is None or not stat.S_ISDIR(info.st_mode):
253
+ raise SelectionError(f"selection state directory is not usable: {path}")
254
+
255
+
256
+ def create_temp_file(path: Path) -> tuple[int, Path]:
257
+ for _ in range(100):
258
+ candidate = path.parent / f".{path.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
259
+ try:
260
+ fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
261
+ os.fchmod(fd, 0o600)
262
+ return fd, candidate
263
+ except FileExistsError:
264
+ continue
265
+ except OSError as exc:
266
+ raise SelectionError(f"cannot create selection state temporary file: {exc}") from exc
267
+ raise SelectionError("cannot create unique selection state temporary file")
268
+
269
+
270
+ def fsync_directory_best_effort(directory: Path) -> None:
271
+ flags = os.O_RDONLY
272
+ if hasattr(os, "O_DIRECTORY"):
273
+ flags |= os.O_DIRECTORY
274
+ try:
275
+ fd = os.open(directory, flags)
276
+ except OSError:
277
+ return
278
+ try:
279
+ try:
280
+ os.fsync(fd)
281
+ except OSError as exc:
282
+ if exc.errno not in (errno.EINVAL, errno.ENOTSUP, errno.EBADF):
283
+ return
284
+ finally:
285
+ os.close(fd)
286
+
287
+
288
+ def final_state_diagnostic(path: Path) -> str:
289
+ try:
290
+ record = load_record(path)
291
+ except SelectionError as exc:
292
+ return f"final selection state cannot be validated: {exc}"
293
+ if record is None:
294
+ return "selection state is now absent"
295
+ return f"selection state is now {record['mode']}"
296
+
297
+
298
+ def write_record(path: Path, proposed: dict[str, object]) -> None:
299
+ ensure_state_directory(path.parent)
300
+ load_record(path)
301
+ record = validate_record(proposed)
302
+ fd = -1
303
+ temporary: Path | None = None
304
+ replacement_started = False
305
+ try:
306
+ fd, temporary = create_temp_file(path)
307
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
308
+ fd = -1
309
+ json.dump(record, handle, indent=2, allow_nan=False)
310
+ handle.write("\n")
311
+ handle.flush()
312
+ os.fsync(handle.fileno())
313
+ validate_record(load_json(temporary))
314
+ replacement_started = True
315
+ os.replace(temporary, path)
316
+ temporary = None
317
+ fsync_directory_best_effort(path.parent)
318
+ except SelectionError:
319
+ raise
320
+ except (OSError, UnicodeError, ValueError) as exc:
321
+ if replacement_started:
322
+ raise SelectionError(
323
+ f"cannot complete selection state write: {exc}; {final_state_diagnostic(path)}"
324
+ ) from exc
325
+ raise SelectionError(f"cannot write selection state: {exc}") from exc
326
+ finally:
327
+ if fd >= 0:
328
+ os.close(fd)
329
+ if temporary is not None:
330
+ try:
331
+ temporary.unlink()
332
+ except FileNotFoundError:
333
+ pass
334
+ except OSError:
335
+ pass
336
+
337
+
338
+ def normalized_pinned_arguments(arguments: argparse.Namespace) -> dict[str, object]:
339
+ requested_ref = arguments.requested_ref
340
+ resolved_ref = arguments.resolved_ref
341
+ commit = arguments.commit.lower()
342
+ if COMMIT_INPUT_RE.fullmatch(requested_ref):
343
+ requested_ref = requested_ref.lower()
344
+ if COMMIT_INPUT_RE.fullmatch(resolved_ref):
345
+ resolved_ref = resolved_ref.lower()
346
+ return {
347
+ "schema_version": 1,
348
+ "mode": "pinned",
349
+ "source": arguments.source,
350
+ "requested_ref": requested_ref,
351
+ "resolved_ref": resolved_ref,
352
+ "commit": commit,
353
+ }
354
+
355
+
356
+ def parser() -> argparse.ArgumentParser:
357
+ result = argparse.ArgumentParser(description="Strict persistent selection-state helper")
358
+ subparsers = result.add_subparsers(dest="command", required=True)
359
+
360
+ read = subparsers.add_parser("read")
361
+ read.add_argument("--path", required=True)
362
+ read.add_argument("--output", required=True)
363
+
364
+ pinned = subparsers.add_parser("write-pinned")
365
+ pinned.add_argument("--path", required=True)
366
+ pinned.add_argument("--source", required=True)
367
+ pinned.add_argument("--requested-ref", required=True)
368
+ pinned.add_argument("--resolved-ref", required=True)
369
+ pinned.add_argument("--commit", required=True)
370
+
371
+ latest = subparsers.add_parser("write-track-latest")
372
+ latest.add_argument("--path", required=True)
373
+ latest.add_argument("--source", required=True)
374
+
375
+ validate = subparsers.add_parser("validate-source")
376
+ validate.add_argument("--source", required=True)
377
+
378
+ display = subparsers.add_parser("display-source")
379
+ display.add_argument("--source", required=True)
380
+ return result
381
+
382
+
383
+ def dispatch(arguments: argparse.Namespace) -> None:
384
+ if arguments.command == "read":
385
+ write_json_output(Path(arguments.output), normalize(load_record(Path(arguments.path))))
386
+ elif arguments.command == "write-pinned":
387
+ write_record(Path(arguments.path), normalized_pinned_arguments(arguments))
388
+ elif arguments.command == "write-track-latest":
389
+ write_record(Path(arguments.path), {
390
+ "schema_version": 1,
391
+ "mode": "track-latest",
392
+ "source": arguments.source,
393
+ })
394
+ elif arguments.command == "validate-source":
395
+ validate_source(arguments.source)
396
+ elif arguments.command == "display-source":
397
+ try:
398
+ print(validate_source(arguments.source))
399
+ except SelectionError:
400
+ print("<redacted-source>")
401
+
402
+
403
+ def main() -> int:
404
+ arguments = parser().parse_args()
405
+ try:
406
+ dispatch(arguments)
407
+ except SelectionError as exc:
408
+ print(f"error: {exc}", file=sys.stderr)
409
+ return 1
410
+ return 0
411
+
412
+
413
+ if __name__ == "__main__":
414
+ raise SystemExit(main())
@@ -0,0 +1,168 @@
1
+ #!/bin/sh
2
+ # Sourced module; callers own set -eu.
3
+
4
+ spw_selection_config_dir() {
5
+ if [ "${SUPERPOWERS_CONFIG_DIR+x}" = x ]; then
6
+ case "$SUPERPOWERS_CONFIG_DIR" in
7
+ /*) printf '%s\n' "$SUPERPOWERS_CONFIG_DIR" ;;
8
+ *) echo "error: SUPERPOWERS_CONFIG_DIR must be absolute" >&2; return 1 ;;
9
+ esac
10
+ return
11
+ fi
12
+
13
+ if [ -n "${XDG_CONFIG_HOME:-}" ]; then
14
+ case "$XDG_CONFIG_HOME" in
15
+ /*) printf '%s/superpowers-manager\n' "$XDG_CONFIG_HOME" ;;
16
+ *) echo "error: XDG_CONFIG_HOME must be absolute" >&2; return 1 ;;
17
+ esac
18
+ return
19
+ fi
20
+
21
+ if [ -z "${HOME:-}" ]; then
22
+ echo "error: HOME is required to locate selection state" >&2
23
+ return 1
24
+ fi
25
+ case "$HOME" in
26
+ /*) printf '%s/.config/superpowers-manager\n' "$HOME" ;;
27
+ *) echo "error: HOME must be absolute" >&2; return 1 ;;
28
+ esac
29
+ }
30
+
31
+ spw_selection_state_path() {
32
+ _spw_selection_config_dir=$(spw_selection_config_dir) || return $?
33
+ printf '%s/selection.json\n' "$_spw_selection_config_dir"
34
+ }
35
+
36
+ spw_selection_state() {
37
+ _spw_selection_state_root="$1"
38
+ shift
39
+ if _spw_selection_state_error=$(
40
+ python3 -S "$_spw_selection_state_root/scripts/core/selection-state.py" \
41
+ "$@" 2>&1
42
+ ); then
43
+ :
44
+ else
45
+ _spw_selection_state_error=${_spw_selection_state_error#error: }
46
+ spw_die "$_spw_selection_state_error"
47
+ fi
48
+ }
49
+
50
+ spw_load_saved_selection() {
51
+ _spw_selection_root="$1"
52
+ _spw_selection_workspace="$2"
53
+ SPW_SELECTION_STATE_PATH=$(spw_selection_state_path)
54
+ if ! _spw_selection_normalized=$(mktemp "$_spw_selection_workspace/selection-state.XXXXXX"); then
55
+ spw_die "cannot create normalized selection scratch file"
56
+ fi
57
+
58
+ if _spw_selection_error=$(
59
+ python3 -S "$_spw_selection_root/scripts/core/selection-state.py" read \
60
+ --path "$SPW_SELECTION_STATE_PATH" \
61
+ --output "$_spw_selection_normalized" 2>&1
62
+ ); then
63
+ :
64
+ else
65
+ rm -f "$_spw_selection_normalized"
66
+ _spw_selection_error=${_spw_selection_error#error: }
67
+ spw_die "$_spw_selection_error"
68
+ fi
69
+
70
+ SPW_SAVED_MODE=$(spw_json_get "$_spw_selection_normalized" saved_mode)
71
+ SPW_SAVED_SOURCE=$(spw_json_get "$_spw_selection_normalized" saved_source)
72
+ SPW_SAVED_REQUESTED_REF=$(spw_json_get "$_spw_selection_normalized" saved_requested_ref)
73
+ SPW_SAVED_RESOLVED_REF=$(spw_json_get "$_spw_selection_normalized" saved_resolved_ref)
74
+ SPW_SAVED_COMMIT=$(spw_json_get "$_spw_selection_normalized" saved_commit)
75
+ rm -f "$_spw_selection_normalized"
76
+
77
+ export SPW_SELECTION_STATE_PATH
78
+ export SPW_SAVED_MODE SPW_SAVED_SOURCE SPW_SAVED_REQUESTED_REF
79
+ export SPW_SAVED_RESOLVED_REF SPW_SAVED_COMMIT
80
+ }
81
+
82
+ spw_compute_effective_selection() {
83
+ _spw_selection_root="$1"
84
+ _spw_selection_workspace="$2"
85
+ spw_load_saved_selection "$_spw_selection_root" "$_spw_selection_workspace"
86
+
87
+ if [ -n "${SUPERPOWERS_UPSTREAM_URL:-}" ]; then
88
+ SPW_UPSTREAM_SOURCE_ORIGIN="environment"
89
+ SPW_EFFECTIVE_SOURCE="$SUPERPOWERS_UPSTREAM_URL"
90
+ elif [ "$SPW_SAVED_MODE" != "none" ]; then
91
+ SPW_UPSTREAM_SOURCE_ORIGIN="user-config"
92
+ SPW_EFFECTIVE_SOURCE="$SPW_SAVED_SOURCE"
93
+ else
94
+ SPW_UPSTREAM_SOURCE_ORIGIN="package-default"
95
+ SPW_EFFECTIVE_SOURCE="$SPW_UPSTREAM_URL_DEFAULT"
96
+ fi
97
+
98
+ spw_selection_state "$_spw_selection_root" \
99
+ validate-source --source="$SPW_EFFECTIVE_SOURCE"
100
+
101
+ _spw_selection_uses_saved_pin=false
102
+ if [ -n "${SUPERPOWERS_REF:-}" ]; then
103
+ SPW_SELECTION_ORIGIN="environment"
104
+ SPW_SELECTION_MODE="override"
105
+ SPW_REQUESTED_REF="$SUPERPOWERS_REF"
106
+ elif [ "$SPW_SAVED_MODE" = "pinned" ]; then
107
+ SPW_SELECTION_ORIGIN="user-config"
108
+ SPW_SELECTION_MODE="pinned"
109
+ SPW_REQUESTED_REF="$SPW_SAVED_REQUESTED_REF"
110
+ _spw_selection_uses_saved_pin=true
111
+ elif [ "$SPW_SAVED_MODE" = "track-latest" ]; then
112
+ SPW_SELECTION_ORIGIN="user-config"
113
+ SPW_SELECTION_MODE="track-latest"
114
+ SPW_REQUESTED_REF="latest-release"
115
+ else
116
+ SPW_SELECTION_ORIGIN="package-default"
117
+ SPW_SELECTION_MODE="default"
118
+ SPW_REQUESTED_REF=$(spw_config_ref "$_spw_selection_root")
119
+ fi
120
+
121
+ if [ "$_spw_selection_uses_saved_pin" = true ]; then
122
+ SPW_RESOLVED_REF="$SPW_SAVED_RESOLVED_REF"
123
+ SPW_DESIRED_COMMIT="$SPW_SAVED_COMMIT"
124
+ if printf '%s' "$SPW_SAVED_REQUESTED_REF" | grep -Eq '^[0-9a-f]{40}$'; then
125
+ SPW_RESOLUTION_KIND="raw-commit"
126
+ else
127
+ SPW_RESOLUTION_KIND="tag"
128
+ fi
129
+ else
130
+ _spw_selection_resolution=$(
131
+ spw_resolve_ref "$SPW_EFFECTIVE_SOURCE" "$SPW_REQUESTED_REF"
132
+ )
133
+ if _spw_selection_fields=$(
134
+ printf '%s\n' "$_spw_selection_resolution" | awk '
135
+ NR == 1 && NF == 3 { printf "%s\t%s\t%s\n", $1, $2, $3; next }
136
+ { exit 1 }
137
+ END { if (NR != 1) exit 1 }
138
+ '
139
+ ); then
140
+ :
141
+ else
142
+ spw_die "invalid ref resolution result"
143
+ fi
144
+ _spw_selection_tab=$(printf '\tX')
145
+ _spw_selection_tab=${_spw_selection_tab%X}
146
+ IFS=$_spw_selection_tab read -r \
147
+ SPW_RESOLUTION_KIND SPW_RESOLVED_REF SPW_DESIRED_COMMIT <<EOF
148
+ $_spw_selection_fields
149
+ EOF
150
+ fi
151
+
152
+ export SPW_SELECTION_ORIGIN SPW_SELECTION_MODE
153
+ export SPW_UPSTREAM_SOURCE_ORIGIN SPW_EFFECTIVE_SOURCE
154
+ export SPW_REQUESTED_REF SPW_RESOLVED_REF SPW_DESIRED_COMMIT
155
+ export SPW_RESOLUTION_KIND
156
+ }
157
+
158
+ spw_display_source() {
159
+ _spw_selection_display_source="$1"
160
+ if _spw_selection_display=$(
161
+ python3 -S "$(spw_root)/scripts/core/selection-state.py" \
162
+ display-source --source="$_spw_selection_display_source" 2>/dev/null
163
+ ); then
164
+ printf '%s\n' "$_spw_selection_display"
165
+ else
166
+ printf '%s\n' '<redacted-source>'
167
+ fi
168
+ }