-
Notifications
You must be signed in to change notification settings - Fork 148
Closes #865 - Add get_changed_items() utility for git-based selective deployment #891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vipulb91
wants to merge
25
commits into
microsoft:main
Choose a base branch
from
vipulb91:feature/get-changed-items
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
be1c79b
Closes #865 - Add get_changed_items() utility for git-based selective…
vipulb91 27a75df
docs: add token_credential to FabricWorkspace example in optional_fea…
vipulb91 21e6179
docs: add append_feature_flag calls above workspace init in example
vipulb91 374900f
refactor: move get_changed_items logic to _common/_git_utils.py
vipulb91 d14ff49
security: add validate_git_compare_ref to prevent git flag injection
vipulb91 754457b
fix: catch subprocess.TimeoutExpired in git diff call
vipulb91 34ce0fa
fix: add -- separator to git diff command to prevent flag injection
vipulb91 e63a481
fix: add 30s timeout to subprocess.run() calls in git_utils
vipulb91 a498bb0
security: add _resolve_git_diff_path() for path validation in git dif…
vipulb91 1756bb5
Merge branch 'main' into feature/get-changed-items
vipulb91 99c8289
Merge branch 'main' into feature/get-changed-items
shirasassoon 66b9fca
Merge branch 'main' into feature/get-changed-items
shirasassoon a3f8632
fix: add underscore to allowed chars in validate_git_compare_ref regex
vipulb91 fc263b5
fix: remove redundant -- separator from git diff as ref is already va…
vipulb91 e9b30b8
docs: add token_credential to FabricWorkspace example in get_changed_…
vipulb91 17fea6f
docs: clarify feature flag requirement for items_to_include in option…
vipulb91 ce0f687
refactor: rename _git_utils.py to _git_diff_utils.py for specificity
vipulb91 178573e
docs: add override note to get_items_to_publish docstring
vipulb91 1c17387
refactor: remove redundant get_changed_items import from publish.py
vipulb91 543bcfa
test: move get_changed_items tests to test_git_diff_utils.py and add …
vipulb91 17ac12d
test: add stronger test coverage for git diff utils and validate_git_…
vipulb91 41be77b
docs: add token_credential to FabricWorkspace example in publish_all_…
vipulb91 b8bdbda
fix: remove unused import and fix import ordering in test_git_diff_utils
vipulb91 b9459e5
Merge branch 'main' into feature/get-changed-items
shirasassoon 5d2ea14
fix: assert exact git diff argv list in test_uses_custom_git_compare_ref
vipulb91 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| kind: added | ||
| body: Add `get_changed_items()` utility function to detect Fabric items changed via git diff for use with selective deployment | ||
| time: 2026-03-19T09:56:18.0000000+00:00 | ||
| custom: | ||
| Author: vipulb91 | ||
| AuthorLink: https://github.com/vipulb91 | ||
| Issue: "865" | ||
| IssueLink: https://github.com/microsoft/fabric-cicd/issues/865 | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
vipulb91 marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
|
|
||
| """Utility functions for detecting Fabric items changed via git diff.""" | ||
|
|
||
| import json | ||
| import logging | ||
| import subprocess | ||
| from pathlib import Path | ||
| from typing import Optional | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _find_platform_item(file_path: Path, repo_root: Path) -> Optional[tuple[str, str]]: | ||
| """ | ||
| Walk up from file_path towards repo_root looking for a .platform file. | ||
|
|
||
| The .platform file marks the boundary of a Fabric item directory. | ||
| Its JSON content contains ``metadata.type`` (item type) and | ||
| ``metadata.displayName`` (item name). | ||
|
|
||
| Returns: | ||
| A ``(item_name, item_type)`` tuple, or ``None`` if not found. | ||
| """ | ||
| current = file_path.parent | ||
| while True: | ||
| platform_file = current / ".platform" | ||
| if platform_file.exists(): | ||
| try: | ||
| data = json.loads(platform_file.read_text(encoding="utf-8")) | ||
| metadata = data.get("metadata", {}) | ||
| item_type = metadata.get("type") | ||
| item_name = metadata.get("displayName") or current.name | ||
| if item_type: | ||
| return item_name, item_type | ||
| except Exception as exc: | ||
| logger.debug(f"Could not parse .platform file at '{platform_file}': {exc}") | ||
| # Stop if we have reached the repository root or the filesystem root | ||
| if current == repo_root or current == current.parent: | ||
| break | ||
| current = current.parent | ||
| return None | ||
|
|
||
|
|
||
| def _resolve_git_diff_path( | ||
| file_path_str: str, | ||
| git_root: Path, | ||
| repository_directory: Path, | ||
| ) -> Optional[Path]: | ||
| """ | ||
| Resolve and validate a file path from git diff output. | ||
|
|
||
| Follows the same resolve → boundary-check → reject contract as | ||
| ``_resolve_file_path`` in ``_parameter/_utils.py``, adapted for | ||
| paths that are relative to a git root with containment checked | ||
| against a (potentially different) repository subdirectory. | ||
|
|
||
| Args: | ||
| file_path_str: Relative path string from git diff output. | ||
| git_root: Resolved absolute path of the git repository root. | ||
| repository_directory: Resolved absolute path of the configured | ||
| repository directory (may be a subdirectory of git_root). | ||
|
|
||
| Returns: | ||
| Resolved absolute Path if valid and within boundary, None otherwise. | ||
| """ | ||
| raw_path = Path(file_path_str) | ||
|
|
||
| # Reject absolute paths — git diff should only produce relative paths | ||
| if raw_path.is_absolute(): | ||
| logger.debug(f"get_changed_items: skipping absolute path '{file_path_str}'") | ||
| return None | ||
|
|
||
| # Reject traversal sequences before resolution (mirrors _validate_wildcard_syntax) | ||
| if ".." in raw_path.parts: | ||
| logger.debug(f"get_changed_items: skipping path with traversal '{file_path_str}'") | ||
| return None | ||
|
|
||
| # Reject null bytes | ||
| if "\x00" in file_path_str: | ||
| logger.debug("get_changed_items: skipping path with null bytes") | ||
| return None | ||
|
|
||
| # Step 1: Resolve relative to git root (analogous to _resolve_file_path Step 1) | ||
| resolved_path = (git_root / file_path_str).resolve() | ||
|
|
||
| # Step 2: Boundary check against repository_directory (analogous to _resolve_file_path Step 2) | ||
| try: | ||
| resolved_path.relative_to(repository_directory) | ||
| except ValueError: | ||
| return None | ||
|
|
||
| # Note: No Step 3 (existence check) — deleted files won't exist on disk | ||
| return resolved_path | ||
|
|
||
|
|
||
| def get_changed_items( | ||
| repository_directory: Path, | ||
| git_compare_ref: str = "HEAD~1", | ||
| ) -> list[str]: | ||
| """ | ||
| Return the list of Fabric items that were added, modified, or renamed relative to ``git_compare_ref``. | ||
|
|
||
| The returned list is in ``"item_name.item_type"`` format and can be passed directly | ||
| to the ``items_to_include`` parameter of :func:`publish_all_items` to deploy only | ||
| what has changed since the last commit. | ||
|
|
||
| Args: | ||
| repository_directory: Path to the local git repository directory | ||
| (e.g. ``FabricWorkspace.repository_directory``). | ||
| git_compare_ref: Git ref to compare against. Defaults to ``"HEAD~1"``. | ||
|
|
||
| Returns: | ||
| List of strings in ``"item_name.item_type"`` format. Returns an empty list when | ||
| no changes are detected, the git root cannot be found, or git is unavailable. | ||
|
|
||
| Examples: | ||
| Deploy only changed items | ||
| >>> from azure.identity import AzureCliCredential | ||
| >>> from fabric_cicd import FabricWorkspace, publish_all_items, get_changed_items | ||
| >>> workspace = FabricWorkspace( | ||
| ... workspace_id="your-workspace-id", | ||
| ... repository_directory="/path/to/repo", | ||
| ... item_type_in_scope=["Notebook", "DataPipeline"], | ||
| ... token_credential=AzureCliCredential() | ||
| ... ) | ||
| >>> changed = get_changed_items(workspace.repository_directory) | ||
| >>> if changed: | ||
| ... publish_all_items(workspace, items_to_include=changed) | ||
|
|
||
| With a custom git ref | ||
| >>> changed = get_changed_items(workspace.repository_directory, git_compare_ref="main") | ||
| >>> if changed: | ||
| ... publish_all_items(workspace, items_to_include=changed) | ||
| """ | ||
| changed, _ = _resolve_changed_items(Path(repository_directory), git_compare_ref) | ||
| return changed | ||
|
|
||
|
|
||
| def _resolve_changed_items( | ||
| repository_directory: Path, | ||
| git_compare_ref: str, | ||
| ) -> tuple[list[str], list[str]]: | ||
| """ | ||
| Use ``git diff --name-status`` to detect Fabric items that changed or were | ||
| deleted relative to *git_compare_ref*. | ||
|
|
||
| Args: | ||
| repository_directory: Absolute path to the local repository directory | ||
| (as stored on ``FabricWorkspace.repository_directory``). | ||
| git_compare_ref: Git ref to diff against (e.g. ``"HEAD~1"``). | ||
|
|
||
| Returns: | ||
| A two-element tuple ``(changed_items, deleted_items)`` where each | ||
| element is a list of strings in ``"item_name.item_type"`` format. | ||
| Both lists are empty when the git root cannot be found or git fails. | ||
| """ | ||
| from fabric_cicd._common._config_validator import _find_git_root | ||
| from fabric_cicd._common._validate_input import validate_git_compare_ref | ||
|
|
||
| validate_git_compare_ref(git_compare_ref) | ||
|
|
||
| git_root = _find_git_root(repository_directory) | ||
| if git_root is None: | ||
| logger.warning("get_changed_items: could not locate a git repository root — returning empty list.") | ||
| return [], [] | ||
|
|
||
| try: | ||
| result = subprocess.run( | ||
| ["git", "diff", "--name-status", git_compare_ref], | ||
| cwd=str(git_root), | ||
|
vipulb91 marked this conversation as resolved.
|
||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| timeout=30, | ||
| ) | ||
| except subprocess.CalledProcessError as exc: | ||
| logger.warning(f"get_changed_items: 'git diff' failed ({exc.stderr.strip()}) — returning empty list.") | ||
| return [], [] | ||
| except subprocess.TimeoutExpired: | ||
| logger.warning("get_changed_items: 'git diff' timed out — returning empty list.") | ||
| return [], [] | ||
|
|
||
| changed_items: set[str] = set() | ||
| deleted_items: set[str] = set() | ||
|
|
||
| git_root_resolved = git_root.resolve() | ||
| repo_dir_resolved = repository_directory.resolve() | ||
|
|
||
| for line in result.stdout.splitlines(): | ||
| line = line.strip() | ||
| if not line: | ||
| continue | ||
|
|
||
| parts = line.split("\t") | ||
| status = parts[0].strip() | ||
|
|
||
| # Renames produce three tab-separated fields: R<score>\told\tnew | ||
| if status.startswith("R") and len(parts) >= 3: | ||
| file_path_str = parts[2] | ||
| elif len(parts) >= 2: | ||
| file_path_str = parts[1] | ||
| else: | ||
| continue | ||
|
|
||
| abs_path = _resolve_git_diff_path(file_path_str, git_root_resolved, repo_dir_resolved) | ||
| if abs_path is None: | ||
| continue | ||
|
|
||
| if status == "D": | ||
| if abs_path.name == ".platform": | ||
| try: | ||
| show_result = subprocess.run( | ||
| ["git", "show", f"{git_compare_ref}:{file_path_str}"], | ||
| cwd=str(git_root_resolved), | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| timeout=30, | ||
| ) | ||
| data = json.loads(show_result.stdout) | ||
| metadata = data.get("metadata", {}) | ||
| item_type = metadata.get("type") | ||
| item_name = metadata.get("displayName") or abs_path.parent.name | ||
| if item_type and item_name: | ||
| deleted_items.add(f"{item_name}.{item_type}") | ||
| except Exception as exc: | ||
| logger.debug(f"get_changed_items: could not read deleted .platform '{file_path_str}': {exc}") | ||
| else: | ||
| item_info = _find_platform_item(abs_path, repo_dir_resolved) | ||
| if item_info: | ||
| changed_items.add(f"{item_info[0]}.{item_info[1]}") | ||
|
|
||
| return list(changed_items), list(deleted_items) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.