tgit 0.34.0__tar.gz → 0.34.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 (54) hide show
  1. {tgit-0.34.0 → tgit-0.34.2}/CHANGELOG.md +20 -0
  2. {tgit-0.34.0 → tgit-0.34.2}/PKG-INFO +1 -1
  3. {tgit-0.34.0 → tgit-0.34.2}/pyproject.toml +1 -1
  4. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_version.py +236 -0
  5. {tgit-0.34.0 → tgit-0.34.2}/tgit/version.py +96 -9
  6. {tgit-0.34.0 → tgit-0.34.2}/uv.lock +492 -486
  7. tgit-0.34.0/.claude/settings.local.json +0 -17
  8. {tgit-0.34.0 → tgit-0.34.2}/.github/workflows/build.yml +0 -0
  9. {tgit-0.34.0 → tgit-0.34.2}/.github/workflows/ci.yml +0 -0
  10. {tgit-0.34.0 → tgit-0.34.2}/.github/workflows/claude-code-review.yml +0 -0
  11. {tgit-0.34.0 → tgit-0.34.2}/.github/workflows/claude.yml +0 -0
  12. {tgit-0.34.0 → tgit-0.34.2}/.gitignore +0 -0
  13. {tgit-0.34.0 → tgit-0.34.2}/.python-version +0 -0
  14. {tgit-0.34.0 → tgit-0.34.2}/.tgit/settings.json +0 -0
  15. {tgit-0.34.0 → tgit-0.34.2}/.vscode/extensions.json +0 -0
  16. {tgit-0.34.0 → tgit-0.34.2}/.vscode/launch.json +0 -0
  17. {tgit-0.34.0 → tgit-0.34.2}/AGENTS.md +0 -0
  18. {tgit-0.34.0 → tgit-0.34.2}/CLAUDE.md +0 -0
  19. {tgit-0.34.0 → tgit-0.34.2}/LICENSE +0 -0
  20. {tgit-0.34.0 → tgit-0.34.2}/README.md +0 -0
  21. {tgit-0.34.0 → tgit-0.34.2}/pyrightconfig.json +0 -0
  22. {tgit-0.34.0 → tgit-0.34.2}/scripts/publish.sh +0 -0
  23. {tgit-0.34.0 → tgit-0.34.2}/scripts/test.sh +0 -0
  24. {tgit-0.34.0 → tgit-0.34.2}/tests/README.md +0 -0
  25. {tgit-0.34.0 → tgit-0.34.2}/tests/__init__.py +0 -0
  26. {tgit-0.34.0 → tgit-0.34.2}/tests/conftest.py +0 -0
  27. {tgit-0.34.0 → tgit-0.34.2}/tests/integration/__init__.py +0 -0
  28. {tgit-0.34.0 → tgit-0.34.2}/tests/integration/test_version_integration.py +0 -0
  29. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/__init__.py +0 -0
  30. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_add.py +0 -0
  31. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_changelog.py +0 -0
  32. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_changelog_coverage.py +0 -0
  33. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_cli.py +0 -0
  34. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_commit.py +0 -0
  35. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_commit_coverage.py +0 -0
  36. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_interactive_settings.py +0 -0
  37. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_settings.py +0 -0
  38. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_types.py +0 -0
  39. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_utils.py +0 -0
  40. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_utils_coverage.py +0 -0
  41. {tgit-0.34.0 → tgit-0.34.2}/tests/unit/test_version_coverage.py +0 -0
  42. {tgit-0.34.0 → tgit-0.34.2}/tgit/__init__.py +0 -0
  43. {tgit-0.34.0 → tgit-0.34.2}/tgit/add.py +0 -0
  44. {tgit-0.34.0 → tgit-0.34.2}/tgit/changelog.py +0 -0
  45. {tgit-0.34.0 → tgit-0.34.2}/tgit/cli.py +0 -0
  46. {tgit-0.34.0 → tgit-0.34.2}/tgit/commit.py +0 -0
  47. {tgit-0.34.0 → tgit-0.34.2}/tgit/constants.py +0 -0
  48. {tgit-0.34.0 → tgit-0.34.2}/tgit/interactive_settings.py +0 -0
  49. {tgit-0.34.0 → tgit-0.34.2}/tgit/prompts/commit.txt +0 -0
  50. {tgit-0.34.0 → tgit-0.34.2}/tgit/py.typed +0 -0
  51. {tgit-0.34.0 → tgit-0.34.2}/tgit/settings.py +0 -0
  52. {tgit-0.34.0 → tgit-0.34.2}/tgit/shared.py +0 -0
  53. {tgit-0.34.0 → tgit-0.34.2}/tgit/types.py +0 -0
  54. {tgit-0.34.0 → tgit-0.34.2}/tgit/utils/__init__.py +0 -0
