diffly-cli 0.4.0__py3-none-any.whl
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.
- diffly_cli/__init__.py +3 -0
- diffly_cli/__main__.py +6 -0
- diffly_cli/astmap.py +121 -0
- diffly_cli/cli.py +770 -0
- diffly_cli/diffparse.py +117 -0
- diffly_cli/explainer.py +227 -0
- diffly_cli/github.py +181 -0
- diffly_cli/local.py +151 -0
- diffly_cli/models.py +68 -0
- diffly_cli/redact.py +59 -0
- diffly_cli/triage.py +142 -0
- diffly_cli/update.py +161 -0
- diffly_cli-0.4.0.dist-info/METADATA +341 -0
- diffly_cli-0.4.0.dist-info/RECORD +17 -0
- diffly_cli-0.4.0.dist-info/WHEEL +4 -0
- diffly_cli-0.4.0.dist-info/entry_points.txt +3 -0
- diffly_cli-0.4.0.dist-info/licenses/LICENSE +107 -0
diffly_cli/models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class PRMetadata:
|
|
9
|
+
owner: str
|
|
10
|
+
repo: str
|
|
11
|
+
number: int
|
|
12
|
+
title: str
|
|
13
|
+
body: str
|
|
14
|
+
state: str
|
|
15
|
+
author: str
|
|
16
|
+
base_ref: str
|
|
17
|
+
head_ref: str
|
|
18
|
+
base_sha: str
|
|
19
|
+
head_sha: str
|
|
20
|
+
mergeable_state: str
|
|
21
|
+
additions: int
|
|
22
|
+
deletions: int
|
|
23
|
+
changed_files: int
|
|
24
|
+
commits: int
|
|
25
|
+
html_url: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Hunk:
|
|
30
|
+
header: str
|
|
31
|
+
old_start: int
|
|
32
|
+
old_count: int
|
|
33
|
+
new_start: int
|
|
34
|
+
new_count: int
|
|
35
|
+
lines: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ChangedFile:
|
|
40
|
+
path: str
|
|
41
|
+
status: str
|
|
42
|
+
additions: int
|
|
43
|
+
deletions: int
|
|
44
|
+
changes: int
|
|
45
|
+
patch: str = ""
|
|
46
|
+
hunks: list[Hunk] = field(default_factory=list)
|
|
47
|
+
touched_symbols: list[str] = field(default_factory=list)
|
|
48
|
+
callers: list[str] = field(default_factory=list)
|
|
49
|
+
tests_found: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RiskFlag:
|
|
54
|
+
code: str
|
|
55
|
+
severity: str
|
|
56
|
+
message: str
|
|
57
|
+
evidence: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class TriageResult:
|
|
62
|
+
metadata: PRMetadata
|
|
63
|
+
files: list[ChangedFile]
|
|
64
|
+
flags: list[RiskFlag]
|
|
65
|
+
verdict: str
|
|
66
|
+
reasoning: list[str]
|
|
67
|
+
checks: dict[str, Any]
|
|
68
|
+
source: str
|
diffly_cli/redact.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RedactionResult:
|
|
9
|
+
text: str
|
|
10
|
+
count: int
|
|
11
|
+
labels: tuple[str, ...]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_PATTERNS: tuple[tuple[str, re.Pattern[str], str], ...] = (
|
|
15
|
+
(
|
|
16
|
+
"private_key",
|
|
17
|
+
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", re.DOTALL),
|
|
18
|
+
"[REDACTED_PRIVATE_KEY]",
|
|
19
|
+
),
|
|
20
|
+
(
|
|
21
|
+
"github_token",
|
|
22
|
+
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_\-]{20,}|github_pat_[A-Za-z0-9_\-]{20,})\b"),
|
|
23
|
+
"[REDACTED_GITHUB_TOKEN]",
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
"aws_access_key",
|
|
27
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
28
|
+
"[REDACTED_AWS_ACCESS_KEY]",
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"bearer_token",
|
|
32
|
+
re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9_\-.=+/]{16,}"),
|
|
33
|
+
r"\1[REDACTED_BEARER_TOKEN]",
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
"secret_assignment",
|
|
37
|
+
re.compile(
|
|
38
|
+
r"(?i)(\b(?:api[_-]?key|access[_-]?key|secret|password|passwd|token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*)([\"']?)[^\s,;\"'}]+(\2)"
|
|
39
|
+
),
|
|
40
|
+
r"\1\2[REDACTED_SECRET]\3",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"connection_string",
|
|
44
|
+
re.compile(r"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://[^\s\"']+"),
|
|
45
|
+
"[REDACTED_CONNECTION_STRING]",
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_secrets(text: str) -> RedactionResult:
|
|
51
|
+
labels: list[str] = []
|
|
52
|
+
count = 0
|
|
53
|
+
redacted = text
|
|
54
|
+
for label, pattern, replacement in _PATTERNS:
|
|
55
|
+
redacted, substitutions = pattern.subn(replacement, redacted)
|
|
56
|
+
if substitutions:
|
|
57
|
+
count += substitutions
|
|
58
|
+
labels.extend([label] * substitutions)
|
|
59
|
+
return RedactionResult(redacted, count, tuple(labels))
|
diffly_cli/triage.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import re
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import ChangedFile, PRMetadata, RiskFlag
|
|
9
|
+
|
|
10
|
+
AUTH_PATTERNS = [
|
|
11
|
+
"*auth*", "*login*", "*oauth*", "*credential*", "*secret*", "*.pem", "*.key",
|
|
12
|
+
".env", ".env.*", "*token*", "*password*", "*security*", "*iam*",
|
|
13
|
+
]
|
|
14
|
+
DB_PATTERNS = ["*migration*", "*migrations*", "*schema*", "*alembic*", "*prisma*", "*.sql", "*db*model*"]
|
|
15
|
+
TEST_PATTERNS = ["test*", "tests*", "spec*", "*_test.*", "*.test.*", "*.spec.*"]
|
|
16
|
+
DEPENDENCY_FILES = {
|
|
17
|
+
"package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
|
|
18
|
+
"pyproject.toml", "poetry.lock", "requirements.txt", "requirements-dev.txt", "Pipfile", "Pipfile.lock",
|
|
19
|
+
"go.mod", "go.sum", "Cargo.toml", "Cargo.lock", "Gemfile", "Gemfile.lock", "pom.xml", "build.gradle",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _matches(path: str, patterns: list[str]) -> bool:
|
|
24
|
+
lowered = path.lower()
|
|
25
|
+
return any(fnmatch.fnmatch(lowered, pattern.lower()) or fnmatch.fnmatch(lowered.split("/")[-1], pattern.lower()) for pattern in patterns)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_production_file(path: str) -> bool:
|
|
29
|
+
lowered = path.lower()
|
|
30
|
+
parts = lowered.split("/")
|
|
31
|
+
if any(part in {"docs", "examples", "fixtures", "generated", "tests", "test"} for part in parts):
|
|
32
|
+
return False
|
|
33
|
+
if _matches(path, TEST_PATTERNS):
|
|
34
|
+
return False
|
|
35
|
+
return not lowered.endswith((".md", ".txt", ".json")) and lowered != "install.sh"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _added_dependency_names(file: ChangedFile) -> list[str]:
|
|
39
|
+
if file.path.rsplit("/", 1)[-1] not in DEPENDENCY_FILES:
|
|
40
|
+
return []
|
|
41
|
+
values: list[str] = []
|
|
42
|
+
for line in file.patch.splitlines():
|
|
43
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
44
|
+
match = re.search(r"[\"']([@A-Za-z0-9_./-]+)[\"']\s*[:=]", line)
|
|
45
|
+
if match:
|
|
46
|
+
values.append(match.group(1))
|
|
47
|
+
elif re.search(r"^[+]\s*[A-Za-z0-9_.-]+[=<>~]", line):
|
|
48
|
+
values.append(line[1:].strip().split()[0])
|
|
49
|
+
return sorted(dict.fromkeys(values))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _test_files(files: list[ChangedFile]) -> list[str]:
|
|
53
|
+
return [file.path for file in files if _matches(file.path, TEST_PATTERNS)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _covered_by_test(file: ChangedFile, test_paths: list[str]) -> list[str]:
|
|
57
|
+
stem = file.path.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
|
|
58
|
+
matches = []
|
|
59
|
+
for test_path in test_paths:
|
|
60
|
+
name = test_path.rsplit("/", 1)[-1].lower()
|
|
61
|
+
if stem in name or file.path.rsplit("/", 1)[0].lower() in test_path.lower():
|
|
62
|
+
matches.append(test_path)
|
|
63
|
+
return matches
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compute_flags(metadata: PRMetadata, files: list[ChangedFile], checks: dict[str, Any], repo_paths: list[str] | None = None) -> list[RiskFlag]:
|
|
67
|
+
flags: list[RiskFlag] = []
|
|
68
|
+
tree_complete = bool(checks.get("repository_tree_complete", repo_paths is not None))
|
|
69
|
+
test_paths = [path for path in (repo_paths or []) if _matches(path, TEST_PATTERNS)] if tree_complete else []
|
|
70
|
+
changed_test_paths = _test_files(files)
|
|
71
|
+
if not tree_complete:
|
|
72
|
+
flags.append(RiskFlag("REPOSITORY_TREE_INCOMPLETE", "medium", "Repository file listing was unavailable or truncated; repository-wide test coverage could not be established.", ["repository tree incomplete"]))
|
|
73
|
+
|
|
74
|
+
auth_files = [file.path for file in files if _matches(file.path, AUTH_PATTERNS)]
|
|
75
|
+
if auth_files:
|
|
76
|
+
flags.append(RiskFlag("AUTH_OR_SECRET", "high", "Touches authentication, credentials, secrets, or security-sensitive files.", auth_files))
|
|
77
|
+
|
|
78
|
+
db_files = [file.path for file in files if _matches(file.path, DB_PATTERNS)]
|
|
79
|
+
if db_files:
|
|
80
|
+
flags.append(RiskFlag("DATABASE_CHANGE", "high", "Touches database schema, models, migrations, or SQL.", db_files))
|
|
81
|
+
|
|
82
|
+
dependency_evidence: list[str] = []
|
|
83
|
+
for file in files:
|
|
84
|
+
names = _added_dependency_names(file)
|
|
85
|
+
if names:
|
|
86
|
+
dependency_evidence.append(f"{file.path}: {', '.join(names)}")
|
|
87
|
+
elif file.path.rsplit("/", 1)[-1] in DEPENDENCY_FILES and file.additions > 0:
|
|
88
|
+
dependency_evidence.append(file.path)
|
|
89
|
+
if dependency_evidence:
|
|
90
|
+
flags.append(RiskFlag("NEW_DEPENDENCY", "medium", "Adds or changes a dependency manifest or lockfile.", dependency_evidence))
|
|
91
|
+
|
|
92
|
+
untested: list[str] = []
|
|
93
|
+
for file in files:
|
|
94
|
+
if _matches(file.path, TEST_PATTERNS):
|
|
95
|
+
continue
|
|
96
|
+
coverage = _covered_by_test(file, test_paths + changed_test_paths)
|
|
97
|
+
file.tests_found = coverage
|
|
98
|
+
if _is_production_file(file.path) and not coverage:
|
|
99
|
+
untested.append(file.path)
|
|
100
|
+
if untested and tree_complete:
|
|
101
|
+
flags.append(RiskFlag("NO_TEST_COVERAGE", "medium", "Changed production files have no obvious neighboring or repository test coverage.", untested[:50]))
|
|
102
|
+
|
|
103
|
+
check_state = str(checks.get("state", "unknown"))
|
|
104
|
+
if check_state == "not_applicable":
|
|
105
|
+
# Local mode has no CI; skip check-derived flags entirely.
|
|
106
|
+
return flags
|
|
107
|
+
if check_state == "failure":
|
|
108
|
+
flags.append(RiskFlag("CHECKS_FAILED", "critical", "One or more required status checks failed.", list(checks.get("failed", []))))
|
|
109
|
+
elif check_state == "pending":
|
|
110
|
+
flags.append(RiskFlag("CHECKS_PENDING", "low", "Required status checks are still pending.", list(checks.get("pending", [])) or [check_state]))
|
|
111
|
+
elif check_state != "success":
|
|
112
|
+
flags.append(RiskFlag("CHECKS_UNKNOWN", "medium", "Required status checks are missing, pending, or unavailable.", [check_state]))
|
|
113
|
+
|
|
114
|
+
return flags
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def verdict_for(flags: list[RiskFlag], checks: dict[str, Any]) -> tuple[str, list[str]]:
|
|
118
|
+
codes = {flag.code for flag in flags}
|
|
119
|
+
reasoning: list[str] = []
|
|
120
|
+
if "CHECKS_FAILED" in codes:
|
|
121
|
+
reasoning.append("BLOCK because at least one status check failed.")
|
|
122
|
+
return "BLOCK", reasoning
|
|
123
|
+
if "AUTH_OR_SECRET" in codes:
|
|
124
|
+
reasoning.append("BLOCK because the pull request touches authentication, credentials, secrets, or security-sensitive files.")
|
|
125
|
+
return "BLOCK", reasoning
|
|
126
|
+
if "DATABASE_CHANGE" in codes:
|
|
127
|
+
reasoning.append("QUARANTINE because database schema or migration changes require an explicit review gate.")
|
|
128
|
+
if "NEW_DEPENDENCY" in codes:
|
|
129
|
+
reasoning.append("QUARANTINE because dependency changes expand the supply-chain and runtime surface.")
|
|
130
|
+
if "NO_TEST_COVERAGE" in codes:
|
|
131
|
+
reasoning.append("QUARANTINE because at least one changed production file lacks obvious test coverage.")
|
|
132
|
+
if "CHECKS_UNKNOWN" in codes:
|
|
133
|
+
reasoning.append("QUARANTINE because the pull request does not have a confirmed passing check result.")
|
|
134
|
+
if "CHECKS_PENDING" in codes:
|
|
135
|
+
reasoning.append("QUARANTINE because required checks are still running.")
|
|
136
|
+
if reasoning:
|
|
137
|
+
return "QUARANTINE", reasoning
|
|
138
|
+
if str(checks.get("state")) == "not_applicable":
|
|
139
|
+
reasoning.append("PASS because no blocking or quarantine rule fired (local analysis has no CI checks).")
|
|
140
|
+
else:
|
|
141
|
+
reasoning.append("PASS because no blocking or quarantine rule fired and all observed checks passed.")
|
|
142
|
+
return "PASS", reasoning
|
diffly_cli/update.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Check for and install updates to diffly-cli.
|
|
2
|
+
|
|
3
|
+
Version checks use the PyPI JSON API so the lookup is fast and does not
|
|
4
|
+
require git or GitHub API authentication. The actual upgrade command is
|
|
5
|
+
chosen based on how diffly was originally installed:
|
|
6
|
+
|
|
7
|
+
- ``curl | install.sh`` → re-runs the install script
|
|
8
|
+
- ``brew install`` → runs ``brew upgrade diffly-cli``
|
|
9
|
+
- ``pip install`` → runs ``pip install --upgrade diffly-cli``
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
|
|
24
|
+
CONFIG_DIR = Path.home() / ".diffly"
|
|
25
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
26
|
+
PYPI_URL = "https://pypi.org/pypi/diffly-cli/json"
|
|
27
|
+
INSTALL_SCRIPT_URL = (
|
|
28
|
+
"https://raw.githubusercontent.com/VIVAAN-DHAWAN/diffly-cli/main/install.sh"
|
|
29
|
+
)
|
|
30
|
+
INSTALLER_VENV = Path.home() / ".local" / "share" / "diffly-cli"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Version helpers
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def _parse_version(version_str: str) -> tuple[int, ...]:
|
|
38
|
+
"""Parse a version string like '0.3.0' into a comparable tuple."""
|
|
39
|
+
return tuple(int(part) for part in version_str.split(".") if part.isdigit())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Config persistence (~/.diffly/config.json)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def _load_config() -> dict[str, Any]:
|
|
47
|
+
"""Load the diffly user configuration, returning defaults when absent."""
|
|
48
|
+
if not CONFIG_FILE.exists():
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
52
|
+
except (json.JSONDecodeError, OSError):
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _save_config(config: dict[str, Any]) -> None:
|
|
57
|
+
"""Persist diffly user configuration to disk."""
|
|
58
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_update_preference() -> str | None:
|
|
63
|
+
"""Return the stored update preference: 'auto', 'manual', or None if never set."""
|
|
64
|
+
return _load_config().get("update_preference")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def set_update_preference(preference: str) -> None:
|
|
68
|
+
"""Persist the user's update preference ('auto' or 'manual')."""
|
|
69
|
+
config = _load_config()
|
|
70
|
+
config["update_preference"] = preference
|
|
71
|
+
_save_config(config)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# PyPI lookup
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _fetch_pypi_info() -> dict[str, Any] | None:
|
|
79
|
+
"""Fetch the latest release metadata from PyPI."""
|
|
80
|
+
import urllib.request
|
|
81
|
+
import urllib.error
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"})
|
|
85
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
86
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
87
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_for_update() -> str | None:
|
|
92
|
+
"""Return the latest version string if a newer release exists on PyPI.
|
|
93
|
+
|
|
94
|
+
Returns ``None`` when the installed version is current or when the PyPI
|
|
95
|
+
lookup cannot be completed (network error, timeout, etc.).
|
|
96
|
+
"""
|
|
97
|
+
info = _fetch_pypi_info()
|
|
98
|
+
if info is None:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
latest = info["info"]["version"]
|
|
102
|
+
except (KeyError, TypeError):
|
|
103
|
+
return None
|
|
104
|
+
if _parse_version(latest) > _parse_version(__version__):
|
|
105
|
+
return latest
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Installation detection
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _detect_install_method() -> str:
|
|
114
|
+
"""Return 'installer', 'brew', or 'pip' based on how diffly was installed."""
|
|
115
|
+
if INSTALLER_VENV.is_dir():
|
|
116
|
+
return "installer"
|
|
117
|
+
if shutil.which("brew") is not None:
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
["brew", "list", "diffly-cli"],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
timeout=10,
|
|
124
|
+
)
|
|
125
|
+
if result.returncode == 0:
|
|
126
|
+
return "brew"
|
|
127
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
128
|
+
pass
|
|
129
|
+
return "pip"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Update installation
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def install_update() -> bool:
|
|
137
|
+
"""Upgrade diffly using the same method it was originally installed with.
|
|
138
|
+
|
|
139
|
+
Detects whether diffly was installed via the curl installer script,
|
|
140
|
+
Homebrew, or pip, and runs the matching upgrade command.
|
|
141
|
+
"""
|
|
142
|
+
method = _detect_install_method()
|
|
143
|
+
|
|
144
|
+
if method == "installer":
|
|
145
|
+
cmd = f"curl -fsSL {INSTALL_SCRIPT_URL} | sh"
|
|
146
|
+
args = ["sh", "-c", cmd]
|
|
147
|
+
elif method == "brew":
|
|
148
|
+
args = ["brew", "upgrade", "diffly-cli"]
|
|
149
|
+
else:
|
|
150
|
+
args = [sys.executable, "-m", "pip", "install", "--upgrade", "diffly-cli"]
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
result = subprocess.run(
|
|
154
|
+
args,
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
timeout=120,
|
|
158
|
+
)
|
|
159
|
+
return result.returncode == 0
|
|
160
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
161
|
+
return False
|