From d320b74a38f54ac1dba9a6732c0522468ef2cdee Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Fri, 13 Feb 2026 09:56:29 +0000 Subject: [PATCH 01/10] gh30 push write wrong rev --- src/sc/branching/commands/push.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index d54a5b1..6af6ba0 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -118,7 +118,8 @@ def _remote_branch_contains(self, repo: Repo, remote: str, branch: str) -> bool: def _update_manifest_revisions(self, manifest: ScManifest): for proj in manifest.projects: proj_repo = Repo(self.top_dir / proj.path) - proj.revision = proj_repo.head.commit.hexsha + proj_branch_name = common.resolve_project_branch_name(self.branch, proj) + proj.revision = proj_repo.branches[proj_branch_name].commit.hexsha manifest.write() def _push_manifest(self, msg: str): @@ -127,3 +128,4 @@ def _push_manifest(self, msg: str): if manifest_repo.is_dirty(): manifest_repo.git.commit("-m", msg) manifest_repo.git.push("-u", "origin", self.branch.name) + manifest_repo.git.push("origin", "--tags") From f1f20a75fc2d35b12a1a8d3bd6a2bc1fbeeb420b Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Fri, 13 Feb 2026 12:24:18 +0000 Subject: [PATCH 02/10] gh30 checkout instead of switching manifest --- src/sc/branching/commands/push.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index 6af6ba0..342e93f 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -20,6 +20,7 @@ from . import common from ..branch import Branch +from .checkout import Checkout from .command import Command from repo_library import RepoLibrary from sc_manifest_parser import ProjectElementInterface, ScManifest @@ -44,13 +45,16 @@ def run_repo_command(self): msg = self._input_commit_msg() try: + if RepoLibrary.get_manifest_branch(self.top_dir) != self.branch.name: + Checkout(self.top_dir, self.branch) self._switch_manifest_to_branch(self.branch.name) manifest = ScManifest.from_repo_root(self.top_dir / '.repo') self._push_projects(manifest.projects) self._update_manifest_revisions(manifest) self._push_manifest(msg) finally: - self._switch_manifest_to_branch(orig_manifest_branch) + if RepoLibrary.get_manifest_branch(self.top_dir) != orig_manifest_branch: + Checkout(self.top_dir, orig_manifest_branch) logger.info(f"Push {self.branch.name} completed!") @@ -61,12 +65,6 @@ def _input_commit_msg(self): return msg logger.warning("Cannot provide an empty commit message!") - def _switch_manifest_to_branch(self, branch: str): - try: - Repo(self.top_dir / '.repo' / 'manifests').git.switch(branch) - except GitCommandError: - logger.error(f"Branch {branch} doesn't exist on manifest!") - def _push_projects(self, projects: list[ProjectElementInterface]): for project in projects: logger.info(f"Operating on {self.top_dir}/{project.path}") @@ -118,8 +116,7 @@ def _remote_branch_contains(self, repo: Repo, remote: str, branch: str) -> bool: def _update_manifest_revisions(self, manifest: ScManifest): for proj in manifest.projects: proj_repo = Repo(self.top_dir / proj.path) - proj_branch_name = common.resolve_project_branch_name(self.branch, proj) - proj.revision = proj_repo.branches[proj_branch_name].commit.hexsha + proj.revision = proj_repo.head.commit.hexsha manifest.write() def _push_manifest(self, msg: str): From 308e27a9a5bbbdc7488eaa1c9aa43a43f6f94a1b Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Tue, 17 Feb 2026 13:52:19 +0000 Subject: [PATCH 03/10] adfa --- src/sc/branching/branching.py | 13 +-- src/sc/branching/commands/finish.py | 174 +++++++++++++++++++++------- src/sc/branching/commands/push.py | 10 +- 3 files changed, 142 insertions(+), 55 deletions(-) diff --git a/src/sc/branching/branching.py b/src/sc/branching/branching.py index 7320428..805c6ad 100644 --- a/src/sc/branching/branching.py +++ b/src/sc/branching/branching.py @@ -39,6 +39,7 @@ logger = logging.getLogger(__name__) class ProjectType(Enum): + """Git or repo.""" GIT = "git" REPO = "repo" @@ -133,14 +134,10 @@ def finish( ): top_dir, project_type = detect_project(run_dir) branch = create_branch(project_type, top_dir, branch_type, name) - try: - run_command_by_project_type( - Finish(top_dir, branch, base), - project_type - ) - except FinishOperationError as e: - logger.error(e) - sys.exit(1) + run_command_by_project_type( + Finish(top_dir, branch, base), + project_type + ) @staticmethod def list(branch_type: BranchType, run_dir: Path = Path.cwd()): diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index 8c28337..2227214 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -14,6 +14,7 @@ from dataclasses import dataclass import logging +import os from pathlib import Path import subprocess import sys @@ -29,13 +30,6 @@ logger = logging.getLogger(__name__) -class FinishOperationError(RuntimeError): - def __init__(self, path: str | Path, message: str): - super().__init__( - f"Finish failed in {path}: {message} \n" - "Please resolve error and rerun sc finish." - ) - @dataclass class Finish(Command): branch: Branch @@ -47,9 +41,6 @@ def __post_init__(self): def run_git_command(self): """Runs gitflow finish in the git project. - - Raises: - FinishOperationError: If the project fails to finish """ if self.branch.type == BranchType.HOTFIX: base = self._resolve_base(self.top_dir) @@ -74,15 +65,12 @@ def run_git_command(self): tag_message=self._tag_msg ) except subprocess.CalledProcessError: - raise FinishOperationError(self.top_dir, "Check above for error.") + logger.error("Git flow finish failed!") def run_repo_command(self): """Runs gitflow finish in all non locked manifest projects, then runs gitflow finish in the manifest repository and finally updates the manifest with the new commit shas. - - Raises: - FinishOperationError: If any project fails to finish. """ self._error_on_sc_uninitialised() @@ -108,6 +96,7 @@ def run_repo_command(self): if self.branch.type in {BranchType.HOTFIX, BranchType.RELEASE}: self._tag_msg = self._prompt_tag_msg() + self._stop_commit_msg_popup() self._finish_all_projects(base) self._finish_manifest_repo(base) @@ -140,14 +129,15 @@ def _prompt_tag_msg(self) -> str: return msg logger.warning("Cannot provide an empty tag message!") + def _stop_commit_msg_popup(self): + """Stops every merge confirming commit message.""" + os.environ["GIT_MERGE_AUTOEDIT"] = "no" + def _finish_all_projects(self, base: str | None): """Run gitflow finish in all non-locked projects. Args: base (str | None): Sets the base for each project if provided. - - Raises: - FinishOperationError: If any project fails to finish. """ manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: @@ -157,10 +147,7 @@ def _finish_all_projects(self, base: str | None): logger.info(f"Operating on {proj_dir}") proj_repo = Repo(proj_dir) if base: - try: - self._set_branch_base(base, proj.path, proj.remote) - except ValueError as e: - raise FinishOperationError(proj.path, e) + self._set_branch_base(base, proj_dir, proj.remote) self._delete_tag_if_exists(proj_repo, self.branch.suffix) try: @@ -172,11 +159,19 @@ def _finish_all_projects(self, base: str | None): tag_message=self._tag_msg ) except subprocess.CalledProcessError: - raise FinishOperationError(proj.path, "Check above for error.") + logger.error( + f"Finish failed in {proj_dir}, resolve errors and rerun " + f"`sc {self.branch.type} release {self.branch.suffix}`" + ) + sys.exit(1) def _set_branch_base(self, base: str, directory: str | Path, remote: str = "origin"): if not self._branch_exists(base, directory, remote): - raise ValueError(f"Base branch '{base}' not found locally or on {remote}.") + logger.error( + f"Base branch '{base}' not found locally or on remote '{remote}' " + f"in {directory}." + ) + sys.exit(1) GitFlowLibrary.set_branch_base(self.branch.name, base, directory) def _delete_tag_if_exists(self, repo: Repo, tag: str): @@ -192,67 +187,124 @@ def _finish_manifest_repo(self, base: str | None): Args: base (str | None): Set the base branch if provided. """ + manifest_dir = self.top_dir / ".repo" / "manifests" + manifest_repo = Repo(manifest_dir) if base: - try: - self._set_branch_base(base, self.top_dir / '.repo' / 'manifests') - except ValueError as e: - raise FinishOperationError(self.top_dir / '.repo' / 'manifests', e) - self._delete_tag_if_exists( - Repo(self.top_dir / '.repo' / 'manifests'), self.branch.suffix) + self._set_branch_base(base, manifest_dir) + + self._delete_tag_if_exists(manifest_repo, self.branch.suffix) + rev_only_change_branches = self._get_rev_only_change_branches(base) + try: GitFlowLibrary.finish( - self.top_dir / '.repo' / 'manifests', + manifest_dir, self.branch.type, name=self.branch.suffix, keep=True, tag_message=self._tag_msg ) except subprocess.CalledProcessError: - raise FinishOperationError( - self.top_dir / '.repo' / 'manifests', "Check above for error.") + logger.warning( + "Manifest finish failed. Attempting to auto resolve conflicts.") + + while True: + if manifest_repo.active_branch.name not in rev_only_change_branches: + logger.error( + "Can't automatically resolve conflict! Resolve yourself and " + f"rerun `sc {self.branch.type} finish {self.branch.suffix}`" + ) + sys.exit(1) + + rev_only_change_branches.pop(manifest_repo.active_branch.name) + + for path in manifest_repo.index.unmerged_blobs().keys(): + manifest_repo.git.checkout("--ours", path) + manifest_repo.git.add(".") + manifest_repo.git.commit("-m", "Automatic conflict resolution.") + + try: + GitFlowLibrary.finish( + manifest_dir, + self.branch.type, + name=self.branch.suffix, + keep=True, + tag_message=self._tag_msg + ) + # Break on successful finish. + break + except subprocess.CalledProcessError: + # Loop to next branch or failure. + continue + def _rebase_manifest(self, base: str | None): - """R + """Rewrites the manifest with any newer commits pulled on top. Args: - base (str | None): _description_ + base (str | None): The base a hotfix branch should be merged into. """ if self.branch.type == BranchType.FEATURE: - self._rebase_to_develop() + self._rebase_develop() elif self.branch.type == BranchType.RELEASE: - self._rebase_to_master() - self._rebase_to_develop() + self._rebase_master() + self._rebase_develop() elif self.branch.type == BranchType.HOTFIX: - self._rebase_to_base(base) + self._rebase_base(base) - def _rebase_to_develop(self): + def _rebase_develop(self): Repo(self.top_dir / '.repo' / 'manifests').git.switch('develop') manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: if proj.lock_status is None: develop = GitFlowLibrary.get_develop_branch(self.top_dir / proj.path) - Repo(self.top_dir / proj.path).git.switch(develop) + self._rebase_proj(self.top_dir / proj.path, develop) + + self._update_manifest(manifest) self._commit_manifest("develop") - def _rebase_to_master(self): + def _rebase_master(self): Repo(self.top_dir / '.repo' / 'manifests').git.switch('master') manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: if proj.lock_status == None: master = GitFlowLibrary.get_master_branch(self.top_dir / proj.path) - Repo(self.top_dir / proj.path).git.switch(master) + self._rebase_proj(self.top_dir / proj.path, master) + + self._update_manifest(manifest) self._commit_manifest("master") - def _rebase_to_base(self, base: str | None): + def _rebase_base(self, base: str | None): Repo(self.top_dir / '.repo' / 'manifests').git.switch(base) manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: if proj.lock_status == None: - Repo(self.top_dir / proj.path).git.switch(base) + self._rebase_proj(self.top_dir / proj.path, base) + + self._update_manifest(manifest) self._commit_manifest(base) + def _rebase_proj(self, proj_path: Path, branch: str): + proj_repo = Repo(proj_path) + proj_repo.git.switch(branch) + try: + subprocess.run(["git", "pull"], cwd=proj_path, check=True) + except subprocess.CalledProcessError: + logger.error( + f"Rebase failed in {proj_path}, please resolve above error and rerun " + f"`sc {self.branch.type} release {self.branch.suffix}`" + ) + sys.exit(1) + + def _update_manifest(self, manifest: ScManifest): + for proj in manifest.projects: + if proj.lock_status == None: + proj_repo = Repo(self.top_dir / proj.path) + proj.revision = proj_repo.head.commit.hexsha + + manifest.write() + def _commit_manifest(self, branch: str): manifest_repo = Repo(self.top_dir / '.repo' / 'manifests') manifest_repo.git.add(A=True) @@ -269,4 +321,36 @@ def _print_next_steps(self, base: str): logger.info("Run sc develop push to push to remote!") elif self.branch.type == BranchType.HOTFIX: base_prefix, base_name = base.split('/', 1) - logger.info(f"Run sc {base_prefix} push {base_prefix} to push to remote!") \ No newline at end of file + logger.info(f"Run sc {base_prefix} push {base_prefix} to push to remote!") + + def _get_rev_only_change_branches(self, base: str | None) -> list[str]: + rev_only_change_branches = [] + manifest = ScManifest.from_repo_root(self.top_dir / ".repo") + if self.branch.type == BranchType.RELEASE: + dev_manifest = self._get_branches_manifest("develop") + if manifest.equals_ignoring_revisions(dev_manifest): + rev_only_change_branches.append("develop") + master_manifest = self._get_branches_manifest("master") + if manifest.equals_ignoring_revisions("master"): + rev_only_change_branches.append("master") + elif self.branch.type == BranchType.FEATURE: + dev_manifest = self._get_branches_manifest("develop") + if manifest.equals_ignoring_revisions(dev_manifest): + rev_only_change_branches.append("develop") + elif self.branch.type == BranchType.HOTFIX: + base_manifest = self._get_branches_manifest(base) + if manifest.equals_ignoring_revisions(base_manifest): + rev_only_change_branches.append(base) + + return rev_only_change_branches + + + def _get_branches_manifest(self, branch: str) -> ScManifest: + """Get the ScManifest of a particular branch.""" + manifest_repo = Repo(self.top_dir / ".repo" / "manifests") + start_branch = manifest_repo.active_branch.name + manifest_repo.git.switch(branch) + manifest = ScManifest.from_repo_root(self.top_dir / ".repo") + manifest_repo.git.switch(start_branch) + return manifest + diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index 342e93f..aa63b28 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -124,5 +124,11 @@ def _push_manifest(self, msg: str): manifest_repo.git.add(A=True) if manifest_repo.is_dirty(): manifest_repo.git.commit("-m", msg) - manifest_repo.git.push("-u", "origin", self.branch.name) - manifest_repo.git.push("origin", "--tags") + subprocess.run( + ["git", "push", "-u", "origin", self.branch.name], + cwd=self.top_dir / ".repo" / "manifests" + ) + subprocess.run( + ["git", "push", "origin", "--tags"], + cwd=self.top_dir / ".repo" / "manifests" + ) From e4abf74535329535c0653840d4f3fb2f8a29112c Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Fri, 20 Feb 2026 17:47:14 +0000 Subject: [PATCH 04/10] Finish works --- src/sc/branching/branching.py | 2 +- src/sc/branching/commands/finish.py | 116 ++++++++++++++++------------ src/sc/branching/commands/push.py | 24 +++--- src/sc/branching/commands/start.py | 4 +- 4 files changed, 82 insertions(+), 64 deletions(-) diff --git a/src/sc/branching/branching.py b/src/sc/branching/branching.py index 805c6ad..070dec9 100644 --- a/src/sc/branching/branching.py +++ b/src/sc/branching/branching.py @@ -25,7 +25,7 @@ from .commands.checkout import Checkout from .commands.clean import Clean from .commands.command import Command -from .commands.finish import Finish, FinishOperationError +from .commands.finish import Finish from .commands.init import Init from .commands.list import List from .commands.pull import Pull diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index 2227214..12e156a 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -194,6 +194,7 @@ def _finish_manifest_repo(self, base: str | None): self._delete_tag_if_exists(manifest_repo, self.branch.suffix) rev_only_change_branches = self._get_rev_only_change_branches(base) + print(rev_only_change_branches) try: GitFlowLibrary.finish( @@ -207,35 +208,7 @@ def _finish_manifest_repo(self, base: str | None): logger.warning( "Manifest finish failed. Attempting to auto resolve conflicts.") - while True: - if manifest_repo.active_branch.name not in rev_only_change_branches: - logger.error( - "Can't automatically resolve conflict! Resolve yourself and " - f"rerun `sc {self.branch.type} finish {self.branch.suffix}`" - ) - sys.exit(1) - - rev_only_change_branches.pop(manifest_repo.active_branch.name) - - for path in manifest_repo.index.unmerged_blobs().keys(): - manifest_repo.git.checkout("--ours", path) - manifest_repo.git.add(".") - manifest_repo.git.commit("-m", "Automatic conflict resolution.") - - try: - GitFlowLibrary.finish( - manifest_dir, - self.branch.type, - name=self.branch.suffix, - keep=True, - tag_message=self._tag_msg - ) - # Break on successful finish. - break - except subprocess.CalledProcessError: - # Loop to next branch or failure. - continue - + self._auto_resolve_manifest_conflicts(manifest_repo, rev_only_change_branches) def _rebase_manifest(self, base: str | None): """Rewrites the manifest with any newer commits pulled on top. @@ -321,29 +294,74 @@ def _print_next_steps(self, base: str): logger.info("Run sc develop push to push to remote!") elif self.branch.type == BranchType.HOTFIX: base_prefix, base_name = base.split('/', 1) - logger.info(f"Run sc {base_prefix} push {base_prefix} to push to remote!") + logger.info(f"Run sc {base_prefix} push to push to remote!") - def _get_rev_only_change_branches(self, base: str | None) -> list[str]: - rev_only_change_branches = [] - manifest = ScManifest.from_repo_root(self.top_dir / ".repo") - if self.branch.type == BranchType.RELEASE: - dev_manifest = self._get_branches_manifest("develop") - if manifest.equals_ignoring_revisions(dev_manifest): - rev_only_change_branches.append("develop") - master_manifest = self._get_branches_manifest("master") - if manifest.equals_ignoring_revisions("master"): - rev_only_change_branches.append("master") - elif self.branch.type == BranchType.FEATURE: - dev_manifest = self._get_branches_manifest("develop") - if manifest.equals_ignoring_revisions(dev_manifest): - rev_only_change_branches.append("develop") - elif self.branch.type == BranchType.HOTFIX: - base_manifest = self._get_branches_manifest(base) - if manifest.equals_ignoring_revisions(base_manifest): - rev_only_change_branches.append(base) + def _auto_resolve_manifest_conflicts( + self, + manifest_repo: Repo, + rev_only_change_branches: list[str] + ): + while True: + if not self._has_merge_conflicts(manifest_repo): + # TODO: Better error message + logger.error( + "Can't automatically resolve conflict as some other error " + "has occured!" + ) + + if manifest_repo.active_branch.name not in rev_only_change_branches: + logger.error( + "Can't automatically resolve conflict! Resolve yourself and " + f"rerun `sc {self.branch.type} finish {self.branch.suffix}`" + ) + sys.exit(1) - return rev_only_change_branches + print(manifest_repo.active_branch.name) + rev_only_change_branches.remove(manifest_repo.active_branch.name) + for path in manifest_repo.index.unmerged_blobs().keys(): + print(path) + manifest_repo.git.checkout("--ours", path) + manifest_repo.git.commit("-am", "Automatic conflict resolution.") + + try: + GitFlowLibrary.finish( + manifest_repo.working_dir, + self.branch.type, + name=self.branch.suffix, + keep=True, + tag_message=self._tag_msg + ) + # Break on successful finish. + break + except subprocess.CalledProcessError: + # Loop to next branch or failure. + continue + + def _get_rev_only_change_branches(self, base: str | None) -> list[str]: + manifest = ScManifest.from_repo_root(self.top_dir / ".repo") + branches: list[str] = [] + + def check(branch_name: str | None): + if not branch_name: + return + other = self._get_branches_manifest(branch_name) + if manifest.equals(other, ignore_attrs={"revision"}): + branches.append(branch_name) + + match self.branch.type: + case BranchType.RELEASE: + check("develop") + check("master") + case BranchType.FEATURE: + check("develop") + case BranchType.HOTFIX: + check(base) + + return branches + + def _has_merge_conflicts(self, repo: Repo): + return bool(repo.index.unmerged_blobs()) def _get_branches_manifest(self, branch: str) -> ScManifest: """Get the ScManifest of a particular branch.""" diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index aa63b28..3b6d7fc 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -42,29 +42,20 @@ def run_repo_command(self): orig_manifest_branch = RepoLibrary.get_manifest_branch(self.top_dir) logger.info(f"Pushing branch {self.branch.name}") - msg = self._input_commit_msg() try: if RepoLibrary.get_manifest_branch(self.top_dir) != self.branch.name: Checkout(self.top_dir, self.branch) - self._switch_manifest_to_branch(self.branch.name) manifest = ScManifest.from_repo_root(self.top_dir / '.repo') self._push_projects(manifest.projects) self._update_manifest_revisions(manifest) - self._push_manifest(msg) + self._push_manifest() finally: if RepoLibrary.get_manifest_branch(self.top_dir) != orig_manifest_branch: Checkout(self.top_dir, orig_manifest_branch) logger.info(f"Push {self.branch.name} completed!") - def _input_commit_msg(self): - while True: - msg = input("Input commit message for manifest: ") - if msg: - return msg - logger.warning("Cannot provide an empty commit message!") - def _push_projects(self, projects: list[ProjectElementInterface]): for project in projects: logger.info(f"Operating on {self.top_dir}/{project.path}") @@ -109,8 +100,12 @@ def _local_branch_exists(self, repo: Repo, branch: str) -> bool: return branch in [h.name for h in repo.heads] def _remote_branch_contains(self, repo: Repo, remote: str, branch: str) -> bool: + try: + remote_commit = repo.refs[f"{remote}/{branch}"] + except IndexError: + return False + local_commit = repo.heads[branch].commit - remote_commit = repo.refs[f"{remote}/{branch}"] return repo.is_ancestor(local_commit, remote_commit) def _update_manifest_revisions(self, manifest: ScManifest): @@ -119,11 +114,14 @@ def _update_manifest_revisions(self, manifest: ScManifest): proj.revision = proj_repo.head.commit.hexsha manifest.write() - def _push_manifest(self, msg: str): + def _push_manifest(self): manifest_repo = Repo(self.top_dir / '.repo' / 'manifests') manifest_repo.git.add(A=True) if manifest_repo.is_dirty(): - manifest_repo.git.commit("-m", msg) + subprocess.run( + ["git", "commit"], + cwd=self.top_dir / ".repo" / "manifests" + ) subprocess.run( ["git", "push", "-u", "origin", self.branch.name], cwd=self.top_dir / ".repo" / "manifests" diff --git a/src/sc/branching/commands/start.py b/src/sc/branching/commands/start.py index 5434fd1..2e030c6 100644 --- a/src/sc/branching/commands/start.py +++ b/src/sc/branching/commands/start.py @@ -14,6 +14,7 @@ from dataclasses import dataclass import logging +import sys from git import Repo @@ -46,6 +47,7 @@ def run_repo_command(self): local_branches = [head.name for head in manifest_repo.heads] if self.branch.name in remote_branches or self.branch.name in local_branches: logger.error(f"Branch {self.branch.name} already exists and can't be started.") + sys.exit(1) if '/' in self.base: base_branch_type, base_name = self.base.split('/', 1) @@ -66,4 +68,4 @@ def run_repo_command(self): manifest_repo.git.checkout('-b', self.branch.name) manifest_repo.git.commit("--allow-empty", m=f"Starting {self.branch.name}") - manifest_repo.remote("origin").push(self.branch.name) + manifest_repo.git.push("-u", "origin", self.branch.name) From 24756bb058cc9b3c63c8ec59a26f7489a05483a2 Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Mon, 23 Feb 2026 15:00:28 +0000 Subject: [PATCH 05/10] More finishes --- src/sc/branching/commands/finish.py | 47 +++++++++++++++++------------ 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index 12e156a..59872a6 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -176,10 +176,10 @@ def _set_branch_base(self, base: str, directory: str | Path, remote: str = "orig def _delete_tag_if_exists(self, repo: Repo, tag: str): try: - logger.info(f"Attempt deleting tag: {tag}") repo.git.tag('-d', tag) + logger.info(f"Deleted preexisting tag {tag}") except GitCommandError: - logger.info(f"Tag doesn't exist.") + pass def _finish_manifest_repo(self, base: str | None): """Run gitflow finish on the manifest repository. @@ -188,13 +188,11 @@ def _finish_manifest_repo(self, base: str | None): base (str | None): Set the base branch if provided. """ manifest_dir = self.top_dir / ".repo" / "manifests" - manifest_repo = Repo(manifest_dir) if base: self._set_branch_base(base, manifest_dir) - self._delete_tag_if_exists(manifest_repo, self.branch.suffix) + self._delete_tag_if_exists(Repo(manifest_dir), self.branch.suffix) rev_only_change_branches = self._get_rev_only_change_branches(base) - print(rev_only_change_branches) try: GitFlowLibrary.finish( @@ -208,7 +206,7 @@ def _finish_manifest_repo(self, base: str | None): logger.warning( "Manifest finish failed. Attempting to auto resolve conflicts.") - self._auto_resolve_manifest_conflicts(manifest_repo, rev_only_change_branches) + self._auto_resolve_manifest_conflicts(rev_only_change_branches) def _rebase_manifest(self, base: str | None): """Rewrites the manifest with any newer commits pulled on top. @@ -241,7 +239,7 @@ def _rebase_master(self): Repo(self.top_dir / '.repo' / 'manifests').git.switch('master') manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: - if proj.lock_status == None: + if proj.lock_status is None: master = GitFlowLibrary.get_master_branch(self.top_dir / proj.path) self._rebase_proj(self.top_dir / proj.path, master) @@ -252,7 +250,7 @@ def _rebase_base(self, base: str | None): Repo(self.top_dir / '.repo' / 'manifests').git.switch(base) manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: - if proj.lock_status == None: + if proj.lock_status is None: self._rebase_proj(self.top_dir / proj.path, base) self._update_manifest(manifest) @@ -272,7 +270,7 @@ def _rebase_proj(self, proj_path: Path, branch: str): def _update_manifest(self, manifest: ScManifest): for proj in manifest.projects: - if proj.lock_status == None: + if proj.lock_status is None: proj_repo = Repo(self.top_dir / proj.path) proj.revision = proj_repo.head.commit.hexsha @@ -293,21 +291,32 @@ def _print_next_steps(self, base: str): elif self.branch.type == BranchType.FEATURE: logger.info("Run sc develop push to push to remote!") elif self.branch.type == BranchType.HOTFIX: - base_prefix, base_name = base.split('/', 1) + if "/" in base: + base_prefix, base_name = base.split('/', 1) + else: + base_prefix = base logger.info(f"Run sc {base_prefix} push to push to remote!") def _auto_resolve_manifest_conflicts( self, - manifest_repo: Repo, rev_only_change_branches: list[str] ): + """ + Resolve manifest merge conflict automatically if only project revisions have + changed between them. + + Args: + rev_only_change_branches (list[str]): A list of target branches that have + changes in only the revisions. + """ + manifest_repo = Repo(self.top_dir / ".repo" / 'manifests') while True: if not self._has_merge_conflicts(manifest_repo): - # TODO: Better error message logger.error( - "Can't automatically resolve conflict as some other error " - "has occured!" + "Finish failed but no merge conflicts were detected in the manifest " + "repository. Manual intervention required." ) + sys.exit(1) if manifest_repo.active_branch.name not in rev_only_change_branches: logger.error( @@ -316,11 +325,9 @@ def _auto_resolve_manifest_conflicts( ) sys.exit(1) - print(manifest_repo.active_branch.name) rev_only_change_branches.remove(manifest_repo.active_branch.name) for path in manifest_repo.index.unmerged_blobs().keys(): - print(path) manifest_repo.git.checkout("--ours", path) manifest_repo.git.commit("-am", "Automatic conflict resolution.") @@ -367,8 +374,10 @@ def _get_branches_manifest(self, branch: str) -> ScManifest: """Get the ScManifest of a particular branch.""" manifest_repo = Repo(self.top_dir / ".repo" / "manifests") start_branch = manifest_repo.active_branch.name - manifest_repo.git.switch(branch) - manifest = ScManifest.from_repo_root(self.top_dir / ".repo") - manifest_repo.git.switch(start_branch) + try: + manifest_repo.git.switch(branch) + manifest = ScManifest.from_repo_root(self.top_dir / ".repo") + finally: + manifest_repo.git.switch(start_branch) return manifest From 8e37c9586d53fee1722ced5f4caf925a147db101 Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Mon, 23 Feb 2026 17:06:41 +0000 Subject: [PATCH 06/10] More fixes --- src/sc/branching/branch.py | 8 ++++++++ src/sc/branching/commands/finish.py | 5 +++-- src/sc/branching/commands/push.py | 30 ++++++++++++++++++++++++----- src/sc/branching/commands/start.py | 21 +++++++++++++++----- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/sc/branching/branch.py b/src/sc/branching/branch.py index a1a7a3b..24f6bf9 100644 --- a/src/sc/branching/branch.py +++ b/src/sc/branching/branch.py @@ -27,6 +27,14 @@ class BranchType(str, Enum): def __str__(self): return self.value + @staticmethod + def is_valid(value: str) -> bool: + try: + BranchType(value) + return True + except ValueError: + return False + @dataclass class Branch: type: BranchType diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index 59872a6..37d25c8 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -66,6 +66,7 @@ def run_git_command(self): ) except subprocess.CalledProcessError: logger.error("Git flow finish failed!") + sys.exit(1) def run_repo_command(self): """Runs gitflow finish in all non locked manifest projects, then @@ -161,7 +162,7 @@ def _finish_all_projects(self, base: str | None): except subprocess.CalledProcessError: logger.error( f"Finish failed in {proj_dir}, resolve errors and rerun " - f"`sc {self.branch.type} release {self.branch.suffix}`" + f"`sc {self.branch.type} finish {self.branch.suffix}`" ) sys.exit(1) @@ -264,7 +265,7 @@ def _rebase_proj(self, proj_path: Path, branch: str): except subprocess.CalledProcessError: logger.error( f"Rebase failed in {proj_path}, please resolve above error and rerun " - f"`sc {self.branch.type} release {self.branch.suffix}`" + f"`sc {self.branch.type} finish {self.branch.suffix}`" ) sys.exit(1) diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index 3b6d7fc..c950a97 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -15,11 +15,12 @@ from dataclasses import dataclass import logging import subprocess +import sys -from git import GitCommandError, Repo +from git import Repo from . import common -from ..branch import Branch +from ..branch import Branch, BranchType from .checkout import Checkout from .command import Command from repo_library import RepoLibrary @@ -39,23 +40,42 @@ def run_git_command(self): def run_repo_command(self): self._error_on_sc_uninitialised() - orig_manifest_branch = RepoLibrary.get_manifest_branch(self.top_dir) + orig_manifest_branch = self._get_original_branch() logger.info(f"Pushing branch {self.branch.name}") try: if RepoLibrary.get_manifest_branch(self.top_dir) != self.branch.name: - Checkout(self.top_dir, self.branch) + Checkout(self.top_dir, self.branch).run_repo_command() manifest = ScManifest.from_repo_root(self.top_dir / '.repo') self._push_projects(manifest.projects) self._update_manifest_revisions(manifest) self._push_manifest() finally: if RepoLibrary.get_manifest_branch(self.top_dir) != orig_manifest_branch: - Checkout(self.top_dir, orig_manifest_branch) + Checkout(self.top_dir, orig_manifest_branch).run_repo_command() logger.info(f"Push {self.branch.name} completed!") + def _get_original_branch(self) -> Branch: + orig_manifest_branch = RepoLibrary.get_manifest_branch(self.top_dir) + + if orig_manifest_branch == "develop": + return Branch(BranchType.DEVELOP) + elif orig_manifest_branch == "master": + return Branch(BranchType.MASTER) + + if "/" in orig_manifest_branch: + prefix, name = orig_manifest_branch.split("/", 1) + if BranchType.is_valid(prefix): + return Branch(BranchType(prefix), name) + + logger.error( + f"Original manifest branch {orig_manifest_branch} is not a " + "valid sc branch. Please checkout a valid sc branch to push." + ) + sys.exit(1) + def _push_projects(self, projects: list[ProjectElementInterface]): for project in projects: logger.info(f"Operating on {self.top_dir}/{project.path}") diff --git a/src/sc/branching/commands/start.py b/src/sc/branching/commands/start.py index 2e030c6..f4087db 100644 --- a/src/sc/branching/commands/start.py +++ b/src/sc/branching/commands/start.py @@ -43,11 +43,7 @@ def run_repo_command(self): manifest_dir = self.top_dir / '.repo' / 'manifests' manifest_repo = Repo(manifest_dir) - remote_branches = [ref.name for ref in manifest_repo.remotes['origin'].refs] - local_branches = [head.name for head in manifest_repo.heads] - if self.branch.name in remote_branches or self.branch.name in local_branches: - logger.error(f"Branch {self.branch.name} already exists and can't be started.") - sys.exit(1) + self._error_if_branch_exists(manifest_repo) if '/' in self.base: base_branch_type, base_name = self.base.split('/', 1) @@ -69,3 +65,18 @@ def run_repo_command(self): manifest_repo.git.checkout('-b', self.branch.name) manifest_repo.git.commit("--allow-empty", m=f"Starting {self.branch.name}") manifest_repo.git.push("-u", "origin", self.branch.name) + + def _error_if_branch_exists(self, manifest_repo: Repo): + remote_branches = [ref.name for ref in manifest_repo.remotes['origin'].refs] + local_branches = [head.name for head in manifest_repo.heads] + if f"origin/{self.branch.name}" in remote_branches: + logger.error( + f"Branch {self.branch.name} exists on the remote manifest repo " + "so cannot be started." + ) + sys.exit(1) + elif self.branch.name in local_branches: + logger.error( + f"Branch {self.branch.name} already exists locally in the manifest " + "repo so cannot be started.") + sys.exit(1) \ No newline at end of file From 7f4d5e497231f80ce9a2f30db3c1f2c2020bd2c9 Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Tue, 24 Feb 2026 08:51:39 +0000 Subject: [PATCH 07/10] Final fixes --- src/sc/branching/commands/finish.py | 15 ++++++-- src/sc/branching/commands/push.py | 54 +++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index 37d25c8..ab15864 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -193,7 +193,7 @@ def _finish_manifest_repo(self, base: str | None): self._set_branch_base(base, manifest_dir) self._delete_tag_if_exists(Repo(manifest_dir), self.branch.suffix) - rev_only_change_branches = self._get_rev_only_change_branches(base) + rev_only_change_branches = self._get_branches_with_revision_only_diff(base) try: GitFlowLibrary.finish( @@ -346,7 +346,18 @@ def _auto_resolve_manifest_conflicts( # Loop to next branch or failure. continue - def _get_rev_only_change_branches(self, base: str | None) -> list[str]: + def _get_branches_with_revision_only_diff(self, base: str | None) -> list[str]: + """Get a list of target manifest branches that differ only by revisions. + These branches are then able to be auto resolved if there is a conflict. + + Args: + base (str | None): The base if finishing a hotfix branch. + + Returns: + list[str]: A list of relevant branches which manifest differs from the + starting manifest by revision only. + + """ manifest = ScManifest.from_repo_root(self.top_dir / ".repo") branches: list[str] = [] diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index c950a97..1dd1e98 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -106,11 +106,21 @@ def _can_push_project(self, proj: ProjectElementInterface) -> bool: def _do_push_project(self, proj: ProjectElementInterface): proj_repo = Repo(self.top_dir / proj.path) proj_branch_name = common.resolve_project_branch_name(self.branch, proj) - subprocess.run( - ["git", "push", "-u", proj.remote, proj_branch_name], - cwd=proj_repo.working_dir - ) - subprocess.run(["git", "push", proj.remote, "--tags"], cwd=proj_repo.working_dir) + try: + subprocess.run( + ["git", "push", "-u", proj.remote, proj_branch_name], + cwd=proj_repo.working_dir, + check=True + ) + subprocess.run( + ["git", "push", proj.remote, "--tags"], + cwd=proj_repo.working_dir, + check=True + ) + except subprocess.CalledProcessError: + logger.error( + f"Failed to push project {proj_repo.working_dir}. Resolve error and " + "rerun.") def _do_push_tag_only_project(self, proj: ProjectElementInterface): proj_repo = Repo(self.top_dir / proj.path) @@ -138,15 +148,29 @@ def _push_manifest(self): manifest_repo = Repo(self.top_dir / '.repo' / 'manifests') manifest_repo.git.add(A=True) if manifest_repo.is_dirty(): + try: + subprocess.run( + ["git", "commit"], + cwd=self.top_dir / ".repo" / "manifests", + check=True + ) + except subprocess.CalledProcessError: + logger.error( + "Failed to commit manifest. Please check error and rerun " \ + "push." + ) + sys.exit(1) + try: subprocess.run( - ["git", "commit"], - cwd=self.top_dir / ".repo" / "manifests" + ["git", "push", "-u", "origin", self.branch.name], + cwd=self.top_dir / ".repo" / "manifests", + check=True ) - subprocess.run( - ["git", "push", "-u", "origin", self.branch.name], - cwd=self.top_dir / ".repo" / "manifests" - ) - subprocess.run( - ["git", "push", "origin", "--tags"], - cwd=self.top_dir / ".repo" / "manifests" - ) + subprocess.run( + ["git", "push", "origin", "--tags"], + cwd=self.top_dir / ".repo" / "manifests", + check=True + ) + except subprocess.CalledProcessError: + logger.error("Failed to push manifest! Resolve errors and push again.") + sys.exit(1) From 034c99d3e12b7c9e6601857a486c5e45c69dd724 Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Thu, 26 Feb 2026 13:51:04 +0000 Subject: [PATCH 08/10] tag TAG_ONLY --- src/sc/branching/commands/finish.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index ab15864..fc277f5 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -142,15 +142,23 @@ def _finish_all_projects(self, base: str | None): """ manifest = ScManifest.from_repo_root(self.top_dir / '.repo') for proj in manifest.projects: - if proj.lock_status is not None: - continue proj_dir = self.top_dir / proj.path logger.info(f"Operating on {proj_dir}") proj_repo = Repo(proj_dir) + + if proj.lock_status == "READ_ONLY": + continue + + self._delete_tag_if_exists(proj_repo, self.branch.suffix) + if proj.lock_status == "TAG_ONLY": + logger.info(f"Project {proj_dir} is TAG_ONLY") + if self.branch.type in {BranchType.HOTFIX, BranchType.RELEASE}: + proj_repo.git.tag(self.branch.suffix) + continue + if base: self._set_branch_base(base, proj_dir, proj.remote) - self._delete_tag_if_exists(proj_repo, self.branch.suffix) try: GitFlowLibrary.finish( proj_dir, From cb0f8367427940bec738e8309cd4c32ba677f6e6 Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Mon, 2 Mar 2026 10:14:20 +0000 Subject: [PATCH 09/10] gh30 docstring --- src/sc/branching/commands/finish.py | 32 ++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index fc277f5..1d4a714 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -69,9 +69,28 @@ def run_git_command(self): sys.exit(1) def run_repo_command(self): - """Runs gitflow finish in all non locked manifest projects, then - runs gitflow finish in the manifest repository and finally updates - the manifest with the new commit shas. + """ + Finish all projects defined in the manifest using git-flow semantics. + + Process: + 1. For each project listed in the manifest, run `git flow finish`. + - If merge conflicts occur, they must be resolved manually before continuing. + + 2. After all projects are finished, run `git flow finish` on the manifest repository. + - If a merge conflict occurs in the manifest and the only differences are + project revision changes, the conflict is auto-resolved. + - This is safe because any functional conflicts between those revisions + were already resolved when finishing the individual projects. + + 3. Once the manifest is merged, pull all projects to update them to the + latest revisions referenced by the manifest. + + 4. Create an additional commit on the target branch of the manifest to + update all project revision references to their latest state. + + Result: + All projects and the manifest are merged consistently, and the manifest + reflects the final resolved revisions of every project. """ self._error_on_sc_uninitialised() @@ -80,6 +99,13 @@ def run_repo_command(self): f"Branch {self.branch.name} doesn't exist so can't be finished!") sys.exit(1) + if self.branch.type not in {BranchType.FEATURE, BranchType.HOTFIX, BranchType.RELEASE}: + logger.error( + f"Can't finish branch of type {self.branch.type}! " + "Can only finish release, feature or hotfix branches." + ) + sys.exit(1) + if self.branch.type == BranchType.HOTFIX: base = self._resolve_base(self.top_dir / '.repo' / 'manifests') if not base: From 58a1a0ab7142957a0c44bc169f5737ebd0be9133 Mon Sep 17 00:00:00 2001 From: Benjamin Milan Date: Mon, 2 Mar 2026 14:23:24 +0000 Subject: [PATCH 10/10] gh30 changes after review --- src/sc/branching/commands/finish.py | 11 ++--------- src/sc/branching/commands/push.py | 6 ++++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/sc/branching/commands/finish.py b/src/sc/branching/commands/finish.py index 1d4a714..7999919 100644 --- a/src/sc/branching/commands/finish.py +++ b/src/sc/branching/commands/finish.py @@ -99,13 +99,6 @@ def run_repo_command(self): f"Branch {self.branch.name} doesn't exist so can't be finished!") sys.exit(1) - if self.branch.type not in {BranchType.FEATURE, BranchType.HOTFIX, BranchType.RELEASE}: - logger.error( - f"Can't finish branch of type {self.branch.type}! " - "Can only finish release, feature or hotfix branches." - ) - sys.exit(1) - if self.branch.type == BranchType.HOTFIX: base = self._resolve_base(self.top_dir / '.repo' / 'manifests') if not base: @@ -118,7 +111,7 @@ def run_repo_command(self): base = None if RepoLibrary.get_manifest_branch(self.top_dir) != self.branch.name: - Checkout(self.top_dir, self.branch) + Checkout(self.top_dir, self.branch).run_repo_command() if self.branch.type in {BranchType.HOTFIX, BranchType.RELEASE}: self._tag_msg = self._prompt_tag_msg() @@ -335,7 +328,7 @@ def _print_next_steps(self, base: str): def _auto_resolve_manifest_conflicts( self, rev_only_change_branches: list[str] - ): + ): """ Resolve manifest merge conflict automatically if only project revisions have changed between them. diff --git a/src/sc/branching/commands/push.py b/src/sc/branching/commands/push.py index 1dd1e98..c84edc4 100644 --- a/src/sc/branching/commands/push.py +++ b/src/sc/branching/commands/push.py @@ -120,7 +120,9 @@ def _do_push_project(self, proj: ProjectElementInterface): except subprocess.CalledProcessError: logger.error( f"Failed to push project {proj_repo.working_dir}. Resolve error and " - "rerun.") + "rerun." + ) + sys.exit(1) def _do_push_tag_only_project(self, proj: ProjectElementInterface): proj_repo = Repo(self.top_dir / proj.path) @@ -156,7 +158,7 @@ def _push_manifest(self): ) except subprocess.CalledProcessError: logger.error( - "Failed to commit manifest. Please check error and rerun " \ + "Failed to commit manifest. Please check error and rerun " "push." ) sys.exit(1)