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.
Files changed (58) hide show
  1. {gitpro-0.4.0 → gitpro-0.4.2}/PKG-INFO +6 -6
  2. {gitpro-0.4.0 → gitpro-0.4.2}/README.md +5 -5
  3. {gitpro-0.4.0 → gitpro-0.4.2}/pyproject.toml +2 -1
  4. gitpro-0.4.2/src/gitpro/VERSION +5 -0
  5. gitpro-0.4.2/src/gitpro/__init__.py +10 -0
  6. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/agents.py +8 -32
  7. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/approval_policy.py +2 -1
  8. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/args.py +11 -0
  9. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/audit.py +8 -19
  10. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/change_contract.py +4 -5
  11. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/changes.py +8 -16
  12. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/cli.py +5 -27
  13. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/clone.py +4 -8
  14. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/commitcheck.py +9 -4
  15. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/config.py +2 -4
  16. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/devcontainer.py +1 -6
  17. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/discover.py +7 -17
  18. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/doctor.py +3 -6
  19. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/envcmd.py +1 -6
  20. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/files.py +11 -2
  21. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/forge.py +23 -29
  22. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/guard.py +18 -15
  23. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/health.py +11 -34
  24. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/incident.py +3 -21
  25. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/jsonfile.py +65 -14
  26. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/keys.py +1 -1
  27. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/lint.py +1 -1
  28. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/lost.py +7 -16
  29. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/mcp.py +13 -52
  30. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operation_contract.py +16 -1
  31. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operation_reconcile.py +2 -3
  32. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operations.py +25 -12
  33. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/pin.py +1 -1
  34. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/policy.py +3 -3
  35. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/render.py +1 -4
  36. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/resolve.py +40 -33
  37. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/trailers.py +9 -5
  38. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/tui.py +1 -1
  39. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/undo.py +3 -8
  40. gitpro-0.4.2/src/gitpro/util.py +34 -0
  41. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/view.py +2 -5
  42. gitpro-0.4.0/src/gitpro/__init__.py +0 -4
  43. {gitpro-0.4.0 → gitpro-0.4.2}/.gitignore +0 -0
  44. {gitpro-0.4.0 → gitpro-0.4.2}/LICENSE +0 -0
  45. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/__main__.py +0 -0
  46. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/completion.py +0 -0
  47. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/deprecation.py +0 -0
  48. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/example.toml +0 -0
  49. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/hot.py +0 -0
  50. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/jsonout.py +0 -0
  51. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/learn.py +0 -0
  52. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/operation_io.py +0 -0
  53. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/plan.py +0 -0
  54. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/prompt.py +0 -0
  55. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/report.py +0 -0
  56. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/scan.py +0 -0
  57. {gitpro-0.4.0 → gitpro-0.4.2}/src/gitpro/scanroots.py +0 -0
  58. {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.0
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
+ ![gitpro refuses a commit in a work repo that would use the personal email, removes the repo's own email setting, and the commit then uses the work email, while a personal repo keeps the personal one](https://gitpro.dev/demo.gif)
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
- `gitpro why`; it shows you the draft and opens nothing until you say yes.
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
+ ![gitpro refuses a commit in a work repo that would use the personal email, removes the repo's own email setting, and the commit then uses the work email, while a personal repo keeps the personal one](https://gitpro.dev/demo.gif)
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
- `gitpro why`; it shows you the draft and opens nothing until you say yes.
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/__init__.py"
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
- shown = undo.tilde(path)
1660
- try:
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
- try:
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
- env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
1693
- return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, env=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 agent status shows it)")
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=_confirm_with(args, question), mkdir=True, snippet=_snippet(t))
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 agent install --agent claude rewrites the entry with "
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 any(ord(c) < 32 or ord(c) == 127 for c in value):
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", "--rewrite"]
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("--rewrite needs the author audit; drop --agents")
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 --rewrite (there is no terminal to confirm on)")
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="--rewrite fixes only the unpushed offending commits, keeps every date and never runs rebase; "
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 guard install) the commit-msg "
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": ["ready", "blocked", "needs_input",
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 any(ord(c) < 32 or ord(c) == 127 for c in str(source)):
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 any(ord(c) < 32 or ord(c) == 127 for c in str(repo)):
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
- _sync_dir(path.parent)
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
- _sync_dir(lock.parent)
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
- _sync_dir(parent.parent)
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
- _sync_dir(path.parent)
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
- _sync_dir(path.parent)
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
- if source == "-":
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 0 if result["ok"] else (3 if result["decision"] == "blocked" else 2)
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
- target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
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 _fail("run as the user who owns these identities, not root", args)
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 _CONTROL_RE.search(url) or url.startswith("-"):
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 scan --suggest --repo {_sh(_tilde(top, home))}")
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
- r = subprocess.run(["git", "-C", str(repo), "interpret-trailers", "--parse"], input=message,
65
- capture_output=True, text=True, timeout=10)
66
- pairs = [tuple(s.strip() for s in ln.split(":", 1)) for ln in r.stdout.splitlines() if ":" in ln]
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: