33
44import argparse
55import os
6+ import re
67import subprocess
78import sys
89from pathlib import Path
2728from config import _parse_yaml , load_config # noqa: E402
2829
2930
31+ _GIT_TIMEOUT = 30 # seconds
32+
33+
3034def git_pull (repo_path , branch ):
31- """Pull latest from origin."""
32- return subprocess .run (
33- ["git" , "-C" , str (repo_path ), "pull" , "origin" , branch , "--ff-only" ],
34- capture_output = True ,
35- text = True ,
36- )
35+ """Pull latest from origin. Returns CompletedProcess, or None on timeout."""
36+ try :
37+ return subprocess .run (
38+ ["git" , "-C" , str (repo_path ), "pull" , "origin" , branch , "--ff-only" ],
39+ capture_output = True ,
40+ text = True ,
41+ timeout = _GIT_TIMEOUT ,
42+ )
43+ except subprocess .TimeoutExpired :
44+ print (f"Warning: git pull timed out for { repo_path } (branch: { branch } )" , file = sys .stderr )
45+ return None
3746
3847
3948def count_delta (repo_path ):
40- """Count added, modified, and deleted markdown files since last pull."""
49+ """Count added/ modified/ deleted .md files since last pull."""
4150 result = subprocess .run (
4251 ["git" , "-C" , str (repo_path ), "diff" , "--name-status" , "HEAD@{1}" , "HEAD" ],
4352 capture_output = True ,
4453 text = True ,
54+ timeout = _GIT_TIMEOUT ,
4555 )
56+ if result .returncode != 0 :
57+ # HEAD@{1} doesn't exist (initial sync) — count all .md files as added.
58+ added = len (list (repo_path .glob ("**/*.md" )))
59+ return {"added" : added , "updated" : 0 , "removed" : 0 }
4660 added = updated = removed = 0
4761 for line in result .stdout .splitlines ():
4862 if not line .strip ():
@@ -103,19 +117,25 @@ def main():
103117 summaries = []
104118 total_delta = {}
105119 any_changes = False
120+ safe_name = re .compile (r"^[A-Za-z0-9._-]+$" )
106121
107122 for sub in subscriptions :
108123 if not isinstance (sub , dict ):
109124 continue
110125 name = sub .get ("name" , "unknown" )
111126 branch = sub .get ("branch" , "main" )
127+
128+ if not safe_name .match (name ):
129+ summaries .append (f"{ name !r} (skipped - invalid subscription name)" )
130+ continue
131+
112132 subscribed_base = (evolve_dir / "entities" / "subscribed" ).resolve ()
113133 repo_path = (evolve_dir / "entities" / "subscribed" / name ).resolve ()
114134 legacy_base = (evolve_dir / "subscribed" ).resolve ()
115135 legacy_repo_path = (evolve_dir / "subscribed" / name ).resolve ()
116136
117137 if repo_path == subscribed_base or not repo_path .is_relative_to (subscribed_base ):
118- summaries .append (f"{ name } (invalid subscription name)" )
138+ summaries .append (f"{ name !r } (skipped - invalid subscription name)" )
119139 continue
120140
121141 if legacy_repo_path != legacy_base and legacy_repo_path .is_relative_to (legacy_base ):
@@ -131,13 +151,17 @@ def main():
131151 continue
132152
133153 pull_result = git_pull (repo_path , branch )
134- if pull_result .returncode != 0 :
154+ if pull_result is None or pull_result .returncode != 0 :
135155 error_lines = (pull_result .stderr or pull_result .stdout or "" ).strip ().splitlines ()
136156 short_error = error_lines [- 1 ] if error_lines else "unknown error"
137157 summaries .append (f"{ name } (git pull failed: { short_error } )" )
158+ total_delta [name ] = {"added" : 0 , "updated" : 0 , "removed" : 0 }
138159 continue
139160
140- delta = count_delta (repo_path )
161+ if "Already up to date" in (pull_result .stdout or "" ):
162+ delta = {"added" : 0 , "updated" : 0 , "removed" : 0 }
163+ else :
164+ delta = count_delta (repo_path )
141165 total_delta [name ] = delta
142166 if any (value > 0 for value in delta .values ()):
143167 any_changes = True
0 commit comments