tgit 0.34.1__tar.gz → 0.35.0__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.1 → tgit-0.35.0}/CHANGELOG.md +24 -0
  2. {tgit-0.34.1 → tgit-0.35.0}/PKG-INFO +1 -1
  3. {tgit-0.34.1 → tgit-0.35.0}/pyproject.toml +1 -1
  4. {tgit-0.34.1 → tgit-0.35.0}/tests/integration/test_version_integration.py +104 -0
  5. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_version.py +445 -0
  6. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_version_coverage.py +2 -4
  7. {tgit-0.34.1 → tgit-0.35.0}/tgit/prompts/commit.txt +22 -6
  8. {tgit-0.34.1 → tgit-0.35.0}/tgit/version.py +311 -24
  9. {tgit-0.34.1 → tgit-0.35.0}/uv.lock +221 -244
  10. tgit-0.34.1/.claude/settings.local.json +0 -17
  11. {tgit-0.34.1 → tgit-0.35.0}/.github/workflows/build.yml +0 -0
  12. {tgit-0.34.1 → tgit-0.35.0}/.github/workflows/ci.yml +0 -0
  13. {tgit-0.34.1 → tgit-0.35.0}/.github/workflows/claude-code-review.yml +0 -0
  14. {tgit-0.34.1 → tgit-0.35.0}/.github/workflows/claude.yml +0 -0
  15. {tgit-0.34.1 → tgit-0.35.0}/.gitignore +0 -0
  16. {tgit-0.34.1 → tgit-0.35.0}/.python-version +0 -0
  17. {tgit-0.34.1 → tgit-0.35.0}/.tgit/settings.json +0 -0
  18. {tgit-0.34.1 → tgit-0.35.0}/.vscode/extensions.json +0 -0
  19. {tgit-0.34.1 → tgit-0.35.0}/.vscode/launch.json +0 -0
  20. {tgit-0.34.1 → tgit-0.35.0}/AGENTS.md +0 -0
  21. {tgit-0.34.1 → tgit-0.35.0}/CLAUDE.md +0 -0
  22. {tgit-0.34.1 → tgit-0.35.0}/LICENSE +0 -0
  23. {tgit-0.34.1 → tgit-0.35.0}/README.md +0 -0
  24. {tgit-0.34.1 → tgit-0.35.0}/pyrightconfig.json +0 -0
  25. {tgit-0.34.1 → tgit-0.35.0}/scripts/publish.sh +0 -0
  26. {tgit-0.34.1 → tgit-0.35.0}/scripts/test.sh +0 -0
  27. {tgit-0.34.1 → tgit-0.35.0}/tests/README.md +0 -0
  28. {tgit-0.34.1 → tgit-0.35.0}/tests/__init__.py +0 -0
  29. {tgit-0.34.1 → tgit-0.35.0}/tests/conftest.py +0 -0
  30. {tgit-0.34.1 → tgit-0.35.0}/tests/integration/__init__.py +0 -0
  31. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/__init__.py +0 -0
  32. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_add.py +0 -0
  33. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_changelog.py +0 -0
  34. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_changelog_coverage.py +0 -0
  35. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_cli.py +0 -0
  36. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_commit.py +0 -0
  37. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_commit_coverage.py +0 -0
  38. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_interactive_settings.py +0 -0
  39. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_settings.py +0 -0
  40. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_types.py +0 -0
  41. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_utils.py +0 -0
  42. {tgit-0.34.1 → tgit-0.35.0}/tests/unit/test_utils_coverage.py +0 -0
  43. {tgit-0.34.1 → tgit-0.35.0}/tgit/__init__.py +0 -0
  44. {tgit-0.34.1 → tgit-0.35.0}/tgit/add.py +0 -0
  45. {tgit-0.34.1 → tgit-0.35.0}/tgit/changelog.py +0 -0
  46. {tgit-0.34.1 → tgit-0.35.0}/tgit/cli.py +0 -0
  47. {tgit-0.34.1 → tgit-0.35.0}/tgit/commit.py +0 -0
  48. {tgit-0.34.1 → tgit-0.35.0}/tgit/constants.py +0 -0
  49. {tgit-0.34.1 → tgit-0.35.0}/tgit/interactive_settings.py +0 -0
  50. {tgit-0.34.1 → tgit-0.35.0}/tgit/py.typed +0 -0
  51. {tgit-0.34.1 → tgit-0.35.0}/tgit/settings.py +0 -0
  52. {tgit-0.34.1 → tgit-0.35.0}/tgit/shared.py +0 -0
  53. {tgit-0.34.1 → tgit-0.35.0}/tgit/types.py +0 -0
  54. {tgit-0.34.1 → tgit-0.35.0}/tgit/utils/__init__.py +0 -0
@@ -1,3 +1,27 @@
1
+ ## v0.35.0
2
+
3
+ [v0.34.2...v0.35.0](https://github.com/Jannchie/tgit/compare/v0.34.2...v0.35.0)
4
+
5
+ ### :sparkles: Features
6
+
7
+ - **version**: add support for syncing Cargo.lock with Cargo.toml - By [Jannchie](mailto:jannchie@gmail.com) in [7ca29ae](https://github.com/Jannchie/tgit/commit/7ca29ae)
8
+
9
+ ### :wrench: Chores
10
+
11
+ - update risk detection guidelines - By [Jannchie](mailto:jannchie@gmail.com) in [b5e0762](https://github.com/Jannchie/tgit/commit/b5e0762)
12
+
13
+ ## v0.34.2
14
+
15
+ [v0.34.1...v0.34.2](https://github.com/Jannchie/tgit/compare/v0.34.1...v0.34.2)
16
+
17
+ ### :adhesive_bandage: Fixes
18
+
19
+ - **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)
20
+
21
+ ### :test_tube: Tests
22
+
23
+ - **cargo**: add unit tests for cargo version updates - By [Jianqi Pan](mailto:jannchie@gmail.com) in [bce5042](https://github.com/Jannchie/tgit/commit/bce5042)
24
+
1
25
  ## v0.34.1
2
26
 
3
27
  [v0.34.0...v0.34.1](https://github.com/Jannchie/tgit/compare/v0.34.0...v0.34.1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tgit
3
- Version: 0.34.1
3
+ Version: 0.35.0
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.1"
3
+ version = "0.35.0"
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 = [
@@ -305,6 +305,110 @@ description = "Test package"
305
305
  node_modules_content = json.loads(node_modules_package_json.read_text())
306
306
  assert node_modules_content["version"] == "1.0.0"
307
307
 
308
+ def _build_args(self, tmp_path, *, recursive: bool) -> VersionArgs:
309
+ return VersionArgs(
310
+ version="",
311
+ verbose=0,
312
+ no_commit=True,
313
+ no_tag=True,
314
+ no_push=True,
315
+ patch=False,
316
+ minor=False,
317
+ major=False,
318
+ prepatch="",
319
+ preminor="",
320
+ premajor="",
321
+ recursive=recursive,
322
+ custom="",
323
+ path=str(tmp_path),
324
+ )
325
+
326
+ def test_update_version_files_syncs_cargo_lock(self, tmp_path):
327
+ """End-to-end: bumping a crate's Cargo.toml also rewrites its
328
+ Cargo.lock entry. The lockfile lives next to the manifest."""
329
+ (tmp_path / "Cargo.toml").write_text(
330
+ '[package]\nname = "demo"\nversion = "0.1.0"\n',
331
+ encoding="utf-8",
332
+ )
333
+ (tmp_path / "Cargo.lock").write_text(
334
+ '[[package]]\nname = "demo"\nversion = "0.1.0"\n',
335
+ encoding="utf-8",
336
+ )
337
+ update_version_files(
338
+ self._build_args(tmp_path, recursive=False),
339
+ Version(major=0, minor=2, patch=0),
340
+ verbose=0,
341
+ recursive=False,
342
+ )
343
+ assert 'version = "0.2.0"' in (tmp_path / "Cargo.toml").read_text()
344
+ assert 'version = "0.2.0"' in (tmp_path / "Cargo.lock").read_text()
345
+
346
+ def test_update_version_files_workspace_lockfile_dedups(self, tmp_path):
347
+ """Cargo workspace with two members and one shared lockfile —
348
+ both [[package]] entries get rewritten, but the lockfile is
349
+ not duplicated."""
350
+ (tmp_path / ".git").mkdir()
351
+ (tmp_path / "Cargo.toml").write_text(
352
+ '[workspace]\nmembers = ["crates/alpha", "crates/beta"]\n',
353
+ encoding="utf-8",
354
+ )
355
+ (tmp_path / "Cargo.lock").write_text(
356
+ '[[package]]\nname = "alpha"\nversion = "0.1.0"\n\n'
357
+ '[[package]]\nname = "beta"\nversion = "0.1.0"\n',
358
+ encoding="utf-8",
359
+ )
360
+ alpha = tmp_path / "crates" / "alpha"
361
+ alpha.mkdir(parents=True)
362
+ (alpha / "Cargo.toml").write_text(
363
+ '[package]\nname = "alpha"\nversion = "0.1.0"\n',
364
+ encoding="utf-8",
365
+ )
366
+ beta = tmp_path / "crates" / "beta"
367
+ beta.mkdir(parents=True)
368
+ (beta / "Cargo.toml").write_text(
369
+ '[package]\nname = "beta"\nversion = "0.1.0"\n',
370
+ encoding="utf-8",
371
+ )
372
+ update_version_files(
373
+ self._build_args(tmp_path, recursive=True),
374
+ Version(major=0, minor=2, patch=0),
375
+ verbose=0,
376
+ recursive=True,
377
+ )
378
+ lockfile_content = (tmp_path / "Cargo.lock").read_text()
379
+ assert lockfile_content.count('version = "0.2.0"') == 2
380
+ assert 'version = "0.1.0"' not in lockfile_content
381
+
382
+ def test_update_version_files_pnpm_root_without_version(self, tmp_path):
383
+ """pnpm workspace root without `version`: bump must insert
384
+ one in the root package.json and update children too."""
385
+ (tmp_path / "package.json").write_text(
386
+ '{\n "name": "monorepo",\n "private": true\n}\n',
387
+ encoding="utf-8",
388
+ )
389
+ (tmp_path / "pnpm-workspace.yaml").write_text(
390
+ 'packages:\n - "packages/*"\n',
391
+ encoding="utf-8",
392
+ )
393
+ alpha = tmp_path / "packages" / "alpha"
394
+ alpha.mkdir(parents=True)
395
+ (alpha / "package.json").write_text(
396
+ '{"name": "@x/alpha", "version": "0.1.0"}\n',
397
+ encoding="utf-8",
398
+ )
399
+
400
+ update_version_files(
401
+ self._build_args(tmp_path, recursive=True),
402
+ Version(major=0, minor=2, patch=0),
403
+ verbose=0,
404
+ recursive=True,
405
+ )
406
+ root_data = json.loads((tmp_path / "package.json").read_text())
407
+ assert root_data["version"] == "0.2.0"
408
+ assert root_data["name"] == "monorepo"
409
+ child_data = json.loads((alpha / "package.json").read_text())
410
+ assert child_data["version"] == "0.2.0"
411
+
308
412
  @patch("tgit.version.get_next_version")
309
413
  @patch("tgit.version.get_current_version")
310
414
  @patch("tgit.version.update_version_files")
@@ -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,
@@ -17,8 +19,11 @@ from tgit.version import (
17
19
  _parse_gitignore,
18
20
  _prompt_for_version_choice,
19
21
  _should_ignore_path,
22
+ _version_from_workspace_children,
20
23
  bump_version,
24
+ cargo_lockfiles_for_manifests,
21
25
  execute_git_commands,
26
+ find_workspace_package_jsons,
22
27
  format_diff_lines,
23
28
  get_current_version,
24
29
  get_custom_version,
@@ -37,8 +42,11 @@ from tgit.version import (
37
42
  get_version_from_version_txt,
38
43
  handle_version,
39
44
  show_file_diff,
45
+ sync_cargo_lockfiles,
46
+ update_cargo_lock_version,
40
47
  update_cargo_toml_version,
41
48
  update_file,
49
+ update_package_json_version,
42
50
  update_version_files,
43
51
  update_version_in_file,
44
52
  version,
@@ -941,6 +949,443 @@ members = ["other-crate"]
941
949
  update_cargo_toml_version(non_existent_file, "1.0.0", 0, show_diff=False)
942
950
 
943
951
 
952
+ class TestGetCargoPackageName:
953
+ """Test cases for _get_cargo_package_name."""
954
+
955
+ def test_basic(self, tmp_path):
956
+ cargo_toml = tmp_path / "Cargo.toml"
957
+ cargo_toml.write_text('[package]\nname = "arthash"\nversion = "0.2.0"\n')
958
+ assert _get_cargo_package_name(cargo_toml) == "arthash"
959
+
960
+ def test_missing_file(self, tmp_path):
961
+ assert _get_cargo_package_name(tmp_path / "Cargo.toml") is None
962
+
963
+ def test_missing_package_section(self, tmp_path):
964
+ cargo_toml = tmp_path / "Cargo.toml"
965
+ cargo_toml.write_text('[workspace]\nmembers = ["a"]\n')
966
+ assert _get_cargo_package_name(cargo_toml) is None
967
+
968
+ def test_missing_name_key(self, tmp_path):
969
+ cargo_toml = tmp_path / "Cargo.toml"
970
+ cargo_toml.write_text('[package]\nversion = "0.1.0"\n')
971
+ assert _get_cargo_package_name(cargo_toml) is None
972
+
973
+ def test_empty_name(self, tmp_path):
974
+ cargo_toml = tmp_path / "Cargo.toml"
975
+ cargo_toml.write_text('[package]\nname = ""\nversion = "0.1.0"\n')
976
+ assert _get_cargo_package_name(cargo_toml) is None
977
+
978
+ def test_invalid_toml(self, tmp_path):
979
+ cargo_toml = tmp_path / "Cargo.toml"
980
+ cargo_toml.write_text("this is not valid toml [[[")
981
+ assert _get_cargo_package_name(cargo_toml) is None
982
+
983
+
984
+ class TestFindCargoLockFor:
985
+ """Test cases for _find_cargo_lock_for."""
986
+
987
+ def test_same_directory(self, tmp_path):
988
+ """Single-crate layout: Cargo.toml and Cargo.lock side-by-side."""
989
+ (tmp_path / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
990
+ lockfile = tmp_path / "Cargo.lock"
991
+ lockfile.write_text("# lock\n")
992
+ assert _find_cargo_lock_for(tmp_path) == lockfile
993
+
994
+ def test_parent_directory(self, tmp_path):
995
+ """Workspace layout: lockfile lives at workspace root."""
996
+ (tmp_path / ".git").mkdir()
997
+ lockfile = tmp_path / "Cargo.lock"
998
+ lockfile.write_text("# lock\n")
999
+ member = tmp_path / "crates" / "alpha"
1000
+ member.mkdir(parents=True)
1001
+ (member / "Cargo.toml").write_text('[package]\nname = "alpha"\nversion = "0.1.0"\n')
1002
+ assert _find_cargo_lock_for(member) == lockfile
1003
+
1004
+ def test_no_lockfile_returns_none(self, tmp_path):
1005
+ (tmp_path / ".git").mkdir()
1006
+ crate = tmp_path / "crate"
1007
+ crate.mkdir()
1008
+ (crate / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1009
+ assert _find_cargo_lock_for(crate) is None
1010
+
1011
+ def test_stops_at_git_root(self, tmp_path):
1012
+ """Must not escape the current repo, even if an outer Cargo.lock exists."""
1013
+ outer_lock = tmp_path / "Cargo.lock"
1014
+ outer_lock.write_text("# outer\n")
1015
+ repo = tmp_path / "inner_repo"
1016
+ repo.mkdir()
1017
+ (repo / ".git").mkdir()
1018
+ crate = repo / "crate"
1019
+ crate.mkdir()
1020
+ (crate / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1021
+ # Should NOT find the outer Cargo.lock — that's in a different repo.
1022
+ assert _find_cargo_lock_for(crate) is None
1023
+
1024
+
1025
+ class TestUpdateCargoLockVersion:
1026
+ """Test cases for update_cargo_lock_version."""
1027
+
1028
+ @staticmethod
1029
+ def _make_lockfile(tmp_path: Path, content: str) -> Path:
1030
+ lockfile = tmp_path / "Cargo.lock"
1031
+ lockfile.write_text(content, encoding="utf-8")
1032
+ return lockfile
1033
+
1034
+ def test_local_crate_version_updated(self, tmp_path):
1035
+ """Workspace member (no source field) gets its version rewritten."""
1036
+ content = (
1037
+ "# This file is automatically @generated by Cargo.\n"
1038
+ "version = 4\n\n"
1039
+ "[[package]]\n"
1040
+ 'name = "arthash"\n'
1041
+ 'version = "0.2.0"\n'
1042
+ "dependencies = [\n"
1043
+ ' "matrixmultiply",\n'
1044
+ "]\n"
1045
+ )
1046
+ lockfile = self._make_lockfile(tmp_path, content)
1047
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1048
+ new_content = lockfile.read_text()
1049
+ assert 'version = "0.3.0"' in new_content
1050
+ assert 'version = "0.2.0"' not in new_content
1051
+
1052
+ def test_registry_dep_with_same_name_not_touched(self, tmp_path):
1053
+ """A registry dep named identically to the local crate must NOT be hit.
1054
+
1055
+ Registry entries have `source = "registry+..."` between `name` and
1056
+ `version`, so they fail the "name immediately followed by version"
1057
+ anchor. Local crates fit the anchor exactly.
1058
+ """
1059
+ content = (
1060
+ "[[package]]\n"
1061
+ 'name = "arthash"\n'
1062
+ 'source = "registry+https://github.com/rust-lang/crates.io-index"\n'
1063
+ 'version = "9.9.9"\n'
1064
+ 'checksum = "deadbeef"\n\n'
1065
+ "[[package]]\n"
1066
+ 'name = "arthash"\n'
1067
+ 'version = "0.2.0"\n'
1068
+ "dependencies = [\n"
1069
+ ' "matrixmultiply",\n'
1070
+ "]\n"
1071
+ )
1072
+ lockfile = self._make_lockfile(tmp_path, content)
1073
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1074
+ new_content = lockfile.read_text()
1075
+ # registry entry untouched
1076
+ assert 'version = "9.9.9"' in new_content
1077
+ # local entry bumped
1078
+ assert 'version = "0.3.0"' in new_content
1079
+ # only one occurrence of the new version (didn't accidentally double-write)
1080
+ assert new_content.count('version = "0.3.0"') == 1
1081
+
1082
+ def test_crate_not_in_lockfile_is_noop(self, tmp_path):
1083
+ """If the crate isn't recorded, nothing changes."""
1084
+ content = (
1085
+ "[[package]]\n"
1086
+ 'name = "other"\n'
1087
+ 'version = "1.0.0"\n'
1088
+ )
1089
+ lockfile = self._make_lockfile(tmp_path, content)
1090
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1091
+ assert lockfile.read_text() == content
1092
+
1093
+ def test_missing_lockfile_is_noop(self, tmp_path):
1094
+ """Pure non-Rust projects don't have a Cargo.lock — must not error."""
1095
+ lockfile = tmp_path / "Cargo.lock"
1096
+ # Should not raise.
1097
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1098
+ assert not lockfile.exists()
1099
+
1100
+ def test_already_in_sync_is_noop(self, tmp_path):
1101
+ """Idempotent: same version in -> file unchanged."""
1102
+ content = (
1103
+ "[[package]]\n"
1104
+ 'name = "arthash"\n'
1105
+ 'version = "0.3.0"\n'
1106
+ )
1107
+ lockfile = self._make_lockfile(tmp_path, content)
1108
+ before = lockfile.stat().st_mtime_ns
1109
+ update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1110
+ # Content identical; we explicitly skip the write in that case.
1111
+ assert lockfile.read_text() == content
1112
+ # And the file was not rewritten (mtime preserved). Some filesystems
1113
+ # have coarse mtime resolution, so this guard is a tightening only —
1114
+ # the content check above is the real assertion.
1115
+ assert lockfile.stat().st_mtime_ns == before
1116
+
1117
+
1118
+ class TestSyncCargoLockfiles:
1119
+ """sync_cargo_lockfiles is the post-manifest step that rewrites
1120
+ every Cargo.lock entry matching a bumped manifest. Manifest update
1121
+ and lockfile sync are deliberately decoupled so workspace members
1122
+ sharing one lockfile don't trigger N rewrites of it."""
1123
+
1124
+ def test_syncs_sibling_lockfile(self, tmp_path):
1125
+ cargo_toml = tmp_path / "Cargo.toml"
1126
+ cargo_toml.write_text(
1127
+ "[package]\n"
1128
+ 'name = "arthash"\n'
1129
+ 'version = "0.3.0"\n',
1130
+ encoding="utf-8",
1131
+ )
1132
+ lockfile = tmp_path / "Cargo.lock"
1133
+ lockfile.write_text(
1134
+ "[[package]]\n"
1135
+ 'name = "arthash"\n'
1136
+ 'version = "0.2.0"\n',
1137
+ encoding="utf-8",
1138
+ )
1139
+ sync_cargo_lockfiles([cargo_toml], "0.3.0", 0, show_diff=False)
1140
+ assert 'version = "0.3.0"' in lockfile.read_text()
1141
+
1142
+ def test_syncs_workspace_root_lockfile(self, tmp_path):
1143
+ """Workspace member's manifest, lockfile at the workspace root."""
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.3.0"\n',
1159
+ encoding="utf-8",
1160
+ )
1161
+ sync_cargo_lockfiles([cargo_toml], "0.3.0", 0, show_diff=False)
1162
+ assert 'version = "0.3.0"' in lockfile.read_text()
1163
+
1164
+ def test_no_lockfile_present_is_noop(self, tmp_path):
1165
+ """Pure non-Rust projects or fresh crates may lack a lockfile."""
1166
+ (tmp_path / ".git").mkdir()
1167
+ cargo_toml = tmp_path / "Cargo.toml"
1168
+ cargo_toml.write_text(
1169
+ "[package]\n"
1170
+ 'name = "alpha"\n'
1171
+ 'version = "0.3.0"\n',
1172
+ encoding="utf-8",
1173
+ )
1174
+ # Should not raise.
1175
+ sync_cargo_lockfiles([cargo_toml], "0.3.0", 0, show_diff=False)
1176
+
1177
+ def test_workspace_members_dedup_to_one_lockfile_rewrite(self, tmp_path):
1178
+ """Two members share one lockfile; both their entries must be
1179
+ rewritten, and the lockfile resolution must not duplicate."""
1180
+ (tmp_path / ".git").mkdir()
1181
+ lockfile = tmp_path / "Cargo.lock"
1182
+ lockfile.write_text(
1183
+ "[[package]]\n"
1184
+ 'name = "alpha"\n'
1185
+ 'version = "0.2.0"\n'
1186
+ "\n"
1187
+ "[[package]]\n"
1188
+ 'name = "beta"\n'
1189
+ 'version = "0.2.0"\n',
1190
+ encoding="utf-8",
1191
+ )
1192
+ alpha_toml = tmp_path / "crates" / "alpha" / "Cargo.toml"
1193
+ alpha_toml.parent.mkdir(parents=True)
1194
+ alpha_toml.write_text(
1195
+ "[package]\n"
1196
+ 'name = "alpha"\n'
1197
+ 'version = "0.3.0"\n',
1198
+ encoding="utf-8",
1199
+ )
1200
+ beta_toml = tmp_path / "crates" / "beta" / "Cargo.toml"
1201
+ beta_toml.parent.mkdir(parents=True)
1202
+ beta_toml.write_text(
1203
+ "[package]\n"
1204
+ 'name = "beta"\n'
1205
+ 'version = "0.3.0"\n',
1206
+ encoding="utf-8",
1207
+ )
1208
+ sync_cargo_lockfiles([alpha_toml, beta_toml], "0.3.0", 0, show_diff=False)
1209
+ new_content = lockfile.read_text()
1210
+ assert new_content.count('version = "0.3.0"') == 2
1211
+ assert 'version = "0.2.0"' not in new_content
1212
+
1213
+ def test_manifests_without_package_name_skipped(self, tmp_path):
1214
+ """A pure [workspace] manifest has no `name`; nothing to do."""
1215
+ (tmp_path / ".git").mkdir()
1216
+ ws_toml = tmp_path / "Cargo.toml"
1217
+ ws_toml.write_text('[workspace]\nmembers = ["crates/alpha"]\n', encoding="utf-8")
1218
+ lockfile = tmp_path / "Cargo.lock"
1219
+ original = '[[package]]\nname = "alpha"\nversion = "0.2.0"\n'
1220
+ lockfile.write_text(original, encoding="utf-8")
1221
+ sync_cargo_lockfiles([ws_toml], "0.3.0", 0, show_diff=False)
1222
+ assert lockfile.read_text() == original
1223
+
1224
+
1225
+ class TestCargoLockfilesForManifests:
1226
+ """Resolution of unique Cargo.lock files for a manifest set."""
1227
+
1228
+ def test_dedups_shared_workspace_lockfile(self, tmp_path):
1229
+ (tmp_path / ".git").mkdir()
1230
+ lockfile = tmp_path / "Cargo.lock"
1231
+ lockfile.write_text("# lock\n")
1232
+ alpha = tmp_path / "crates" / "alpha" / "Cargo.toml"
1233
+ alpha.parent.mkdir(parents=True)
1234
+ alpha.write_text('[package]\nname = "alpha"\nversion = "0.1.0"\n')
1235
+ beta = tmp_path / "crates" / "beta" / "Cargo.toml"
1236
+ beta.parent.mkdir(parents=True)
1237
+ beta.write_text('[package]\nname = "beta"\nversion = "0.1.0"\n')
1238
+ result = cargo_lockfiles_for_manifests([alpha, beta])
1239
+ assert result == [lockfile]
1240
+
1241
+ def test_ignores_non_cargo_paths(self, tmp_path):
1242
+ assert cargo_lockfiles_for_manifests([tmp_path / "package.json"]) == []
1243
+
1244
+ def test_missing_lockfile_returns_empty(self, tmp_path):
1245
+ (tmp_path / ".git").mkdir()
1246
+ cargo_toml = tmp_path / "Cargo.toml"
1247
+ cargo_toml.write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1248
+ assert cargo_lockfiles_for_manifests([cargo_toml]) == []
1249
+
1250
+
1251
+ class TestUpdatePackageJsonVersion:
1252
+ """update_package_json_version handles both replace (most pkgs) and
1253
+ insert (pnpm/npm workspace roots that omit `version`)."""
1254
+
1255
+ def test_replaces_existing_version(self, tmp_path):
1256
+ pkg = tmp_path / "package.json"
1257
+ pkg.write_text('{\n "name": "x",\n "version": "0.1.0"\n}\n', encoding="utf-8")
1258
+ update_package_json_version(str(pkg), "0.2.0", 0, show_diff=False)
1259
+ assert '"version": "0.2.0"' in pkg.read_text()
1260
+ assert '"version": "0.1.0"' not in pkg.read_text()
1261
+
1262
+ def test_inserts_when_missing(self, tmp_path):
1263
+ """Workspace root without `version` — must end up with one
1264
+ and remain valid JSON."""
1265
+ pkg = tmp_path / "package.json"
1266
+ pkg.write_text(
1267
+ '{\n "name": "monorepo",\n "private": true,\n "workspaces": ["packages/*"]\n}\n',
1268
+ encoding="utf-8",
1269
+ )
1270
+ update_package_json_version(str(pkg), "1.0.0", 0, show_diff=False)
1271
+ new_content = pkg.read_text()
1272
+ assert '"version": "1.0.0"' in new_content
1273
+ # Still valid JSON
1274
+ import json as _json
1275
+ data = _json.loads(new_content)
1276
+ assert data["version"] == "1.0.0"
1277
+ assert data["name"] == "monorepo"
1278
+
1279
+ def test_only_first_version_match_replaced(self, tmp_path):
1280
+ """Avoid touching a nested `"version"` inside dependencies."""
1281
+ pkg = tmp_path / "package.json"
1282
+ pkg.write_text(
1283
+ '{\n'
1284
+ ' "name": "x",\n'
1285
+ ' "version": "0.1.0",\n'
1286
+ ' "dependencies": {\n'
1287
+ ' "left-pad": "1.3.0"\n'
1288
+ ' }\n'
1289
+ '}\n',
1290
+ encoding="utf-8",
1291
+ )
1292
+ update_package_json_version(str(pkg), "0.2.0", 0, show_diff=False)
1293
+ new_content = pkg.read_text()
1294
+ assert '"version": "0.2.0"' in new_content
1295
+ # left-pad pin must remain unchanged
1296
+ assert '"left-pad": "1.3.0"' in new_content
1297
+
1298
+ def test_file_not_exists_is_noop(self, tmp_path):
1299
+ # Should not raise.
1300
+ update_package_json_version(str(tmp_path / "missing.json"), "1.0.0", 0, show_diff=False)
1301
+
1302
+
1303
+ class TestWorkspaceVersionDiscovery:
1304
+ """When the root package.json omits `version` (typical pnpm/yarn
1305
+ monorepo root), version detection falls back to the highest
1306
+ version among workspace children."""
1307
+
1308
+ def test_pnpm_workspace_yaml_resolves_child_versions(self, tmp_path):
1309
+ (tmp_path / "package.json").write_text('{"name": "root", "private": true}\n', encoding="utf-8")
1310
+ (tmp_path / "pnpm-workspace.yaml").write_text('packages:\n - "packages/*"\n', encoding="utf-8")
1311
+ alpha = tmp_path / "packages" / "alpha"
1312
+ alpha.mkdir(parents=True)
1313
+ (alpha / "package.json").write_text('{"name": "@x/alpha", "version": "1.2.3"}\n', encoding="utf-8")
1314
+ beta = tmp_path / "packages" / "beta"
1315
+ beta.mkdir(parents=True)
1316
+ (beta / "package.json").write_text('{"name": "@x/beta", "version": "1.5.0"}\n', encoding="utf-8")
1317
+
1318
+ version = get_version_from_package_json(tmp_path)
1319
+ assert version is not None
1320
+ # Max of child versions
1321
+ assert (version.major, version.minor, version.patch) == (1, 5, 0)
1322
+
1323
+ def test_npm_workspaces_field_list(self, tmp_path):
1324
+ (tmp_path / "package.json").write_text(
1325
+ '{"name": "root", "private": true, "workspaces": ["packages/*"]}\n',
1326
+ encoding="utf-8",
1327
+ )
1328
+ alpha = tmp_path / "packages" / "alpha"
1329
+ alpha.mkdir(parents=True)
1330
+ (alpha / "package.json").write_text('{"name": "@x/alpha", "version": "0.4.0"}\n', encoding="utf-8")
1331
+
1332
+ version = get_version_from_package_json(tmp_path)
1333
+ assert version is not None
1334
+ assert (version.major, version.minor, version.patch) == (0, 4, 0)
1335
+
1336
+ def test_yarn_workspaces_object_form(self, tmp_path):
1337
+ (tmp_path / "package.json").write_text(
1338
+ '{"name": "root", "workspaces": {"packages": ["modules/*"]}}\n',
1339
+ encoding="utf-8",
1340
+ )
1341
+ alpha = tmp_path / "modules" / "alpha"
1342
+ alpha.mkdir(parents=True)
1343
+ (alpha / "package.json").write_text('{"name": "@x/alpha", "version": "2.0.1"}\n', encoding="utf-8")
1344
+
1345
+ version = get_version_from_package_json(tmp_path)
1346
+ assert version is not None
1347
+ assert (version.major, version.minor, version.patch) == (2, 0, 1)
1348
+
1349
+ def test_root_version_takes_precedence(self, tmp_path):
1350
+ """If root has its own version, children are ignored."""
1351
+ (tmp_path / "package.json").write_text(
1352
+ '{"name": "root", "version": "5.0.0", "workspaces": ["packages/*"]}\n',
1353
+ encoding="utf-8",
1354
+ )
1355
+ alpha = tmp_path / "packages" / "alpha"
1356
+ alpha.mkdir(parents=True)
1357
+ (alpha / "package.json").write_text('{"version": "9.9.9"}\n', encoding="utf-8")
1358
+ version = get_version_from_package_json(tmp_path)
1359
+ assert version is not None
1360
+ assert (version.major, version.minor, version.patch) == (5, 0, 0)
1361
+
1362
+ def test_no_workspace_no_version_returns_none(self, tmp_path):
1363
+ (tmp_path / "package.json").write_text('{"name": "x"}\n', encoding="utf-8")
1364
+ assert get_version_from_package_json(tmp_path) is None
1365
+
1366
+ def test_child_without_version_skipped(self, tmp_path):
1367
+ (tmp_path / "package.json").write_text('{"workspaces": ["packages/*"]}\n', encoding="utf-8")
1368
+ alpha = tmp_path / "packages" / "alpha"
1369
+ alpha.mkdir(parents=True)
1370
+ (alpha / "package.json").write_text('{"name": "@x/alpha"}\n', encoding="utf-8")
1371
+ beta = tmp_path / "packages" / "beta"
1372
+ beta.mkdir(parents=True)
1373
+ (beta / "package.json").write_text('{"name": "@x/beta", "version": "0.1.0"}\n', encoding="utf-8")
1374
+
1375
+ version = get_version_from_package_json(tmp_path)
1376
+ assert version is not None
1377
+ assert (version.major, version.minor, version.patch) == (0, 1, 0)
1378
+
1379
+ def test_find_workspace_package_jsons_excludes_root(self, tmp_path):
1380
+ (tmp_path / "package.json").write_text('{"workspaces": ["."]}\n', encoding="utf-8")
1381
+ result = find_workspace_package_jsons(tmp_path)
1382
+ assert result == []
1383
+
1384
+ def test_version_from_workspace_children_no_workspace(self, tmp_path):
1385
+ """No workspaces config at all → no children, returns None."""
1386
+ assert _version_from_workspace_children(tmp_path) is None
1387
+
1388
+
944
1389
  class TestParseGitignore:
945
1390
  """Test cases for _parse_gitignore function."""
946
1391
 
@@ -311,14 +311,12 @@ version = "invalid"
311
311
  str(file_path), r'version\s*=\s*".*?"', 'version = "1.2.3"', 0, show_diff=False
312
312
  )
313
313
 
314
- @patch("tgit.version.update_file")
314
+ @patch("tgit.version.update_package_json_version")
315
315
  def test_update_version_in_file_package_json(self, mock_update, tmp_path):
316
316
  """Test update_version_in_file for package.json."""
317
317
  file_path = tmp_path / "package.json"
318
318
  update_version_in_file(0, "1.2.3", "package.json", file_path)
319
- mock_update.assert_called_once_with(
320
- str(file_path), r'"version":\s*".*?"', '"version": "1.2.3"', 0, show_diff=False
321
- )
319
+ mock_update.assert_called_once_with(str(file_path), "1.2.3", 0, show_diff=False)
322
320
 
323
321
  @patch("tgit.version.update_file")
324
322
  def test_update_version_in_file_version_txt(self, mock_update, tmp_path):