Skip to content

Instantly share code, notes, and snippets.

@YarGnawh
Last active August 6, 2025 14:23
Show Gist options
  • Save YarGnawh/8853cd13fcc498feb91d94a96bec48aa to your computer and use it in GitHub Desktop.
Save YarGnawh/8853cd13fcc498feb91d94a96bec48aa to your computer and use it in GitHub Desktop.
Azure DevOps TFVC Changeset Diff Generator
#!/bin/bash
# Azure DevOps TFVC Changeset Diff Generator
#
# A bash script that generates unified diff patches from Azure DevOps TFVC (Team Foundation Version Control) changesets. This tool solves the common problem of extracting readable diffs from TFVC changesets, which Azure DevOps doesn't provide natively through # its API.
#
# Features
#
# - Fetches all files modified in a specific TFVC changeset
# - Retrieves both current and previous versions of each file
# - Generates a standard unified diff patch file
# - Handles text files, binary files, additions, and deletions
# - Outputs to a single .patch file that can be reviewed or applied elsewhere
#
# Use Cases
#
# - Code review outside of Azure DevOps
# - Archiving changeset modifications
# - Migrating TFVC history to Git
# - Analyzing changes across multiple files in a changeset
# - Creating portable change documentation
#
# Requirements
#
# - Bash shell
# - curl for API calls
# - jq for JSON parsing
# - Azure DevOps Personal Access Token (PAT) with TFVC read permissions
#
# Usage
#
# ./get_changeset_diff.sh <changeset_id>
#
# Outputs to: changeset_<id>.patch
#
# Note
#
# Update the script with your Azure DevOps organization, project, and PAT before use.
#
# ---
# This script fills a gap in Azure DevOps TFVC tooling by providing a simple way to extract and review changeset differences in a standard format.
set -e
# Configuration
PAT="generate in azure devops"
ORGANIZATION="org name"
PROJECT="project name"
API_VERSION="7.1"
BASE_URL="https://dev.azure.com/${ORGANIZATION}"
# Check arguments
if [ $# -ne 1 ]; then
echo "Usage: $0 <changeset_id>"
exit 1
fi
CHANGESET_ID=$1
PREVIOUS_CHANGESET=$((CHANGESET_ID - 1))
OUTPUT_FILE="changeset_${CHANGESET_ID}.patch"
# Create temp directory for files
TEMP_DIR=$(mktemp -d)
trap "rm -rf ${TEMP_DIR}" EXIT
echo "Fetching changeset ${CHANGESET_ID} details..."
# Get list of changes in the changeset
CHANGES_JSON=$(curl -s -u ":${PAT}" \
"${BASE_URL}/_apis/tfvc/changesets/${CHANGESET_ID}/changes?api-version=${API_VERSION}")
# Parse the changes and get file paths
echo "${CHANGES_JSON}" | jq -r '.value[]? | select(.item.path != null) | .item.path' > "${TEMP_DIR}/changed_files.txt"
if [ ! -s "${TEMP_DIR}/changed_files.txt" ]; then
echo "No file changes found in changeset ${CHANGESET_ID}"
exit 1
fi
echo "Found $(wc -l < ${TEMP_DIR}/changed_files.txt) changed files"
echo "Generating diff patch..."
# Initialize patch file with header
cat > "${OUTPUT_FILE}" << EOF
From changeset ${CHANGESET_ID}
Date: $(date)
Subject: Changes from changeset ${CHANGESET_ID}
---
EOF
# Process each changed file
while IFS= read -r FILE_PATH; do
echo "Processing: ${FILE_PATH}"
# Don't URL encode - use the path as-is with proper query params
# Azure DevOps API expects the path parameter to be part of the query string
# Create subdirectories in temp
FILE_NAME=$(basename "${FILE_PATH}")
SAFE_NAME=$(echo "${FILE_PATH}" | sed 's/[^a-zA-Z0-9._-]/_/g')
# Fetch current version (changeset version)
# Note: The path parameter needs to be URL encoded as a query parameter
CURRENT_FILE="${TEMP_DIR}/current_${SAFE_NAME}"
ENCODED_PATH=$(printf '%s' "$FILE_PATH" | jq -sRr @uri)
curl -s -u ":${PAT}" \
"${BASE_URL}/_apis/tfvc/items?path=${ENCODED_PATH}&versionType=Changeset&version=${CHANGESET_ID}&api-version=${API_VERSION}" \
-H "Accept: application/octet-stream" \
-o "${CURRENT_FILE}" 2>/dev/null || true
# Fetch previous version
PREVIOUS_FILE="${TEMP_DIR}/previous_${SAFE_NAME}"
curl -s -u ":${PAT}" \
"${BASE_URL}/_apis/tfvc/items?path=${ENCODED_PATH}&versionType=Changeset&version=${PREVIOUS_CHANGESET}&api-version=${API_VERSION}" \
-H "Accept: application/octet-stream" \
-o "${PREVIOUS_FILE}" 2>/dev/null || true
# Check if files are binary or if previous doesn't exist (new file)
if [ ! -f "${PREVIOUS_FILE}" ] || [ ! -s "${PREVIOUS_FILE}" ]; then
# New file
echo "" >> "${OUTPUT_FILE}"
echo "diff --git a${FILE_PATH} b${FILE_PATH}" >> "${OUTPUT_FILE}"
echo "new file mode 100644" >> "${OUTPUT_FILE}"
echo "--- /dev/null" >> "${OUTPUT_FILE}"
echo "+++ b${FILE_PATH}" >> "${OUTPUT_FILE}"
if [ -f "${CURRENT_FILE}" ] && file "${CURRENT_FILE}" | grep -q text; then
# Add content with + prefix for new files
sed 's/^/+/' "${CURRENT_FILE}" >> "${OUTPUT_FILE}"
else
echo "Binary file added" >> "${OUTPUT_FILE}"
fi
elif [ ! -f "${CURRENT_FILE}" ] || [ ! -s "${CURRENT_FILE}" ]; then
# Deleted file
echo "" >> "${OUTPUT_FILE}"
echo "diff --git a${FILE_PATH} b${FILE_PATH}" >> "${OUTPUT_FILE}"
echo "deleted file mode 100644" >> "${OUTPUT_FILE}"
echo "--- a${FILE_PATH}" >> "${OUTPUT_FILE}"
echo "+++ /dev/null" >> "${OUTPUT_FILE}"
if file "${PREVIOUS_FILE}" | grep -q text; then
# Add content with - prefix for deleted files
sed 's/^/-/' "${PREVIOUS_FILE}" >> "${OUTPUT_FILE}"
else
echo "Binary file deleted" >> "${OUTPUT_FILE}"
fi
else
# Check if files are text files
if file "${CURRENT_FILE}" | grep -q text && file "${PREVIOUS_FILE}" | grep -q text; then
# Generate unified diff
echo "" >> "${OUTPUT_FILE}"
echo "diff --git a${FILE_PATH} b${FILE_PATH}" >> "${OUTPUT_FILE}"
echo "--- a${FILE_PATH}" >> "${OUTPUT_FILE}"
echo "+++ b${FILE_PATH}" >> "${OUTPUT_FILE}"
# Use diff to generate the actual differences
diff -u "${PREVIOUS_FILE}" "${CURRENT_FILE}" | tail -n +3 >> "${OUTPUT_FILE}" 2>/dev/null || true
else
# Binary files
echo "" >> "${OUTPUT_FILE}"
echo "diff --git a${FILE_PATH} b${FILE_PATH}" >> "${OUTPUT_FILE}"
echo "Binary files differ" >> "${OUTPUT_FILE}"
fi
fi
done < "${TEMP_DIR}/changed_files.txt"
echo ""
echo "✅ Diff patch saved to: ${OUTPUT_FILE}"
echo "Total size: $(wc -l < ${OUTPUT_FILE}) lines"
# Optional: Display summary
echo ""
echo "Summary of changes:"
echo "${CHANGES_JSON}" | jq -r '.value[]? | "\(.changeType): \(.item.path)"' 2>/dev/null || true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment