Add library git change detection and management system

Features:
- Yellow banner alert on Library tab when changes detected
- Custom diff viewer showing changed symbols/footprints by name
- Categorizes changes: symbols, footprints, and other files
- Color-coded status indicators (added/modified/deleted/untracked)
- Commit and push functionality from UI
- Smart authentication error handling with helpful messages
- Git configuration info display (remote URL, auth type, credential helper)
- Terminal opener for manual git credential setup
- Auto-refresh after commits to update status

Backend:
- check_library_git_status(): Checks for uncommitted/unpushed changes
- get_library_changed_parts(): Parses git status and categorizes by part type
- Socket handlers for git status, changes, commit, and push operations
- Authentication type detection (SSH vs HTTPS)
- Context-specific error messages for auth failures

Frontend:
- Git status banner with view changes button
- Modal with tabular display of changed parts
- Commit message input with commit/push buttons
- Real-time status updates during git operations
- Git config info section with terminal helper button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-02-28 12:14:03 -06:00
parent 7a7a9dfdcc
commit 4281fa2b97
2 changed files with 784 additions and 0 deletions

471
app.py
View File

@@ -92,6 +92,182 @@ def get_kicad_lib_path():
return None
return os.path.join(um_kicad, 'lib')
def check_library_git_status():
"""Check if the library repository has uncommitted changes or unpushed commits."""
um_kicad = os.environ.get('UM_KICAD')
print(f"[DEBUG] check_library_git_status called, UM_KICAD={um_kicad}")
if not um_kicad or not os.path.exists(um_kicad):
print(f"[DEBUG] Path check failed: exists={os.path.exists(um_kicad) if um_kicad else 'N/A'}")
return {'error': 'Library repository path not found'}
# The git repository is at ${UM_KICAD}/lib
lib_repo_path = os.path.join(um_kicad, 'lib')
print(f"[DEBUG] Checking git repo at: {lib_repo_path}")
if not os.path.exists(lib_repo_path):
print(f"[DEBUG] Library path does not exist: {lib_repo_path}")
return {'error': 'Library path not found'}
try:
# Check if it's a git repository
result = subprocess.run(
['git', 'rev-parse', '--git-dir'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
print(f"[DEBUG] Not a git repository: {result.stderr}")
return {'error': 'Not a git repository'}
print(f"[DEBUG] Git repo detected at {lib_repo_path}")
# Check for uncommitted changes (staged or unstaged)
status_result = subprocess.run(
['git', 'status', '--porcelain'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
has_changes = bool(status_result.stdout.strip())
print(f"[DEBUG] Git status output: {repr(status_result.stdout)}")
print(f"[DEBUG] has_changes={has_changes}")
# Check for unpushed commits
unpushed_result = subprocess.run(
['git', 'log', '@{u}..', '--oneline'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
has_unpushed = bool(unpushed_result.stdout.strip())
print(f"[DEBUG] Unpushed commits output: {repr(unpushed_result.stdout)}")
print(f"[DEBUG] has_unpushed={has_unpushed}")
# Get current branch
branch_result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else 'unknown'
result_data = {
'has_changes': has_changes,
'has_unpushed': has_unpushed,
'current_branch': current_branch,
'repo_path': lib_repo_path
}
print(f"[DEBUG] Returning git status: {result_data}")
return result_data
except subprocess.TimeoutExpired:
return {'error': 'Git command timeout'}
except FileNotFoundError:
return {'error': 'Git not found'}
except Exception as e:
return {'error': str(e)}
def get_library_changed_parts():
"""Get list of changed parts (symbols/footprints) in the library repository."""
um_kicad = os.environ.get('UM_KICAD')
if not um_kicad or not os.path.exists(um_kicad):
return {'error': 'Library repository path not found'}
lib_repo_path = os.path.join(um_kicad, 'lib')
if not os.path.exists(lib_repo_path):
return {'error': 'Library path not found'}
try:
# Get list of changed files
status_result = subprocess.run(
['git', 'status', '--porcelain'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
if status_result.returncode != 0:
return {'error': 'Failed to get git status'}
changed_symbols = []
changed_footprints = []
other_changes = []
for line in status_result.stdout.strip().split('\n'):
if not line.strip():
continue
# Parse git status line: "XY filename"
status = line[:2]
filepath = line[3:].strip()
# Remove quotes if present
if filepath.startswith('"') and filepath.endswith('"'):
filepath = filepath[1:-1]
# Determine change type
change_type = 'modified'
if 'A' in status:
change_type = 'added'
elif 'D' in status:
change_type = 'deleted'
elif 'M' in status:
change_type = 'modified'
elif '?' in status:
change_type = 'untracked'
# Check if it's a symbol file
if filepath.startswith('symbols/') and filepath.endswith('.kicad_sym'):
# Extract library name and get symbols within it
lib_name = os.path.basename(filepath)[:-10] # Remove .kicad_sym
changed_symbols.append({
'library': lib_name,
'file': filepath,
'change_type': change_type
})
# Check if it's a footprint file
elif filepath.startswith('footprints/') and '.pretty/' in filepath and filepath.endswith('.kicad_mod'):
# Extract library name and footprint name
parts = filepath.split('/')
lib_name = parts[1] if len(parts) > 1 else 'unknown'
fp_name = os.path.basename(filepath)[:-10] # Remove .kicad_mod
changed_footprints.append({
'library': lib_name,
'footprint': fp_name,
'file': filepath,
'change_type': change_type
})
else:
# Other changes (themes, etc.)
other_changes.append({
'file': filepath,
'change_type': change_type
})
return {
'symbols': changed_symbols,
'footprints': changed_footprints,
'other': other_changes,
'total_changes': len(changed_symbols) + len(changed_footprints) + len(other_changes)
}
except subprocess.TimeoutExpired:
return {'error': 'Git command timeout'}
except FileNotFoundError:
return {'error': 'Git not found'}
except Exception as e:
return {'error': str(e)}
def get_symbol_libraries():
"""Get list of all symbol library files."""
lib_path = get_kicad_lib_path()
@@ -2161,6 +2337,301 @@ def handle_get_footprints_in_library(data):
import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('check_library_git_status')
def handle_check_library_git_status():
"""Check git status of the library repository."""
try:
status = check_library_git_status()
emit('library_git_status', status)
except Exception as e:
import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('get_library_changes')
def handle_get_library_changes():
"""Get detailed list of changed parts in the library repository."""
try:
changes = get_library_changed_parts()
emit('library_changes_result', changes)
except Exception as e:
import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('commit_library_changes')
def handle_commit_library_changes(data):
"""Commit changes to the library repository."""
try:
commit_message = data.get('message', '').strip()
if not commit_message:
emit('library_commit_error', {'error': 'Commit message is required'})
return
um_kicad = os.environ.get('UM_KICAD')
if not um_kicad:
emit('library_commit_error', {'error': 'UM_KICAD not set'})
return
lib_repo_path = os.path.join(um_kicad, 'lib')
if not os.path.exists(lib_repo_path):
emit('library_commit_error', {'error': 'Library path not found'})
return
emit('library_commit_status', {'status': 'Staging all changes...'})
# Stage all changes
add_result = subprocess.run(
['git', 'add', '-A'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=10
)
if add_result.returncode != 0:
emit('library_commit_error', {'error': f'Failed to stage changes: {add_result.stderr}'})
return
emit('library_commit_status', {'status': 'Committing changes...'})
# Commit changes
commit_result = subprocess.run(
['git', 'commit', '-m', commit_message],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=10
)
if commit_result.returncode != 0:
# Check if it's just "nothing to commit"
if 'nothing to commit' in commit_result.stdout.lower():
emit('library_commit_error', {'error': 'No changes to commit'})
else:
emit('library_commit_error', {'error': f'Commit failed: {commit_result.stderr}'})
return
emit('library_commit_complete', {'message': 'Changes committed successfully!'})
# Refresh git status
status = check_library_git_status()
emit('library_git_status', status)
except subprocess.TimeoutExpired:
emit('library_commit_error', {'error': 'Git command timeout'})
except Exception as e:
import traceback
emit('library_commit_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('get_git_remote_info')
def handle_get_git_remote_info():
"""Get information about the git remote configuration."""
try:
um_kicad = os.environ.get('UM_KICAD')
if not um_kicad:
emit('git_remote_info', {'error': 'UM_KICAD not set'})
return
lib_repo_path = os.path.join(um_kicad, 'lib')
if not os.path.exists(lib_repo_path):
emit('git_remote_info', {'error': 'Library path not found'})
return
# Get remote URL
remote_result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
if remote_result.returncode != 0:
emit('git_remote_info', {'error': 'No remote configured'})
return
remote_url = remote_result.stdout.strip()
uses_ssh = remote_url.startswith('git@') or remote_url.startswith('ssh://')
# Get credential helper config
cred_result = subprocess.run(
['git', 'config', '--get', 'credential.helper'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
credential_helper = cred_result.stdout.strip() if cred_result.returncode == 0 else 'none'
emit('git_remote_info', {
'remote_url': remote_url,
'uses_ssh': uses_ssh,
'credential_helper': credential_helper,
'repo_path': lib_repo_path
})
except Exception as e:
import traceback
emit('git_remote_info', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('setup_git_credentials')
def handle_setup_git_credentials():
"""Open a terminal window at the library repo for manual git credential setup."""
try:
um_kicad = os.environ.get('UM_KICAD')
if not um_kicad:
emit('git_setup_error', {'error': 'UM_KICAD not set'})
return
lib_repo_path = os.path.join(um_kicad, 'lib')
if not os.path.exists(lib_repo_path):
emit('git_setup_error', {'error': 'Library path not found'})
return
# Open terminal at the repo location
# Windows: open cmd
if os.name == 'nt':
subprocess.Popen(['cmd', '/K', f'cd /d {lib_repo_path}'])
else:
# Linux/Mac: try common terminals
try:
subprocess.Popen(['gnome-terminal', '--working-directory', lib_repo_path])
except:
try:
subprocess.Popen(['xterm', '-e', f'cd {lib_repo_path} && bash'])
except:
subprocess.Popen(['open', '-a', 'Terminal', lib_repo_path])
emit('git_setup_complete', {'message': f'Terminal opened at: {lib_repo_path}\n\nYou can now run git commands to set up authentication.'})
except Exception as e:
import traceback
emit('git_setup_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('commit_push_library_changes')
def handle_commit_push_library_changes(data):
"""Commit and push changes to the library repository."""
try:
commit_message = data.get('message', '').strip()
if not commit_message:
emit('library_commit_error', {'error': 'Commit message is required'})
return
um_kicad = os.environ.get('UM_KICAD')
if not um_kicad:
emit('library_commit_error', {'error': 'UM_KICAD not set'})
return
lib_repo_path = os.path.join(um_kicad, 'lib')
if not os.path.exists(lib_repo_path):
emit('library_commit_error', {'error': 'Library path not found'})
return
emit('library_commit_status', {'status': 'Staging all changes...'})
# Stage all changes
add_result = subprocess.run(
['git', 'add', '-A'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=10
)
if add_result.returncode != 0:
emit('library_commit_error', {'error': f'Failed to stage changes: {add_result.stderr}'})
return
emit('library_commit_status', {'status': 'Committing changes...'})
# Commit changes
commit_result = subprocess.run(
['git', 'commit', '-m', commit_message],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=10
)
if commit_result.returncode != 0:
if 'nothing to commit' in commit_result.stdout.lower():
# No commit, but maybe there are unpushed commits - try to push anyway
emit('library_commit_status', {'status': 'No new changes to commit, checking for unpushed commits...'})
else:
emit('library_commit_error', {'error': f'Commit failed: {commit_result.stderr}'})
return
emit('library_commit_status', {'status': 'Pushing to remote...'})
# Get remote URL to check auth type
remote_result = subprocess.run(
['git', 'config', '--get', 'remote.origin.url'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=5
)
remote_url = remote_result.stdout.strip() if remote_result.returncode == 0 else ''
uses_ssh = remote_url.startswith('git@') or remote_url.startswith('ssh://')
# Push to remote
push_result = subprocess.run(
['git', 'push'],
cwd=lib_repo_path,
capture_output=True,
text=True,
timeout=30
)
if push_result.returncode != 0:
error_msg = push_result.stderr
# Provide helpful error messages based on the type of failure
if 'Authentication failed' in error_msg or 'Failed to authenticate' in error_msg:
if uses_ssh:
helpful_msg = (
f'Push failed: Authentication error.\n\n'
f'Your repository uses SSH authentication ({remote_url}).\n\n'
f'To fix this:\n'
f'1. Ensure your SSH key is added to your Git hosting service (GitHub/GitLab/etc.)\n'
f'2. Test SSH access: ssh -T git@github.com (or your Git host)\n'
f'3. Make sure ssh-agent is running and has your key loaded\n\n'
f'Original error: {error_msg}'
)
else:
helpful_msg = (
f'Push failed: Authentication error.\n\n'
f'Your repository uses HTTPS authentication ({remote_url}).\n\n'
f'To fix this:\n'
f'1. Configure Git Credential Manager:\n'
f' git config --global credential.helper manager-core\n'
f'2. Or use a personal access token instead of password\n'
f'3. You can also switch to SSH authentication\n\n'
f'To configure credentials now, open a terminal in:\n'
f'{lib_repo_path}\n'
f'And run: git push\n\n'
f'Original error: {error_msg}'
)
emit('library_commit_error', {'error': helpful_msg})
elif 'rejected' in error_msg.lower():
emit('library_commit_error', {'error': f'Push rejected. The remote has changes you don\'t have locally.\n\nPlease pull changes first:\n{error_msg}'})
else:
emit('library_commit_error', {'error': f'Push failed: {error_msg}'})
return
emit('library_commit_complete', {'message': 'Changes committed and pushed successfully!'})
# Refresh git status
status = check_library_git_status()
emit('library_git_status', status)
except subprocess.TimeoutExpired:
emit('library_commit_error', {'error': 'Git command timeout'})
except Exception as e:
import traceback
emit('library_commit_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('render_footprint')
def handle_render_footprint(data):
try: