From c30f603482bda65dd1fa46c26922f68f196e9a95 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 07:03:54 +0000 Subject: [PATCH 1/9] fix(git-ops): truncate PR body to stay within GitHub's 65536-char limit --- path_sync/_internal/git_ops.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/path_sync/_internal/git_ops.py b/path_sync/_internal/git_ops.py index 97d53d6..a8ebf20 100644 --- a/path_sync/_internal/git_ops.py +++ b/path_sync/_internal/git_ops.py @@ -10,6 +10,15 @@ logger = logging.getLogger(__name__) +GH_PR_BODY_MAX_CHARS = 64536 # real limit is 65536, but we leave some buffer +_TRUNCATION_NOTICE = "\n\n... (truncated, output too long for PR body)" + + +def _truncate_body(body: str) -> str: + if len(body) <= GH_PR_BODY_MAX_CHARS: + return body + return body[: GH_PR_BODY_MAX_CHARS - len(_TRUNCATION_NOTICE)] + _TRUNCATION_NOTICE + def _auth_url(url: str) -> str: """Inject GH_TOKEN into HTTPS URL for authentication.""" @@ -189,6 +198,7 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool: if not pr_number: return False + body = _truncate_body(body) cmd = [ "gh", "api", @@ -243,8 +253,9 @@ def create_or_update_pr( reviewers: list[str] | None = None, assignees: list[str] | None = None, ) -> str: + body = _truncate_body(body) if body else "" cmd = ["gh", "pr", "create", "--head", branch, "--title", title] - cmd.extend(["--body", body or ""]) + cmd.extend(["--body", body]) if labels: cmd.extend(["--label", ",".join(labels)]) if reviewers: From 9385cb8fca19943096c3cd45c164ccdb77212ede Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 07:06:04 +0000 Subject: [PATCH 2/9] chore: add changelog entry --- .changelog/030.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changelog/030.yaml diff --git a/.changelog/030.yaml b/.changelog/030.yaml new file mode 100644 index 0000000..da9a4ab --- /dev/null +++ b/.changelog/030.yaml @@ -0,0 +1,7 @@ +name: __ROOT__ +ts: 2026-02-25 07:05:40.310869+00:00 +type: fix +author: Espen Albert +changelog_message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' +message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' +short_sha: '153127' From 84c7f300de2962ac68ba67aac6bcbaf99aa02191 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 07:29:24 +0000 Subject: [PATCH 3/9] fix: skip force-push when remote branch has same content --- path_sync/_internal/cmd_dep_update.py | 7 ++- path_sync/_internal/cmd_dep_update_test.py | 48 +++++++++++++++++ path_sync/_internal/git_ops.py | 18 ++++++- path_sync/_internal/git_ops_test.py | 60 ++++++++++++++++++++++ 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 path_sync/_internal/git_ops_test.py diff --git a/path_sync/_internal/cmd_dep_update.py b/path_sync/_internal/cmd_dep_update.py index 607107a..5f43e2f 100644 --- a/path_sync/_internal/cmd_dep_update.py +++ b/path_sync/_internal/cmd_dep_update.py @@ -201,9 +201,12 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt continue repo = git_ops.get_repo(result.repo_path) - git_ops.push_branch(repo, config.pr.branch, force=True) - body = _build_pr_body(result.log_content, result.failures) + + if not git_ops.push_branch(repo, config.pr.branch, force=True): + git_ops.update_pr_body(result.repo_path, config.pr.branch, body) + continue + pr_url = git_ops.create_or_update_pr( result.repo_path, config.pr.branch, diff --git a/path_sync/_internal/cmd_dep_update_test.py b/path_sync/_internal/cmd_dep_update_test.py index 91cee31..051cebc 100644 --- a/path_sync/_internal/cmd_dep_update_test.py +++ b/path_sync/_internal/cmd_dep_update_test.py @@ -9,7 +9,9 @@ from path_sync._internal import verify as verify_module from path_sync._internal.cmd_dep_update import ( DepUpdateOptions, + RepoResult, Status, + _create_prs, _process_single_repo, _run_updates, _update_and_validate, @@ -197,3 +199,49 @@ def test_update_and_validate_keeps_pr_when_config_flag_set( assert not results git_ops.has_open_pr.assert_not_called() git_ops.close_pr.assert_not_called() + + +# --- _create_prs tests --- + +CREATE_PRS_MODULE = _create_prs.__module__ + + +def test_create_prs_skips_push_when_content_unchanged(config: DepConfig, tmp_path: Path): + result = RepoResult( + dest=Destination(name="test-repo", dest_path_relative="code/test-repo", default_branch="main"), + repo_path=tmp_path, + status=Status.PASSED, + log_content="some output", + ) + opts = DepUpdateOptions() + + with patch(f"{CREATE_PRS_MODULE}.git_ops") as git_ops: + git_ops.get_repo.return_value = MagicMock() + git_ops.push_branch.return_value = False + + pr_refs = _create_prs(config, [result], opts) + + git_ops.create_or_update_pr.assert_not_called() + git_ops.update_pr_body.assert_called_once() + assert not pr_refs + + +def test_create_prs_pushes_when_content_changed(config: DepConfig, tmp_path: Path): + result = RepoResult( + dest=Destination(name="test-repo", dest_path_relative="code/test-repo", default_branch="main"), + repo_path=tmp_path, + status=Status.PASSED, + log_content="some output", + ) + opts = DepUpdateOptions() + + with patch(f"{CREATE_PRS_MODULE}.git_ops") as git_ops: + git_ops.get_repo.return_value = MagicMock() + git_ops.push_branch.return_value = True + git_ops.create_or_update_pr.return_value = "https://github.com/test/pr/1" + + pr_refs = _create_prs(config, [result], opts) + + git_ops.push_branch.assert_called_once_with(git_ops.get_repo.return_value, "chore/deps", force=True) + git_ops.create_or_update_pr.assert_called_once() + assert len(pr_refs) == 1 diff --git a/path_sync/_internal/git_ops.py b/path_sync/_internal/git_ops.py index a8ebf20..b950d20 100644 --- a/path_sync/_internal/git_ops.py +++ b/path_sync/_internal/git_ops.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -GH_PR_BODY_MAX_CHARS = 64536 # real limit is 65536, but we leave some buffer +GH_PR_BODY_MAX_CHARS = 64536 # real limit is 65536, but we leave some buffer _TRUNCATION_NOTICE = "\n\n... (truncated, output too long for PR body)" @@ -154,10 +154,24 @@ def _ensure_git_user(repo: Repo) -> None: repo.config_writer().set_value("user", "email", "path-sync[bot]@users.noreply.github.com").release() -def push_branch(repo: Repo, branch: str, force: bool = True) -> None: +def remote_branch_has_same_content(repo: Repo, branch: str) -> bool: + """Check if origin/{branch} has identical file content (tree) as local {branch}.""" + with suppress(GitCommandError): + local_tree = repo.git.rev_parse(f"{branch}^{{tree}}") + remote_tree = repo.git.rev_parse(f"origin/{branch}^{{tree}}") + return local_tree == remote_tree + return False + + +def push_branch(repo: Repo, branch: str, force: bool = True) -> bool: + """Push branch to origin. Returns False if skipped (content unchanged on remote).""" + if force and remote_branch_has_same_content(repo, branch): + logger.info(f"Skipping push for {branch}: content unchanged on remote") + return False logger.info(f"Pushing {branch}" + (" (force)" if force else "")) args = ["--force", "-u", "origin", branch] if force else ["-u", "origin", branch] repo.git.push(*args) + return True def _get_repo_full_name(repo_path: Path) -> str | None: diff --git a/path_sync/_internal/git_ops_test.py b/path_sync/_internal/git_ops_test.py new file mode 100644 index 0000000..a0330bf --- /dev/null +++ b/path_sync/_internal/git_ops_test.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path + +from git import Repo + +from path_sync._internal.git_ops import remote_branch_has_same_content + + +def _init_repo_with_remote(tmp_path: Path) -> tuple[Repo, Repo]: + """Create a bare 'remote' and a clone that points to it.""" + bare_path = tmp_path / "remote.git" + bare = Repo.init(bare_path, bare=True) + + clone_path = tmp_path / "clone" + clone = Repo.clone_from(str(bare_path), str(clone_path)) + (clone_path / "file.txt").write_text("initial") + clone.index.add(["file.txt"]) + clone.index.commit("initial") + clone.git.push("-u", "origin", "main") + return bare, clone + + +def test_same_content_returns_true(tmp_path: Path): + _, clone = _init_repo_with_remote(tmp_path) + clone.git.checkout("-b", "feature") + (Path(clone.working_dir) / "file.txt").write_text("updated") + clone.index.add(["file.txt"]) + clone.index.commit("first") + clone.git.push("-u", "origin", "feature") + + # New commit with same tree (content unchanged) + clone.git.commit("--allow-empty", "-m", "second") + + assert remote_branch_has_same_content(clone, "feature") + + +def test_different_content_returns_false(tmp_path: Path): + _, clone = _init_repo_with_remote(tmp_path) + clone.git.checkout("-b", "feature") + (Path(clone.working_dir) / "file.txt").write_text("v1") + clone.index.add(["file.txt"]) + clone.index.commit("first") + clone.git.push("-u", "origin", "feature") + + (Path(clone.working_dir) / "file.txt").write_text("v2") + clone.index.add(["file.txt"]) + clone.index.commit("second") + + assert not remote_branch_has_same_content(clone, "feature") + + +def test_no_remote_branch_returns_false(tmp_path: Path): + _, clone = _init_repo_with_remote(tmp_path) + clone.git.checkout("-b", "new-branch") + (Path(clone.working_dir) / "file.txt").write_text("changed") + clone.index.add(["file.txt"]) + clone.index.commit("first") + + assert not remote_branch_has_same_content(clone, "new-branch") From 90d478c91e236afd17c433bd659862c56b41b6aa Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 07:29:58 +0000 Subject: [PATCH 4/9] chore: sync changelog fix --- .changelog/030.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.changelog/030.yaml b/.changelog/030.yaml index da9a4ab..65d9b4d 100644 --- a/.changelog/030.yaml +++ b/.changelog/030.yaml @@ -5,3 +5,16 @@ author: Espen Albert changelog_message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' short_sha: '153127' +--- +name: remote_branch_has_same_content +ts: 2026-02-25 07:29:42.955395+00:00 +type: keep_private +full_path: _internal.git_ops.remote_branch_has_same_content +--- +name: __ROOT__ +ts: 2026-02-25 07:29:47.509062+00:00 +type: fix +author: Espen Albert +changelog_message: 'fix: skip force-push when remote branch has same content' +message: 'fix: skip force-push when remote branch has same content' +short_sha: 6747b3 From e7973d4799a06811e65bc603b662acfec5d0945a Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 07:40:13 +0000 Subject: [PATCH 5/9] chore(git-ops): close open code fences when truncating PR body --- path_sync/_internal/cmd_copy.py | 6 ++-- path_sync/_internal/git_ops.py | 8 ++++- path_sync/_internal/git_ops_test.py | 45 ++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/path_sync/_internal/cmd_copy.py b/path_sync/_internal/cmd_copy.py index aae95ea..fcd5a5c 100644 --- a/path_sync/_internal/cmd_copy.py +++ b/path_sync/_internal/cmd_copy.py @@ -607,8 +607,10 @@ def _push_and_pr( if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt): return None - git_ops.push_branch(repo, copy_branch, force=True) - typer.echo(f" Pushed: {copy_branch} (force)", err=True) + if git_ops.push_branch(repo, copy_branch, force=True): + typer.echo(f" Pushed: {copy_branch} (force)", err=True) + else: + typer.echo(f" Skipped push for {copy_branch}: content unchanged on remote", err=True) if opts.no_pr or not prompt_utils.prompt_confirm(f"Create PR for {dest.name}?", opts.no_prompt): return None diff --git a/path_sync/_internal/git_ops.py b/path_sync/_internal/git_ops.py index b950d20..5bd173a 100644 --- a/path_sync/_internal/git_ops.py +++ b/path_sync/_internal/git_ops.py @@ -2,6 +2,7 @@ import logging import os +import re import subprocess from contextlib import suppress from pathlib import Path @@ -12,12 +13,17 @@ GH_PR_BODY_MAX_CHARS = 64536 # real limit is 65536, but we leave some buffer _TRUNCATION_NOTICE = "\n\n... (truncated, output too long for PR body)" +_CODE_FENCE_RE = re.compile(r"^`{3,}", re.MULTILINE) def _truncate_body(body: str) -> str: if len(body) <= GH_PR_BODY_MAX_CHARS: return body - return body[: GH_PR_BODY_MAX_CHARS - len(_TRUNCATION_NOTICE)] + _TRUNCATION_NOTICE + cut = GH_PR_BODY_MAX_CHARS - len(_TRUNCATION_NOTICE) + truncated = body[:cut] + fence_count = len(_CODE_FENCE_RE.findall(truncated)) + suffix = "\n```" + _TRUNCATION_NOTICE if fence_count % 2 else _TRUNCATION_NOTICE + return truncated[: GH_PR_BODY_MAX_CHARS - len(suffix)] + suffix def _auth_url(url: str) -> str: diff --git a/path_sync/_internal/git_ops_test.py b/path_sync/_internal/git_ops_test.py index a0330bf..a5c5ee9 100644 --- a/path_sync/_internal/git_ops_test.py +++ b/path_sync/_internal/git_ops_test.py @@ -4,7 +4,50 @@ from git import Repo -from path_sync._internal.git_ops import remote_branch_has_same_content +from path_sync._internal.git_ops import ( + GH_PR_BODY_MAX_CHARS, + _truncate_body, + remote_branch_has_same_content, +) + + +def test_truncate_body_short_unchanged(): + body = "short body" + assert _truncate_body(body) == body + + +def test_truncate_body_at_limit_unchanged(): + body = "x" * GH_PR_BODY_MAX_CHARS + assert _truncate_body(body) == body + + +def test_truncate_body_over_limit(): + body = "x" * (GH_PR_BODY_MAX_CHARS + 500) + result = _truncate_body(body) + assert len(result) == GH_PR_BODY_MAX_CHARS + assert result.endswith("... (truncated, output too long for PR body)") + + +def test_truncate_body_closes_open_code_fence(): + prefix = "## Command Output\n\n```\n" + filler = "log line\n" * 10_000 + body = prefix + filler + assert len(body) > GH_PR_BODY_MAX_CHARS + + result = _truncate_body(body) + assert len(result) <= GH_PR_BODY_MAX_CHARS + assert "\n```\n" in result[len(prefix) :] + assert result.endswith("... (truncated, output too long for PR body)") + + +def test_truncate_body_no_extra_fence_when_closed(): + prefix = "## Output\n\n```\nsome log\n```\n\nmore text\n" + filler = "x" * GH_PR_BODY_MAX_CHARS + body = prefix + filler + + result = _truncate_body(body) + assert len(result) == GH_PR_BODY_MAX_CHARS + assert "```\n\n... (truncated" not in result def _init_repo_with_remote(tmp_path: Path) -> tuple[Repo, Repo]: From 8665f1169b2915ab5c52a554fd120dd48a4d8446 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 10:41:57 +0000 Subject: [PATCH 6/9] chore: rebased changelog entry --- .changelog/030.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changelog/030.yaml b/.changelog/030.yaml index 65d9b4d..62963a1 100644 --- a/.changelog/030.yaml +++ b/.changelog/030.yaml @@ -4,7 +4,7 @@ type: fix author: Espen Albert changelog_message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' -short_sha: '153127' +short_sha: 6081a4 --- name: remote_branch_has_same_content ts: 2026-02-25 07:29:42.955395+00:00 @@ -17,4 +17,4 @@ type: fix author: Espen Albert changelog_message: 'fix: skip force-push when remote branch has same content' message: 'fix: skip force-push when remote branch has same content' -short_sha: 6747b3 +short_sha: 234e21 From 6e1380700ad235259d138746eebc681a1da7ab97 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 10:53:53 +0000 Subject: [PATCH 7/9] test: add tests for push_branch functionality in git_ops --- path_sync/_internal/git_ops_test.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/path_sync/_internal/git_ops_test.py b/path_sync/_internal/git_ops_test.py index a5c5ee9..ee4d590 100644 --- a/path_sync/_internal/git_ops_test.py +++ b/path_sync/_internal/git_ops_test.py @@ -7,6 +7,7 @@ from path_sync._internal.git_ops import ( GH_PR_BODY_MAX_CHARS, _truncate_body, + push_branch, remote_branch_has_same_content, ) @@ -101,3 +102,31 @@ def test_no_remote_branch_returns_false(tmp_path: Path): clone.index.commit("first") assert not remote_branch_has_same_content(clone, "new-branch") + + +def test_push_branch_skips_when_content_unchanged(tmp_path: Path): + _, clone = _init_repo_with_remote(tmp_path) + clone.git.checkout("-b", "feature") + (Path(clone.working_dir) / "file.txt").write_text("updated") + clone.index.add(["file.txt"]) + clone.index.commit("first") + clone.git.push("-u", "origin", "feature") + + clone.git.commit("--allow-empty", "-m", "empty commit") + + assert not push_branch(clone, "feature", force=True) + + +def test_push_branch_pushes_when_content_differs(tmp_path: Path): + _, clone = _init_repo_with_remote(tmp_path) + clone.git.checkout("-b", "feature") + (Path(clone.working_dir) / "file.txt").write_text("v1") + clone.index.add(["file.txt"]) + clone.index.commit("first") + clone.git.push("-u", "origin", "feature") + + (Path(clone.working_dir) / "file.txt").write_text("v2") + clone.index.add(["file.txt"]) + clone.index.commit("second") + + assert push_branch(clone, "feature", force=True) From 89a0145221a9ed322e755543d621a70160d7f20a Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 11:28:10 +0000 Subject: [PATCH 8/9] chore: rebased commit refs --- .changelog/030.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changelog/030.yaml b/.changelog/030.yaml index 62963a1..b1f2e72 100644 --- a/.changelog/030.yaml +++ b/.changelog/030.yaml @@ -4,7 +4,7 @@ type: fix author: Espen Albert changelog_message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' message: 'fix(git-ops): truncate PR body to stay within GitHub''s 65536-char limit' -short_sha: 6081a4 +short_sha: c30f60 --- name: remote_branch_has_same_content ts: 2026-02-25 07:29:42.955395+00:00 @@ -17,4 +17,4 @@ type: fix author: Espen Albert changelog_message: 'fix: skip force-push when remote branch has same content' message: 'fix: skip force-push when remote branch has same content' -short_sha: 234e21 +short_sha: 84c7f3 From 578aa79fd68c654a6bbe723ab500715597ad3c66 Mon Sep 17 00:00:00 2001 From: Espen Albert Date: Wed, 25 Feb 2026 11:30:46 +0000 Subject: [PATCH 9/9] chore(git-ops): set bare repo HEAD to main for CI compatibility --- path_sync/_internal/git_ops_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/path_sync/_internal/git_ops_test.py b/path_sync/_internal/git_ops_test.py index ee4d590..77f295f 100644 --- a/path_sync/_internal/git_ops_test.py +++ b/path_sync/_internal/git_ops_test.py @@ -55,6 +55,7 @@ def _init_repo_with_remote(tmp_path: Path) -> tuple[Repo, Repo]: """Create a bare 'remote' and a clone that points to it.""" bare_path = tmp_path / "remote.git" bare = Repo.init(bare_path, bare=True) + bare.git.symbolic_ref("HEAD", "refs/heads/main") clone_path = tmp_path / "clone" clone = Repo.clone_from(str(bare_path), str(clone_path))