@@ -1,3 +1,23 @@
1
+ ## v0.34.2
2
+
3
+ [v0.34.1...v0.34.2](https://github.com/Jannchie/tgit/compare/v0.34.1...v0.34.2)
4
+
5
+ ### :adhesive_bandage: Fixes
6
+
7
+ - **version**: replace utf-8 errors handling in version file reads - By [Jannchie](mailto:jannchie@gmail.com) in [27dd1f8](https://github.com/Jannchie/tgit/commit/27dd1f8)
8
+
9
+ ### :test_tube: Tests
10
+
11
+ - **cargo**: add unit tests for cargo version updates - By [Jianqi Pan](mailto:jannchie@gmail.com) in [bce5042](https://github.com/Jannchie/tgit/commit/bce5042)
12
+
13
+ ## v0.34.1
14
+
15
+ [v0.34.0...v0.34.1](https://github.com/Jannchie/tgit/compare/v0.34.0...v0.34.1)
16
+
17
+ ### :adhesive_bandage: Fixes
18
+
19
+ - **version**: add utf-8 encoding for file reading - By [Jannchie](mailto:jannchie@gmail.com) in [d68a815](https://github.com/Jannchie/tgit/commit/d68a815)
20
+
1
21
  ## v0.34.0
2
22
 
3
23
  [v0.33.1...v0.34.0](https://github.com/Jannchie/tgit/compare/v0.33.1...v0.34.0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tgit
3
- Version: 0.34.0
3
+ Version: 0.34.2
4
4
  Summary: AI-powered Git CLI for commits, changelogs, and semantic versioning.
5
5
  Project-URL: Homepage, https://github.com/Jannchie/tgit
6
6
  Project-URL: Repository, https://github.com/Jannchie/tgit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tgit"
3
- version = "0.34.0"
3
+ version = "0.34.2"
4
4
  description = "AI-powered Git CLI for commits, changelogs, and semantic versioning."
5
5
  authors = [{ name = "Jannchie", email = "jannchie@gmail.com" }]
6
6
  dependencies = [
@@ -10,6 +10,8 @@ from tgit.version import (
10
10
  VersionArgs,
11
11
  VersionChoice,
12
12
  _apply_version_choice,
13
+ _find_cargo_lock_for,
14
+ _get_cargo_package_name,
13
15
  _get_default_bump_from_commits,
14
16
  _handle_explicit_version_args,
15
17
  _handle_interactive_version_selection,
@@ -37,6 +39,7 @@ from tgit.version import (
37
39
  get_version_from_version_txt,
38
40
  handle_version,
39
41
  show_file_diff,
42
+ update_cargo_lock_version,
40
43
  update_cargo_toml_version,
41
44
  update_file,
42
45
  update_version_files,
@@ -941,6 +944,239 @@ members = ["other-crate"]
941
944
  update_cargo_toml_version(non_existent_file, "1.0.0", 0, show_diff=False)
942
945
 
943
946
 
947
+ class TestGetCargoPackageName:
948
+ """Test cases for _get_cargo_package_name."""
949
+
950
+ def test_basic(self, tmp_path):
951
+ cargo_toml = tmp_path / "Cargo.toml"
952
+ cargo_toml.write_text('[package]\nname = "arthash"\nversion = "0.2.0"\n')
953
+ assert _get_cargo_package_name(cargo_toml) == "arthash"
954
+
955
+ def test_missing_file(self, tmp_path):
956
+ assert _get_cargo_package_name(tmp_path / "Cargo.toml") is None
957
+
958
+ def test_missing_package_section(self, tmp_path):
959
+ cargo_toml = tmp_path / "Cargo.toml"
960
+ cargo_toml.write_text('[workspace]\nmembers = ["a"]\n')
961
+ assert _get_cargo_package_name(cargo_toml) is None
962
+
963
+ def test_missing_name_key(self, tmp_path):
964
+ cargo_toml = tmp_path / "Cargo.toml"
965
+ cargo_toml.write_text('[package]\nversion = "0.1.0"\n')
966
+ assert _get_cargo_package_name(cargo_toml) is None
967
+
968
+ def test_empty_name(self, tmp_path):
969
+ cargo_toml = tmp_path / "Cargo.toml"
970
+ cargo_toml.write_text('[package]\nname = ""\nversion = "0.1.0"\n')
971
+ assert _get_cargo_package_name(cargo_toml) is None
972
+
973
+ def test_invalid_toml(self, tmp_path):
974
+ cargo_toml = tmp_path / "Cargo.toml"
975
+ cargo_toml.write_text("this is not valid toml [[[")
976
+ assert _get_cargo_package_name(cargo_toml) is None
977
+
978
+
979
+ class TestFindCargoLockFor:
980
+ """Test cases for _find_cargo_lock_for."""
981
+
982
+ def test_same_directory(self, tmp_path):
983
+ """Single-crate layout: Cargo.toml and Cargo.lock side-by-side."""
984
+ (tmp_path / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
985
+ lockfile = tmp_path / "Cargo.lock"
986
+ lockfile.write_text("# lock\n")
987
+ assert _find_cargo_lock_for(tmp_path) == lockfile
988
+
989
+ def test_parent_directory(self, tmp_path):
990
+ """Workspace layout: lockfile lives at workspace root."""
991
+ (tmp_path / ".git").mkdir()
992
+ lockfile = tmp_path / "Cargo.lock"
993
+ lockfile.write_text("# lock\n")
994
+ member = tmp_path / "crates" / "alpha"
995
+ member.mkdir(parents=True)
996
+ (member / "Cargo.toml").write_text('[package]\nname = "alpha"\nversion = "0.1.0"\n')
997
+ assert _find_cargo_lock_for(member) == lockfile
998
+
999
+ def test_no_lockfile_returns_none(self, tmp_path):
1000
+ (tmp_path / ".git").mkdir()
1001
+ crate = tmp_path / "crate"
1002
+ crate.mkdir()
1003
+ (crate / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1004
+ assert _find_cargo_lock_for(crate) is None
1005
+
1006
+ def test_stops_at_git_root(self, tmp_path):
1007
+ """Must not escape the current repo, even if an outer Cargo.lock exists."""
1008
+ outer_lock = tmp_path / "Cargo.lock"
1009
+ outer_lock.write_text("# outer\n")
1010
+ repo = tmp_path / "inner_repo"
1011
+ repo.mkdir()
1012
+ (repo / ".git").mkdir()
1013
+ crate = repo / "crate"
1014
+ crate.mkdir()
1015
+ (crate / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1016
+ # Should NOT find the outer Cargo.lock — that's in a different repo.
1017
+ assert _find_cargo_lock_for(crate) is None
1018
+
1019
+
1020
+ class TestUpdateCargoLockVersion:
1021
+ """Test cases for update_cargo_lock_version."""
1022
+
1023
+ @staticmethod
1024
+ def _make_lockfile(tmp_path: Path, content: str) -> Path:
1025
+ lockfile = tmp_path / "Cargo.lock"
1026
+ lockfile.write_text(content, encoding="utf-8")
1027
+ return lockfile
1028
+
1029
+ def test_local_crate_version_updated(self, tmp_path):
1030
+ """Workspace member (no source field) gets its version rewritten."""
1031
+ content = (
1032
+ "# This file is automatically @generated by Cargo.\n"
1033
+ "version = 4\n\n"
1034
+ "[[package]]\n"
1035
+ 'name = "arthash"\n'
1036
+ 'version = "0.2.0"\n'
1037
+ "dependencies = [\n"
1038
+ ' "matrixmultiply",\n'
1039
+ "]\n"
1040
+ )
1041
+ lockfile = self._make_lockfile(tmp_path, content)
1042
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1043
+ new_content = lockfile.read_text()
1044
+ assert 'version = "0.3.0"' in new_content
1045
+ assert 'version = "0.2.0"' not in new_content
1046
+
1047
+ def test_registry_dep_with_same_name_not_touched(self, tmp_path):
1048
+ """A registry dep named identically to the local crate must NOT be hit.
1049
+
1050
+ Registry entries have `source = "registry+..."` between `name` and
1051
+ `version`, so they fail the "name immediately followed by version"
1052
+ anchor. Local crates fit the anchor exactly.
1053
+ """
1054
+ content = (
1055
+ "[[package]]\n"
1056
+ 'name = "arthash"\n'
1057
+ 'source = "registry+https://github.com/rust-lang/crates.io-index"\n'
1058
+ 'version = "9.9.9"\n'
1059
+ 'checksum = "deadbeef"\n\n'
1060
+ "[[package]]\n"
1061
+ 'name = "arthash"\n'
1062
+ 'version = "0.2.0"\n'
1063
+ "dependencies = [\n"
1064
+ ' "matrixmultiply",\n'
1065
+ "]\n"
1066
+ )
1067
+ lockfile = self._make_lockfile(tmp_path, content)
1068
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1069
+ new_content = lockfile.read_text()
1070
+ # registry entry untouched
1071
+ assert 'version = "9.9.9"' in new_content
1072
+ # local entry bumped
1073
+ assert 'version = "0.3.0"' in new_content
1074
+ # only one occurrence of the new version (didn't accidentally double-write)
1075
+ assert new_content.count('version = "0.3.0"') == 1
1076
+
1077
+ def test_crate_not_in_lockfile_is_noop(self, tmp_path):
1078
+ """If the crate isn't recorded, nothing changes."""
1079
+ content = (
1080
+ "[[package]]\n"
1081
+ 'name = "other"\n'
1082
+ 'version = "1.0.0"\n'
1083
+ )
1084
+ lockfile = self._make_lockfile(tmp_path, content)
1085
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1086
+ assert lockfile.read_text() == content
1087
+
1088
+ def test_missing_lockfile_is_noop(self, tmp_path):
1089
+ """Pure non-Rust projects don't have a Cargo.lock — must not error."""
1090
+ lockfile = tmp_path / "Cargo.lock"
1091
+ # Should not raise.
1092
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1093
+ assert not lockfile.exists()
1094
+
1095
+ def test_already_in_sync_is_noop(self, tmp_path):
1096
+ """Idempotent: same version in -> file unchanged."""
1097
+ content = (
1098
+ "[[package]]\n"
1099
+ 'name = "arthash"\n'
1100
+ 'version = "0.3.0"\n'
1101
+ )
1102
+ lockfile = self._make_lockfile(tmp_path, content)
1103
+ before = lockfile.stat().st_mtime_ns
1104
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1105
+ # Content identical; we explicitly skip the write in that case.
1106
+ assert lockfile.read_text() == content
1107
+ # And the file was not rewritten (mtime preserved). Some filesystems
1108
+ # have coarse mtime resolution, so this guard is a tightening only —
1109
+ # the content check above is the real assertion.
1110
+ assert lockfile.stat().st_mtime_ns == before
1111
+
1112
+
1113
+ class TestUpdateVersionInFileCargoIntegration:
1114
+ """update_version_in_file should sync Cargo.lock when it sees Cargo.toml."""
1115
+
1116
+ def test_cargo_toml_branch_syncs_sibling_lockfile(self, tmp_path):
1117
+ cargo_toml = tmp_path / "Cargo.toml"
1118
+ cargo_toml.write_text(
1119
+ "[package]\n"
1120
+ 'name = "arthash"\n'
1121
+ 'version = "0.2.0"\n'
1122
+ "\n"
1123
+ "[dependencies]\n"
1124
+ 'matrixmultiply = "0.3"\n',
1125
+ encoding="utf-8",
1126
+ )
1127
+ lockfile = tmp_path / "Cargo.lock"
1128
+ lockfile.write_text(
1129
+ "[[package]]\n"
1130
+ 'name = "arthash"\n'
1131
+ 'version = "0.2.0"\n'
1132
+ "dependencies = [\n"
1133
+ ' "matrixmultiply",\n'
1134
+ "]\n",
1135
+ encoding="utf-8",
1136
+ )
1137
+ update_version_in_file(0, "0.3.0", "Cargo.toml", cargo_toml, show_diff=False)
1138
+ # Both files should now report 0.3.0.
1139
+ assert 'version = "0.3.0"' in cargo_toml.read_text()
1140
+ assert 'version = "0.3.0"' in lockfile.read_text()
1141
+
1142
+ def test_cargo_toml_branch_syncs_workspace_root_lockfile(self, tmp_path):
1143
+ """Lockfile in workspace root, Cargo.toml in a member directory."""
1144
+ (tmp_path / ".git").mkdir()
1145
+ lockfile = tmp_path / "Cargo.lock"
1146
+ lockfile.write_text(
1147
+ "[[package]]\n"
1148
+ 'name = "alpha"\n'
1149
+ 'version = "0.2.0"\n',
1150
+ encoding="utf-8",
1151
+ )
1152
+ member_dir = tmp_path / "crates" / "alpha"
1153
+ member_dir.mkdir(parents=True)
1154
+ cargo_toml = member_dir / "Cargo.toml"
1155
+ cargo_toml.write_text(
1156
+ "[package]\n"
1157
+ 'name = "alpha"\n'
1158
+ 'version = "0.2.0"\n',
1159
+ encoding="utf-8",
1160
+ )
1161
+ update_version_in_file(0, "0.3.0", "Cargo.toml", cargo_toml, show_diff=False)
1162
+ assert 'version = "0.3.0"' in cargo_toml.read_text()
1163
+ assert 'version = "0.3.0"' in lockfile.read_text()
1164
+
1165
+ def test_cargo_toml_branch_no_lockfile_present(self, tmp_path):
1166
+ """When there's no lockfile at all, the manifest update still proceeds."""
1167
+ (tmp_path / ".git").mkdir()
1168
+ cargo_toml = tmp_path / "Cargo.toml"
1169
+ cargo_toml.write_text(
1170
+ "[package]\n"
1171
+ 'name = "alpha"\n'
1172
+ 'version = "0.2.0"\n',
1173
+ encoding="utf-8",
1174
+ )
1175
+ # Should not raise.
1176
+ update_version_in_file(0, "0.3.0", "Cargo.toml", cargo_toml, show_diff=False)
1177
+ assert 'version = "0.3.0"' in cargo_toml.read_text()
1178
+
1179
+
944
1180
  class TestParseGitignore:
945
1181
  """Test cases for _parse_gitignore function."""
946
1182
 
@@ -183,7 +183,7 @@ def _get_version_from_other_files(path: Path) -> Version | None: # noqa: PLR091
183
183
  def get_version_from_package_json(path: Path) -> Version | None:
184
184
  package_json_path = path / "package.json"
185
185
  if package_json_path.exists():
186
- with package_json_path.open() as f:
186
+ with package_json_path.open(encoding="utf-8") as f:
187
187
  json_data = json.load(f)
188
188
  if version := json_data.get("version"):
189
189
  try:
@@ -354,7 +354,7 @@ def get_version_from_chart_yaml(path: Path) -> Version | None:
354
354
  def get_version_from_setup_py(path: Path) -> Version | None:
355
355
  setup_py_path = path / "setup.py"
356
356
  if setup_py_path.exists():
357
- with setup_py_path.open() as f:
357
+ with setup_py_path.open(encoding="utf-8", errors="replace") as f:
358
358
  setup_data = f.read()
359
359
  if res := re.search(r"version=['\"]([^'\"]+)['\"]", setup_data):
360
360
  try:
@@ -413,7 +413,7 @@ def get_version_from_cargo_toml(directory_path: Path) -> Version | None:
413
413
  def get_version_from_version_file(path: Path) -> Version | None:
414
414
  version_path = path / "VERSION"
415
415
  if version_path.exists():
416
- with version_path.open() as f:
416
+ with version_path.open(encoding="utf-8", errors="replace") as f:
417
417
  version = f.read().strip()
418
418
  try:
419
419
  return Version.from_str(version)
@@ -425,7 +425,7 @@ def get_version_from_version_file(path: Path) -> Version | None:
425
425
  def get_version_from_version_txt(path: Path) -> Version | None:
426
426
  version_txt_path = path / "VERSION.txt"
427
427
  if version_txt_path.exists():
428
- with version_txt_path.open() as f:
428
+ with version_txt_path.open(encoding="utf-8", errors="replace") as f:
429
429
  version = f.read().strip()
430
430
  try:
431
431
  return Version.from_str(version)
@@ -437,7 +437,7 @@ def get_version_from_version_txt(path: Path) -> Version | None:
437
437
  def get_version_from_build_gradle_kts(path: Path) -> Version | None:
438
438
  build_gradle_kts_path = path / "build.gradle.kts"
439
439
  if build_gradle_kts_path.exists():
440
- with build_gradle_kts_path.open() as f:
440
+ with build_gradle_kts_path.open(encoding="utf-8", errors="replace") as f:
441
441
  content = f.read()
442
442
  if res := re.search(r'version\s*=\s*"([^"]+)"', content):
443
443
  try:
@@ -875,7 +875,18 @@ def update_version_in_file(verbose: int, next_version_str: str, file: str, file_
875
875
  elif file == "setup.py":
876
876
  update_file(str(file_path), r"version=['\"].*?['\"]", f"version='{next_version_str}'", verbose, show_diff=show_diff)
877
877
  elif file == "Cargo.toml":
878
+ crate_name = _get_cargo_package_name(file_path)
878
879
  update_cargo_toml_version(str(file_path), next_version_str, verbose, show_diff=show_diff)
880
+ # Cargo.lock pins every workspace member's version. Without
881
+ # syncing it here, `cargo publish` and `cargo build --locked`
882
+ # refuse to run against a manifest that disagrees with the
883
+ # lockfile — the most common reason a release tag gets bumped
884
+ # but the publish CI step fails on "working directory contains
885
+ # changes". Idempotent / no-op for pure non-Rust projects.
886
+ if crate_name:
887
+ lockfile = _find_cargo_lock_for(file_path.parent)
888
+ if lockfile is not None:
889
+ update_cargo_lock_version(crate_name, next_version_str, lockfile, verbose, show_diff=show_diff)
879
890
  elif file in ("VERSION", "VERSION.txt"):
880
891
  update_file(str(file_path), None, next_version_str, verbose, show_diff=show_diff)
881
892
  elif file in ("__about__.py", "__init__.py"):
@@ -918,12 +929,12 @@ def update_file(filename: str, search_pattern: str | None, replace_text: str, ve
918
929
  return
919
930
  if verbose > 0:
920
931
  console.print(f"Updating {file_path}")
921
- with file_path.open() as f:
932
+ with file_path.open(encoding="utf-8", errors="replace") as f:
922
933
  content = f.read()
923
934
  new_content = re.sub(search_pattern, replace_text, content) if search_pattern else replace_text
924
935
  if show_diff:
925
936
  show_file_diff(content, new_content, str(file_path))
926
- with file_path.open("w") as f:
937
+ with file_path.open("w", encoding="utf-8") as f:
927
938
  f.write(new_content)
928
939
 
929
940
 
@@ -935,7 +946,7 @@ def update_cargo_toml_version(filename: str, next_version_str: str, verbose: int
935
946
  if verbose > 0:
936
947
  console.print(f"Updating {file_path}")
937
948
 
938
- with file_path.open() as f:
949
+ with file_path.open(encoding="utf-8", errors="replace") as f:
939
950
  content = f.read()
940
951
 
941
952
  # Use regex to match version in [package] section only
@@ -954,10 +965,86 @@ def update_cargo_toml_version(filename: str, next_version_str: str, verbose: int
954
965
  if show_diff:
955
966
  show_file_diff(content, new_content, str(file_path))
956
967
 
957
- with file_path.open("w") as f:
968
+ with file_path.open("w", encoding="utf-8") as f:
958
969
  f.write(new_content)
959
970
 
960
971
 
972
+ def _get_cargo_package_name(cargo_toml_path: Path) -> str | None:
973
+ """Read [package].name from a Cargo.toml. Returns None if missing/invalid.
974
+
975
+ Used to anchor lockfile rewrites — we need the crate's own name to
976
+ find its entry in Cargo.lock.
977
+ """
978
+ if not cargo_toml_path.is_file():
979
+ return None
980
+ try:
981
+ with cargo_toml_path.open("rb") as f:
982
+ data = tomllib.load(f)
983
+ except (OSError, tomllib.TOMLDecodeError):
984
+ return None
985
+ package = data.get("package")
986
+ if not isinstance(package, dict):
987
+ return None
988
+ name = package.get("name")
989
+ return name if isinstance(name, str) and name else None
990
+
991
+
992
+ def _find_cargo_lock_for(cargo_toml_dir: Path) -> Path | None:
993
+ """Walk up from a Cargo.toml's directory looking for a Cargo.lock.
994
+
995
+ Covers both layouts:
996
+ * single crate with its own Cargo.lock (lockfile in same dir)
997
+ * cargo workspace where members share a root-level Cargo.lock
998
+
999
+ Stops at the directory containing .git (we never escape the current
1000
+ repo) or at the filesystem root.
1001
+ """
1002
+ current = cargo_toml_dir.resolve()
1003
+ while True:
1004
+ candidate = current / "Cargo.lock"
1005
+ if candidate.is_file():
1006
+ return candidate
1007
+ if (current / ".git").exists():
1008
+ return None
1009
+ if current.parent == current:
1010
+ return None
1011
+ current = current.parent
1012
+
1013
+
1014
+ def update_cargo_lock_version(
1015
+ crate_name: str,
1016
+ next_version_str: str,
1017
+ lockfile_path: Path,
1018
+ verbose: int,
1019
+ *,
1020
+ show_diff: bool = True,
1021
+ ) -> None:
1022
+ """Sync a local crate's version in Cargo.lock.
1023
+
1024
+ Cargo.lock records every dep, but registry deps carry a
1025
+ `source = "registry+..."` line between `name` and `version`.
1026
+ Workspace members / path deps do NOT — their `version` is on the
1027
+ line immediately after `name`. We anchor on that two-line shape so
1028
+ registry deps with the same name as a local crate can't be hit by
1029
+ mistake. No-op if the crate isn't in this lockfile or is already
1030
+ in sync.
1031
+ """
1032
+ if not lockfile_path.is_file():
1033
+ return
1034
+ if verbose > 0:
1035
+ console.print(f"Updating {lockfile_path}")
1036
+ content = lockfile_path.read_text(encoding="utf-8")
1037
+ pattern = re.compile(
1038
+ rf'(\[\[package\]\]\nname = "{re.escape(crate_name)}"\nversion = ")[^"]+(")',
1039
+ )
1040
+ new_content, n = pattern.subn(rf"\g<1>{next_version_str}\g<2>", content, count=1)
1041
+ if n == 0 or new_content == content:
1042
+ return
1043
+ if show_diff:
1044
+ show_file_diff(content, new_content, str(lockfile_path))
1045
+ lockfile_path.write_text(new_content, encoding="utf-8")
1046
+
1047
+
961
1048
  def show_file_diff(old_content: str, new_content: str, filename: str) -> None:
962
1049
  old_lines = old_content.splitlines()
963
1050
  new_lines = new_content.splitlines()