gitpro 0.4.0__tar.gz → 0.4.2__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.
- {gitpro-0.4.0 → gitpro-0.4.2}/PKG-INFO +6 -6
- {gitpro-0.4.0 → gitpro-0.4.2}/README.md +5 -5
- {gitpro-0.4.0 → gitpro-0.4.2}/pyproject.toml +2 -1
- gitpro-0.4.2/src/gitpro/VERSION +5 -0
- gitpro-0.4.2/src/gitpro/__init__.py +10 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/agents.py +8 -32
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/approval_policy.py +2 -1
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/args.py +11 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/audit.py +8 -19
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/change_contract.py +4 -5
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/changes.py +8 -16
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/cli.py +5 -27
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/clone.py +4 -8
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/commitcheck.py +9 -4
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/config.py +2 -4
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/devcontainer.py +1 -6
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/discover.py +7 -17
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/doctor.py +3 -6
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/envcmd.py +1 -6
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/files.py +11 -2
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/forge.py +23 -29
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/guard.py +18 -15
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/health.py +11 -34
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/incident.py +3 -21
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/jsonfile.py +65 -14
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/keys.py +1 -1
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/lint.py +1 -1
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/lost.py +7 -16
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/mcp.py +13 -52
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operation_contract.py +16 -1
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operation_reconcile.py +2 -3
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operations.py +25 -12
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/pin.py +1 -1
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/policy.py +3 -3
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/render.py +1 -4
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/resolve.py +40 -33
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/trailers.py +9 -5
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/tui.py +1 -1
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/undo.py +3 -8
- gitpro-0.4.2/src/gitpro/util.py +34 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/view.py +2 -5
- gitpro-0.4.0/src/gitpro/__init__.py +0 -4
- {gitpro-0.4.0 → gitpro-0.4.2}/.gitignore +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/LICENSE +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/__main__.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/completion.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/deprecation.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/example.toml +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/hot.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/jsonout.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/learn.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operation_io.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/plan.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/prompt.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/report.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/scan.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/scanroots.py +0 -0
- {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitpro
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Per-remote git identities (name, email, SSH key, commit signing) from one TOML file
|
|
5
5
|
Project-URL: Homepage, https://gitpro.dev
|
|
6
6
|
Project-URL: Source, https://github.com/Yantrakar/gitpro
|
|
@@ -34,6 +34,8 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
|
|
35
35
|
[gitpro.dev](https://gitpro.dev) · [PyPI](https://pypi.org/project/gitpro/) · [VS Code](https://marketplace.visualstudio.com/items?itemName=yantrakar.gitpro) · made by [Yantrakar](https://yantrakar.com)
|
|
36
36
|
|
|
37
|
+

|
|
38
|
+
|
|
37
39
|
Work on a company repository, commit with your work email. Switch to a personal
|
|
38
40
|
project, use your personal identity. Gitpro chooses by **remote URL**, not by
|
|
39
41
|
where you happened to clone the repository.
|
|
@@ -114,12 +116,10 @@ see [agent workflows](docs/agent-workflows.md).
|
|
|
114
116
|
|
|
115
117
|
## Problems and questions
|
|
116
118
|
|
|
117
|
-
Run `gitpro report` to draft an issue with the versions and a redacted
|
|
118
|
-
|
|
119
|
-
Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
|
|
119
|
+
Run `gitpro report` to draft an issue with the versions and a redacted `gitpro why`; it shows
|
|
120
|
+
you the draft and opens nothing until you say yes. Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
|
|
120
121
|
Report a security problem privately with `gitpro report --security`, as the
|
|
121
|
-
[security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
|
|
122
|
-
not in a public issue.
|
|
122
|
+
[security policy](https://github.com/Yantrakar/gitpro/security/policy) describes, not in a public issue.
|
|
123
123
|
|
|
124
124
|
## License
|
|
125
125
|
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
[gitpro.dev](https://gitpro.dev) · [PyPI](https://pypi.org/project/gitpro/) · [VS Code](https://marketplace.visualstudio.com/items?itemName=yantrakar.gitpro) · made by [Yantrakar](https://yantrakar.com)
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+
|
|
11
13
|
Work on a company repository, commit with your work email. Switch to a personal
|
|
12
14
|
project, use your personal identity. Gitpro chooses by **remote URL**, not by
|
|
13
15
|
where you happened to clone the repository.
|
|
@@ -88,12 +90,10 @@ see [agent workflows](docs/agent-workflows.md).
|
|
|
88
90
|
|
|
89
91
|
## Problems and questions
|
|
90
92
|
|
|
91
|
-
Run `gitpro report` to draft an issue with the versions and a redacted
|
|
92
|
-
|
|
93
|
-
Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
|
|
93
|
+
Run `gitpro report` to draft an issue with the versions and a redacted `gitpro why`; it shows
|
|
94
|
+
you the draft and opens nothing until you say yes. Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
|
|
94
95
|
Report a security problem privately with `gitpro report --security`, as the
|
|
95
|
-
[security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
|
|
96
|
-
not in a public issue.
|
|
96
|
+
[security policy](https://github.com/Yantrakar/gitpro/security/policy) describes, not in a public issue.
|
|
97
97
|
|
|
98
98
|
## License
|
|
99
99
|
|
|
@@ -36,7 +36,8 @@ Issues = "https://github.com/Yantrakar/gitpro/issues"
|
|
|
36
36
|
gitpro = "gitpro.hot:main"
|
|
37
37
|
|
|
38
38
|
[tool.hatch.version]
|
|
39
|
-
path = "src/gitpro/
|
|
39
|
+
path = "src/gitpro/VERSION"
|
|
40
|
+
pattern = '\A(?P<version>\d+\.\d+\.\d+)'
|
|
40
41
|
|
|
41
42
|
[tool.hatch.build.targets.wheel]
|
|
42
43
|
packages = ["src/gitpro"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
0.4.2 2026-10-20 README demo GIF, Marketplace keywords, Open VSX in the release run
|
|
2
|
+
0.4.1 2026-10-18 secret masking, atomic writes, re-runnable release, version in one file
|
|
3
|
+
0.4.0 2026-10-16 setup replaces init and apply; guard refuses mixed-identity repos
|
|
4
|
+
0.3.4 report, support links and xdist CI
|
|
5
|
+
0.3.3 gitpro icon, links to gitpro.dev and MCP Registry metadata
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Per-remote git identities generated from one TOML file (see cli.py)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# The release version is written only in the VERSION file next to this one (hatch reads it too):
|
|
7
|
+
# the first word of its first line. os and open() only: the hooks import this package on every
|
|
8
|
+
# commit, so nothing heavier is loaded.
|
|
9
|
+
with open(os.path.join(os.path.dirname(__file__), "VERSION"), encoding="utf-8") as _f:
|
|
10
|
+
__version__ = _f.readline().split()[0]
|
|
@@ -1656,20 +1656,10 @@ def _precheck(path: Path, t: Target, snippet: str) -> Optional[object]:
|
|
|
1656
1656
|
"""The file's JSON (None when missing); ConfigError for anything gitpro must not edit."""
|
|
1657
1657
|
from . import jsonfile, undo
|
|
1658
1658
|
from .config import ConfigError
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
text = path.read_text(encoding="utf-8")
|
|
1662
|
-
except FileNotFoundError:
|
|
1659
|
+
obj = jsonfile.read_object(path, snippet)
|
|
1660
|
+
if obj is None:
|
|
1663
1661
|
return None
|
|
1664
|
-
|
|
1665
|
-
obj = json.loads(text)
|
|
1666
|
-
except ValueError:
|
|
1667
|
-
if jsonfile.strip_jsonc(text)[1]:
|
|
1668
|
-
raise ConfigError(f"{shown} has comments; gitpro cannot edit it without losing them"
|
|
1669
|
-
f" — add this by hand: {snippet}") from None
|
|
1670
|
-
raise ConfigError(f"{shown} is not valid JSON; fix it by hand (nothing written)") from None
|
|
1671
|
-
if not isinstance(obj, dict):
|
|
1672
|
-
raise ConfigError(f"{shown} is not a JSON object; fix it by hand (nothing written)")
|
|
1662
|
+
shown = undo.tilde(path)
|
|
1673
1663
|
hooks = obj.get("hooks", {})
|
|
1674
1664
|
if not isinstance(hooks, dict):
|
|
1675
1665
|
raise ConfigError(f"{shown}: hooks is not an object; fix it by hand (nothing written)")
|
|
@@ -1689,8 +1679,8 @@ def _repo_top() -> Optional[Path]:
|
|
|
1689
1679
|
|
|
1690
1680
|
|
|
1691
1681
|
def _git_out(top: Path, *args: str, timeout: float = 30) -> subprocess.CompletedProcess:
|
|
1692
|
-
|
|
1693
|
-
return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, env=
|
|
1682
|
+
from . import resolve
|
|
1683
|
+
return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, env=resolve._child_env(),
|
|
1694
1684
|
stdin=subprocess.DEVNULL, timeout=timeout)
|
|
1695
1685
|
|
|
1696
1686
|
|
|
@@ -1724,20 +1714,6 @@ def _exclude_local(top: Path, script: Optional[Path]) -> bool:
|
|
|
1724
1714
|
return True
|
|
1725
1715
|
|
|
1726
1716
|
|
|
1727
|
-
def _confirm_with(args: argparse.Namespace, question: str) -> Callable[[str], bool]:
|
|
1728
|
-
def confirm(diff: str) -> bool:
|
|
1729
|
-
from .config import ConfigError
|
|
1730
|
-
if args.yes:
|
|
1731
|
-
return True
|
|
1732
|
-
if not sys.stdin.isatty():
|
|
1733
|
-
raise ConfigError("no terminal to confirm on; re-run with --yes")
|
|
1734
|
-
from . import view
|
|
1735
|
-
if diff:
|
|
1736
|
-
print(diff, end="" if diff.endswith("\n") else "\n")
|
|
1737
|
-
return view.confirm(question)
|
|
1738
|
-
return confirm
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
1717
|
def _edit(args: argparse.Namespace, removing: bool) -> int:
|
|
1742
1718
|
from . import args as gargs
|
|
1743
1719
|
from . import jsonfile, jsonout, undo
|
|
@@ -1780,7 +1756,7 @@ def _edit(args: argparse.Namespace, removing: bool) -> int:
|
|
|
1780
1756
|
shown = undo.tilde(t.path)
|
|
1781
1757
|
if say and agent == "claude" and not removing:
|
|
1782
1758
|
from . import view
|
|
1783
|
-
view.step("plugin users already have this hook (gitpro
|
|
1759
|
+
view.step("plugin users already have this hook (gitpro setup -n shows it)")
|
|
1784
1760
|
if after == before and (obj is not None or removing):
|
|
1785
1761
|
if args.json:
|
|
1786
1762
|
jsonout.emit(command, {"file": str(t.path), "changed": False, "backup": None, "undo": None})
|
|
@@ -1798,7 +1774,7 @@ def _edit(args: argparse.Namespace, removing: bool) -> int:
|
|
|
1798
1774
|
"hook": _snippet(t), "backup": None, "undo": None})
|
|
1799
1775
|
question = f"{'remove' if removing else 'install'} the {title} hook?"
|
|
1800
1776
|
res = jsonfile.edit_json(t.path, change, command=command, create={},
|
|
1801
|
-
confirm=
|
|
1777
|
+
confirm=jsonfile.confirm_once(args, question), mkdir=True, snippet=_snippet(t))
|
|
1802
1778
|
if not res.changed:
|
|
1803
1779
|
from . import view
|
|
1804
1780
|
view.aborted()
|
|
@@ -1907,7 +1883,7 @@ def status_data(home: Path, explicit_config: Optional[Path] = None) -> dict:
|
|
|
1907
1883
|
plain, login = _command_v(False), _command_v(True)
|
|
1908
1884
|
if not plain and login:
|
|
1909
1885
|
warnings.append(f"gitpro is not on PATH for sh (found by login shell at {login}); the hook exits "
|
|
1910
|
-
f"silently without it → gitpro
|
|
1886
|
+
f"silently without it → gitpro setup --agent claude rewrites the entry with "
|
|
1911
1887
|
f"the absolute path")
|
|
1912
1888
|
return {"mode": mode, "agents": agents_, "guard_installed": guard_installed(),
|
|
1913
1889
|
"gitpro_path": {"sh": plain, "login": login}, "warnings": warnings}
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
import re
|
|
10
10
|
from typing import Optional
|
|
11
11
|
|
|
12
|
+
from .config import SCAN_CONTROL_RE
|
|
12
13
|
from .operation_contract import OperationError, decode, digest
|
|
13
14
|
|
|
14
15
|
MAX_POLICY = 65536
|
|
@@ -43,7 +44,7 @@ def _invalid(message: str) -> OperationError:
|
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def _path(value: object) -> Path:
|
|
46
|
-
if not isinstance(value, str) or not value or
|
|
47
|
+
if not isinstance(value, str) or not value or SCAN_CONTROL_RE.search(value):
|
|
47
48
|
raise _invalid("Policy paths must be absolute, normalized paths without control characters.")
|
|
48
49
|
path = Path(value)
|
|
49
50
|
if not path.is_absolute() or ".." in path.parts or str(path) != value or path.resolve() != path:
|
|
@@ -66,6 +66,17 @@ def preview_result(args: object, command: str, data: dict) -> int:
|
|
|
66
66
|
return 0
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def ask(question: str, yes: bool) -> bool:
|
|
70
|
+
"""True for -y; else ask on the terminal. Without one, a ConfigError (the caller exits 2)."""
|
|
71
|
+
if yes:
|
|
72
|
+
return True
|
|
73
|
+
if not sys.stdin.isatty():
|
|
74
|
+
from .config import ConfigError
|
|
75
|
+
raise ConfigError("no terminal to confirm on; re-run with --yes")
|
|
76
|
+
from . import view
|
|
77
|
+
return view.confirm(question)
|
|
78
|
+
|
|
79
|
+
|
|
69
80
|
def fail(msg: str, args: object) -> int:
|
|
70
81
|
"""Exit 2 with msg: the JSON error object on stdout under --json (nothing on stderr), else ✗ on stderr."""
|
|
71
82
|
if getattr(args, "json", False):
|
|
@@ -19,8 +19,9 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
|
20
20
|
|
|
21
21
|
from . import config, jsonout, resolve, trailers, undo
|
|
22
|
-
from .args import capability, dry_run, preview_only
|
|
22
|
+
from .args import ask as _confirm, capability, dry_run, preview_only
|
|
23
23
|
from .config import Config, ConfigError
|
|
24
|
+
from .util import sh_tilde as _sh
|
|
24
25
|
|
|
25
26
|
PROG = "gitpro"
|
|
26
27
|
DEFAULT_RANGE = "HEAD --not --remotes"
|
|
@@ -579,7 +580,7 @@ def _rows(items: List[dict], verbose: bool, fmt) -> List[str]:
|
|
|
579
580
|
|
|
580
581
|
|
|
581
582
|
def _rewrite_cmd(data: dict, args) -> str:
|
|
582
|
-
argv = [PROG, "audit", "--
|
|
583
|
+
argv = [PROG, "audit", "--fix"]
|
|
583
584
|
if getattr(args, "config", None):
|
|
584
585
|
argv += ["-c", str(args.config)]
|
|
585
586
|
rw = data["rewrite"]
|
|
@@ -591,10 +592,6 @@ def _rewrite_cmd(data: dict, args) -> str:
|
|
|
591
592
|
return shown + " " + _sh(resolve._tilde(data["repo"]))
|
|
592
593
|
|
|
593
594
|
|
|
594
|
-
def _sh(path: str) -> str:
|
|
595
|
-
return path if path == "~" else "~/" + shlex.quote(path[2:]) if path.startswith("~/") else shlex.quote(path)
|
|
596
|
-
|
|
597
|
-
|
|
598
595
|
def render(data: dict, args) -> List[str]:
|
|
599
596
|
"""The text report, one line per item."""
|
|
600
597
|
verbose = bool(getattr(args, "verbose", False))
|
|
@@ -672,15 +669,6 @@ def _public(data: dict) -> dict:
|
|
|
672
669
|
return out
|
|
673
670
|
|
|
674
671
|
|
|
675
|
-
def _confirm(question: str, yes: bool) -> bool:
|
|
676
|
-
if yes:
|
|
677
|
-
return True
|
|
678
|
-
if not sys.stdin.isatty():
|
|
679
|
-
raise ConfigError("no terminal to confirm on; re-run with --yes")
|
|
680
|
-
from . import view
|
|
681
|
-
return view.confirm(question)
|
|
682
|
-
|
|
683
|
-
|
|
684
672
|
def _preview(data: dict, exp) -> List[str]:
|
|
685
673
|
rw = data["rewrite"]
|
|
686
674
|
branch = data["branch"]
|
|
@@ -716,12 +704,13 @@ def _preview(data: dict, exp) -> List[str]:
|
|
|
716
704
|
|
|
717
705
|
def cmd_audit(args) -> int:
|
|
718
706
|
as_json = jsonout.wants_json(args)
|
|
707
|
+
flag = "--rewrite" if getattr(args, "rewrite_flag", False) else "--fix" # errors name the flag given
|
|
719
708
|
args.rewrite = args.rewrite or getattr(args, "rewrite_flag", False)
|
|
720
709
|
if args.rewrite and getattr(args, "agents", False):
|
|
721
|
-
raise ConfigError("
|
|
710
|
+
raise ConfigError(f"{flag} needs the author audit; drop --agents")
|
|
722
711
|
show_only = bool(args.rewrite and args.dry_run and preview_only(args))
|
|
723
712
|
if args.rewrite and as_json and not args.yes and not show_only:
|
|
724
|
-
raise ConfigError("--json needs -y with
|
|
713
|
+
raise ConfigError(f"--json needs -y with {flag} (there is no terminal to confirm on)")
|
|
725
714
|
home = Path(args.home).expanduser().resolve()
|
|
726
715
|
path, how = config.find(args.config, home)
|
|
727
716
|
cfg = config.load_all(path, how)
|
|
@@ -787,11 +776,11 @@ def register(sub, common, confirm) -> None:
|
|
|
787
776
|
"not the repo's identity, commits made with an AI agent (Co-Authored-By trailers, bot "
|
|
788
777
|
"emails) and the [*.trailers] policy. Default: the unpushed commits (as of the last fetch) "
|
|
789
778
|
"plus the newest pushed ones.",
|
|
790
|
-
epilog="--
|
|
779
|
+
epilog="--fix fixes only the unpushed offending commits, keeps every date and never runs rebase; "
|
|
791
780
|
"pushed commits are only reported. \"Pushed\" means reachable from a remote-tracking ref "
|
|
792
781
|
"(refs/remotes/*) as of the last fetch: commits published only through a tag, `git push <url>` "
|
|
793
782
|
"or Gerrit's refs/for/* count as unpushed, so fetch first. With commit.gpgsign set the new "
|
|
794
|
-
"commits are signed; notes are copied to them. With the guard installed (gitpro
|
|
783
|
+
"commits are signed; notes are copied to them. With the guard installed (gitpro setup) the commit-msg "
|
|
795
784
|
"hook applies the trailer policy at commit time; skip it once in your own shell with "
|
|
796
785
|
"GITPRO_SKIP=1 git commit ...")
|
|
797
786
|
sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from . import config
|
|
10
10
|
from .approval_policy import AUTHORITY_SCHEMA
|
|
11
|
-
from .operation_contract import MAX_REQUEST, OperationError
|
|
11
|
+
from .operation_contract import DECISIONS, MAX_REQUEST, OperationError
|
|
12
12
|
|
|
13
13
|
KINDS = {
|
|
14
14
|
"configure": ("config_path", "toml"),
|
|
@@ -40,8 +40,7 @@ RESULT_SCHEMA = {
|
|
|
40
40
|
"properties": {
|
|
41
41
|
"version": {"const": 1}, "command": {"enum": ["change propose", "change approve", "change apply",
|
|
42
42
|
"change status", "change recover"]},
|
|
43
|
-
"ok": {"type": "boolean"}, "decision": {"enum":
|
|
44
|
-
"needs_approval", "indeterminate"]},
|
|
43
|
+
"ok": {"type": "boolean"}, "decision": {"enum": list(DECISIONS)},
|
|
45
44
|
"problems": {"type": "array", "items": {
|
|
46
45
|
"type": "object", "required": ["code", "message", "decision"]}},
|
|
47
46
|
"plan_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
@@ -100,13 +99,13 @@ def parse(raw: object) -> dict:
|
|
|
100
99
|
if not source.is_absolute() or source.suffix != ".toml" or home not in source.parents:
|
|
101
100
|
raise OperationError("invalid_config_path", "Use an absolute .toml configuration path inside your home.",
|
|
102
101
|
"needs_input")
|
|
103
|
-
if ".." in source.parts or
|
|
102
|
+
if ".." in source.parts or config.SCAN_CONTROL_RE.search(str(source)):
|
|
104
103
|
raise OperationError("invalid_config_path", "Use a normalized configuration path without control characters.",
|
|
105
104
|
"needs_input")
|
|
106
105
|
out["config_path"] = str(source)
|
|
107
106
|
if kind == "repo-identity":
|
|
108
107
|
repo = Path(raw["repo"])
|
|
109
|
-
if not repo.is_absolute() or
|
|
108
|
+
if not repo.is_absolute() or config.SCAN_CONTROL_RE.search(str(repo)):
|
|
110
109
|
raise OperationError("invalid_repo", "Supply an absolute worktree root.", "needs_input")
|
|
111
110
|
out["repo"] = str(repo.resolve())
|
|
112
111
|
return out
|
|
@@ -19,7 +19,7 @@ from typing import Iterator, Optional
|
|
|
19
19
|
from . import change_contract as contract
|
|
20
20
|
from . import approval_policy
|
|
21
21
|
from . import config, files, jsonout, resolve
|
|
22
|
-
from .operation_contract import MAX_REQUEST, OperationError, decode, digest
|
|
22
|
+
from .operation_contract import MAX_REQUEST, OperationError, decode, digest, exit_code, read_text
|
|
23
23
|
from .operation_io import Ledger, configuration_lock, process, repository_lock, state_dir, termination_signals
|
|
24
24
|
from .operation_io import sync_path as _sync
|
|
25
25
|
|
|
@@ -199,7 +199,7 @@ class Store(Ledger):
|
|
|
199
199
|
continue
|
|
200
200
|
if (current.st_dev, current.st_ino, current.st_ctime_ns) == (device, inode, ctime):
|
|
201
201
|
path.unlink()
|
|
202
|
-
|
|
202
|
+
_sync(path.parent, directory=True)
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
class Builder:
|
|
@@ -510,10 +510,6 @@ def approve(proposal: object) -> dict:
|
|
|
510
510
|
"be durably recorded. Inspect change status.", "indeterminate"))
|
|
511
511
|
|
|
512
512
|
|
|
513
|
-
def _sync_dir(path: Path) -> None:
|
|
514
|
-
_sync(path, directory=True)
|
|
515
|
-
|
|
516
|
-
|
|
517
513
|
@contextlib.contextmanager
|
|
518
514
|
def _target_lock(path: Path, store: Store, identifier: str) -> Iterator[None]:
|
|
519
515
|
lock = path.with_name(path.name + ".lock")
|
|
@@ -534,7 +530,7 @@ def _target_lock(path: Path, store: Store, identifier: str) -> Iterator[None]:
|
|
|
534
530
|
if current and (st.st_dev, st.st_ino, st.st_ctime_ns) == (
|
|
535
531
|
current.st_dev, current.st_ino, current.st_ctime_ns):
|
|
536
532
|
lock.unlink()
|
|
537
|
-
|
|
533
|
+
_sync(lock.parent, directory=True)
|
|
538
534
|
|
|
539
535
|
|
|
540
536
|
def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store: Store, identifier: str) -> None:
|
|
@@ -542,7 +538,7 @@ def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store
|
|
|
542
538
|
missing = [p for p in path.parents if not p.exists()]
|
|
543
539
|
for parent in reversed(missing):
|
|
544
540
|
parent.mkdir(mode=0o700)
|
|
545
|
-
|
|
541
|
+
_sync(parent.parent, directory=True)
|
|
546
542
|
with _target_lock(path, store, identifier):
|
|
547
543
|
if _snapshot(path) != expected:
|
|
548
544
|
raise OperationError("stale_plan", f"A file changed before replacement: {path}", "blocked")
|
|
@@ -553,7 +549,7 @@ def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store
|
|
|
553
549
|
files.atomic_write(path, _bytes(wanted), wanted["mode"], follow_symlinks=False,
|
|
554
550
|
overwrite=expected is not None)
|
|
555
551
|
_sync(path)
|
|
556
|
-
|
|
552
|
+
_sync(path.parent, directory=True)
|
|
557
553
|
if not _same_content(_snapshot(path), wanted):
|
|
558
554
|
raise OperationError("verification_failed", f"Replacement could not be verified: {path}", "indeterminate")
|
|
559
555
|
|
|
@@ -571,7 +567,7 @@ def _rollback(document: dict, store: Store) -> list[str]:
|
|
|
571
567
|
if _same_content(current, change["before"]):
|
|
572
568
|
if current is not None:
|
|
573
569
|
_sync(path)
|
|
574
|
-
|
|
570
|
+
_sync(path.parent, directory=True)
|
|
575
571
|
continue
|
|
576
572
|
if not _same_content(current, change["after"]):
|
|
577
573
|
conflicts.append(str(path))
|
|
@@ -773,11 +769,7 @@ def command(args: argparse.Namespace) -> int:
|
|
|
773
769
|
if not source:
|
|
774
770
|
raise OperationError("missing_input", "Supply the request/proposal JSON file.", "needs_input")
|
|
775
771
|
limit = MAX_REQUEST if action == "propose" else MAX_PLAN
|
|
776
|
-
|
|
777
|
-
text = sys.stdin.read(limit + 1)
|
|
778
|
-
else:
|
|
779
|
-
with open(source, encoding="utf-8") as stream:
|
|
780
|
-
text = stream.read(limit + 1)
|
|
772
|
+
text = read_text(source, limit)
|
|
781
773
|
if action == "propose":
|
|
782
774
|
result = propose(decode(text), args.config, policy_path=args.approval_policy)
|
|
783
775
|
else:
|
|
@@ -824,7 +816,7 @@ def command(args: argparse.Namespace) -> int:
|
|
|
824
816
|
result = contract.error(action, OperationError("invalid_request", "Could not read the JSON input.",
|
|
825
817
|
"needs_input"))
|
|
826
818
|
jsonout.emit(result["command"], result)
|
|
827
|
-
return
|
|
819
|
+
return exit_code(result)
|
|
828
820
|
|
|
829
821
|
|
|
830
822
|
def register(sub: argparse._SubParsersAction, common: argparse.ArgumentParser,
|
|
@@ -35,10 +35,10 @@ from pathlib import Path
|
|
|
35
35
|
from typing import Dict, Iterator, Optional, Tuple
|
|
36
36
|
|
|
37
37
|
from . import __version__, config, deprecation, jsonout
|
|
38
|
-
from .args import CAPABILITIES, capability, common, confirm, dry_run, fail, guarded, preview_only, preview_result
|
|
38
|
+
from .args import CAPABILITIES, ask as _confirm, capability, common, confirm, dry_run, fail, guarded, preview_only, preview_result
|
|
39
|
+
from .util import sh_tilde as _sh
|
|
39
40
|
|
|
40
41
|
PROG = "gitpro"
|
|
41
|
-
MIN_GIT = (2, 36)
|
|
42
42
|
|
|
43
43
|
# One line per package, alphabetical by module name. 01 writes the whole list up front; a module that
|
|
44
44
|
# is not built yet is skipped (see _extension_modules). No later package edits this file.
|
|
@@ -330,15 +330,6 @@ def _warn(cfg, env) -> None:
|
|
|
330
330
|
view.warn(msg)
|
|
331
331
|
|
|
332
332
|
|
|
333
|
-
def _confirm(question: str, yes: bool) -> bool:
|
|
334
|
-
if yes:
|
|
335
|
-
return True
|
|
336
|
-
if not sys.stdin.isatty():
|
|
337
|
-
raise config.ConfigError("no terminal to confirm on; re-run with --yes")
|
|
338
|
-
from . import view
|
|
339
|
-
return view.confirm(question)
|
|
340
|
-
|
|
341
|
-
|
|
342
333
|
def _changes(cfg, env) -> list:
|
|
343
334
|
from . import files, plan
|
|
344
335
|
return files.diff(*plan.build(cfg, env))
|
|
@@ -374,7 +365,7 @@ def cmd_status(p: argparse.ArgumentParser, subs: Subs) -> int:
|
|
|
374
365
|
def cmd_init(args) -> int:
|
|
375
366
|
from importlib import resources
|
|
376
367
|
|
|
377
|
-
from . import view
|
|
368
|
+
from . import files, view
|
|
378
369
|
target = (args.path or Path.home() / config.USER_CONFIG).expanduser()
|
|
379
370
|
if target.exists():
|
|
380
371
|
view.ok(f"{target} already exists, left unchanged")
|
|
@@ -383,10 +374,7 @@ def cmd_init(args) -> int:
|
|
|
383
374
|
if preview_only(args):
|
|
384
375
|
print(f"Would create {target}:\n{text}")
|
|
385
376
|
return preview_result(args, "init", {"path": str(target), "content": text})
|
|
386
|
-
|
|
387
|
-
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
388
|
-
with os.fdopen(fd, "w") as f:
|
|
389
|
-
f.write(text)
|
|
377
|
+
files.atomic_write(target, text, 0o600, overwrite=False) # no half-written config for the next init to refuse
|
|
390
378
|
extra = "" if args.path is None else f" -c {target}"
|
|
391
379
|
view.ok(f"created {target}")
|
|
392
380
|
view.step(f"replace the placeholders in it, then run: {PROG} plan{extra}")
|
|
@@ -594,12 +582,6 @@ def _lost(roots, exclude, cfg, paths, source) -> dict:
|
|
|
594
582
|
repos=paths, roots_source=source)
|
|
595
583
|
|
|
596
584
|
|
|
597
|
-
def _sh(path: str) -> str:
|
|
598
|
-
"""Quote a path for the shell, leaving a leading ~ unquoted so the shell still expands it."""
|
|
599
|
-
import shlex
|
|
600
|
-
return path if path == "~" else "~/" + shlex.quote(path[2:]) if path.startswith("~/") else shlex.quote(path)
|
|
601
|
-
|
|
602
|
-
|
|
603
585
|
def _fix(args, repos: list, home: Path) -> int:
|
|
604
586
|
from . import plan, scan
|
|
605
587
|
state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local/state") / PROG
|
|
@@ -699,10 +681,6 @@ def cmd_uninstall(args) -> int:
|
|
|
699
681
|
return 0
|
|
700
682
|
|
|
701
683
|
|
|
702
|
-
def _fail(msg: str, args) -> int:
|
|
703
|
-
return fail(msg, args)
|
|
704
|
-
|
|
705
|
-
|
|
706
684
|
def main(argv: Optional[list] = None) -> int:
|
|
707
685
|
global CALLER_UMASK
|
|
708
686
|
if os.name != "posix":
|
|
@@ -714,7 +692,7 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
714
692
|
if args.version:
|
|
715
693
|
return cmd_version(args)
|
|
716
694
|
if os.geteuid() == 0:
|
|
717
|
-
return
|
|
695
|
+
return fail("run as the user who owns these identities, not root", args)
|
|
718
696
|
CALLER_UMASK = os.umask(0o077)
|
|
719
697
|
if args.command is None:
|
|
720
698
|
return guarded(lambda a: cmd_status(p, subs), args)
|
|
@@ -24,7 +24,8 @@ from typing import Iterator, List, Optional, Sequence, Tuple
|
|
|
24
24
|
|
|
25
25
|
from . import jsonout, resolve
|
|
26
26
|
from .args import capability
|
|
27
|
-
from .config import Config, ConfigError, Identity
|
|
27
|
+
from .config import SCAN_CONTROL_RE, Config, ConfigError, Identity
|
|
28
|
+
from .util import expand_home as _expand
|
|
28
29
|
|
|
29
30
|
# scheme -> what kind of access it is ("auth" in --json for a URL that is kept as given)
|
|
30
31
|
_SCHEMES = {"http": "https", "https": "https", "ssh": "ssh", "git+ssh": "ssh", "ssh+git": "ssh",
|
|
@@ -33,7 +34,6 @@ _SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://")
|
|
|
33
34
|
_SEG = r"[A-Za-z0-9._-]+"
|
|
34
35
|
_SHORT_RE = re.compile(rf"^{_SEG}(?:/{_SEG})+$") # owner/repo, group/sub/repo, host/owner/repo
|
|
35
36
|
_PLACEHOLDER_RE = re.compile(r"\{(host|owner|repo|id)\}")
|
|
36
|
-
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
@dataclass(frozen=True)
|
|
@@ -79,7 +79,7 @@ def _find(cfg: Config, want: str) -> Identity:
|
|
|
79
79
|
def _source(url: str, ident: Optional[Identity], cwd: Path) -> _Source:
|
|
80
80
|
"""What the user typed, classified: a URL with a scheme, scp-like, a local path, or a shorthand."""
|
|
81
81
|
bad = ConfigError(f"cannot parse {url!r} as a git URL")
|
|
82
|
-
if not url.strip() or
|
|
82
|
+
if not url.strip() or SCAN_CONTROL_RE.search(url) or url.startswith("-"):
|
|
83
83
|
raise bad
|
|
84
84
|
m = _SCHEME_RE.match(url)
|
|
85
85
|
if m:
|
|
@@ -117,10 +117,6 @@ def _source(url: str, ident: Optional[Identity], cwd: Path) -> _Source:
|
|
|
117
117
|
return _Source(url, "local", None, None, repo)
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def _expand(p: str, home: Path) -> Path:
|
|
121
|
-
return home / p[2:] if p.startswith("~/") else Path(p)
|
|
122
|
-
|
|
123
|
-
|
|
124
120
|
def _tilde(path: Path, home: Path) -> str:
|
|
125
121
|
from .plan import Env
|
|
126
122
|
return Env(home).tilde(Path(path))
|
|
@@ -406,7 +402,7 @@ def _problem_line(prob: resolve.Problem, p: ClonePlan, top: Path, home: Path) ->
|
|
|
406
402
|
if prob.code == "no_identity":
|
|
407
403
|
where = f"{p.host}/{p.owner}" if p.host and p.owner else resolve.redact_url(p.clone_url)
|
|
408
404
|
return (f"no identity for {where}: git will refuse to commit here → "
|
|
409
|
-
f"gitpro
|
|
405
|
+
f"gitpro setup --add {_sh(_tilde(top, home))}")
|
|
410
406
|
fix = resolve._shown_fix(prob)
|
|
411
407
|
return prob.message + (f" → {fix}" if fix else "")
|
|
412
408
|
|
|
@@ -23,8 +23,9 @@ def _who(rep) -> str:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def _where(rep) -> str:
|
|
26
|
+
from .resolve import redact_url
|
|
26
27
|
m = rep.expected.match
|
|
27
|
-
return f"{m.remote} ({m.url})" if m.remote and m.url else str(rep.repo)
|
|
28
|
+
return f"{m.remote} ({redact_url(m.url)})" if m.remote and m.url else str(rep.repo)
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def identity_problems(rep, cfg, committer_email: Optional[str] = None,
|
|
@@ -61,9 +62,13 @@ def trailer_problems(repo: Path, rep, cfg, message: str, warnings: List[str]) ->
|
|
|
61
62
|
warnings.append("message not checked: trailer rules need gitpro's audit package")
|
|
62
63
|
return []
|
|
63
64
|
try:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
if hasattr(trailers, "parse_text"): # exactly what the commit-msg hook reads
|
|
66
|
+
pairs = trailers.parse_text(message, repo)
|
|
67
|
+
else:
|
|
68
|
+
r = subprocess.run(["git", "-C", str(repo), "interpret-trailers", "--parse", "--no-divider"],
|
|
69
|
+
input=message, capture_output=True, text=True, encoding="utf-8",
|
|
70
|
+
errors="replace", timeout=10)
|
|
71
|
+
pairs = [tuple(s.strip() for s in ln.split(":", 1)) for ln in r.stdout.splitlines() if ":" in ln]
|
|
67
72
|
# 08 §11: trailers.policy_for(identity, default), trailers.check(trailers, policy) -> list[str]
|
|
68
73
|
policy = call_spec(trailers.policy_for, [("identity", rep.expected.match.identity),
|
|
69
74
|
("default", cfg.default)])
|
|
@@ -9,6 +9,8 @@ from dataclasses import MISSING, dataclass, field, fields, replace
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Optional, Tuple
|
|
11
11
|
|
|
12
|
+
from .util import expand_home as _expand
|
|
13
|
+
|
|
12
14
|
if sys.version_info >= (3, 11):
|
|
13
15
|
import tomllib
|
|
14
16
|
else:
|
|
@@ -319,10 +321,6 @@ TOP_LEVEL = ("agents", "clone", "default", "forge", "guard", "identity", "policy
|
|
|
319
321
|
HOOKS_DIR = ".config/gitpro/hooks" # gitpro's own global hooks dir, relative to home (06)
|
|
320
322
|
|
|
321
323
|
|
|
322
|
-
def _expand(p: str, home: Path) -> Path:
|
|
323
|
-
return home / p[2:] if p.startswith("~/") else Path(p)
|
|
324
|
-
|
|
325
|
-
|
|
326
324
|
def scan_root_error(r: object) -> str | None:
|
|
327
325
|
"""Why r cannot be a [scan] roots entry, else None. Roots are only walked, never written into a
|
|
328
326
|
gitconfig or ssh config, so unlike PATH_RE any character but a control character is allowed
|
|
@@ -20,6 +20,7 @@ from typing import List, Optional
|
|
|
20
20
|
from . import config, jsonfile, jsonout, resolve
|
|
21
21
|
from .args import capability, fail, guarded
|
|
22
22
|
from .config import ConfigError
|
|
23
|
+
from .util import tilde as _tilde
|
|
23
24
|
|
|
24
25
|
KEY = "initializeCommand"
|
|
25
26
|
CANDIDATES = (".devcontainer/devcontainer.json", ".devcontainer.json")
|
|
@@ -55,12 +56,6 @@ _PIN_RE = re.compile(r"gitpro\s+pin\b[^\n;|&]*?\s(?:-y|--yes)(?=\s|[;|&]|$)")
|
|
|
55
56
|
|
|
56
57
|
# ---------------------------------------------------------------- pure parts
|
|
57
58
|
|
|
58
|
-
def _tilde(path: Path) -> str:
|
|
59
|
-
home = str(Path.home())
|
|
60
|
-
s = str(path)
|
|
61
|
-
return "~" + s[len(home):] if s == home or s.startswith(home + "/") else s
|
|
62
|
-
|
|
63
|
-
|
|
64
59
|
def locate(repo: Path) -> Optional[Path]:
|
|
65
60
|
"""DIR/.devcontainer/devcontainer.json, else DIR/.devcontainer.json, else None."""
|
|
66
61
|
for name in CANDIDATES:
|