fix(check_upstream_updates): warn when check falls back to a fork's own origin (#265)

On a fork without an 'upstream' remote, the checker silently fell back to 'origin' (the fork itself) and still printed '[OK] All framework files are up to date with upstream!', a false positive: the fork is always up to date with itself, so upstream updates were never reported. This is exactly the setup CONTRIBUTING.md recommends for forks.

Now, when the fallback remote does not point at the ai-job-search template repo, the script warns that the comparison is fork-vs-self and prints the command to add the template as a remote. The final OK line now names the ref it actually compared against.

Tests (new tests/test_check_upstream_updates.py, three scenarios) fail on master and pass with the fix.
This commit is contained in:
Oscar Madera
2026-08-01 22:00:49 +02:00
committed by GitHub
parent 1cdaf9497f
commit 72bbe00529
2 changed files with 138 additions and 1 deletions
+119
View File
@@ -0,0 +1,119 @@
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "tools" / "check_upstream_updates.py"
TEMPLATE_URL = "https://github.com/MadsLorentzen/ai-job-search.git"
FORK_URL = "https://github.com/octocat/ai-job-search.git"
FRAMEWORK_FILES = [
".claude/skills/job-application-assistant/01-candidate-profile.md",
".claude/skills/job-application-assistant/02-behavioral-profile.md",
".claude/skills/job-application-assistant/03-writing-style.md",
".claude/skills/job-application-assistant/04-job-evaluation.md",
".claude/skills/job-application-assistant/05-cv-templates.md",
".claude/skills/job-application-assistant/06-cover-letter-templates.md",
".claude/skills/job-application-assistant/07-interview-prep.md",
".claude/skills/job-application-assistant/08-application-forms.md",
".claude/skills/job-application-assistant/SKILL.md",
"AGENTS.md",
]
FRONTMATTER = "---\nframework_version: 1.0.0\n---\n"
class UpstreamCheckerRepoFixture(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
tools = self.root / "tools"
tools.mkdir()
shutil.copy(SCRIPT, tools / "check_upstream_updates.py")
for rel in FRAMEWORK_FILES:
path = self.root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(FRONTMATTER, encoding="utf-8")
subprocess.run(["git", "init", "-b", "master"], cwd=self.root, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "Test"], cwd=self.root, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.root, check=True, capture_output=True)
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=self.root, check=True, capture_output=True)
def add_remote(self, name: str, url: str) -> None:
subprocess.run(["git", "remote", "add", name, url], cwd=self.root, check=True, capture_output=True)
def materialize_remote_ref(self, name: str) -> None:
subprocess.run(
["git", "update-ref", f"refs/remotes/{name}/master", "HEAD"],
cwd=self.root,
check=True,
capture_output=True,
)
def run_checker(self, *args) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(self.root / "tools" / "check_upstream_updates.py"), "--no-fetch", *args],
cwd=self.root,
capture_output=True,
text=True,
)
class ForkWithoutUpstreamRemoteTests(UpstreamCheckerRepoFixture):
def setUp(self):
super().setUp()
self.add_remote("origin", FORK_URL)
self.materialize_remote_ref("origin")
def test_fork_fallback_warns_that_check_is_against_own_fork(self):
result = self.run_checker("--remote", "upstream")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Falling back to 'origin'", result.stdout)
self.assertIn("does not point to the ai-job-search template repo", result.stdout)
self.assertNotIn("up to date with upstream!", result.stdout)
self.assertIn("up to date with origin/master", result.stdout)
class DirectCloneFallbackTests(UpstreamCheckerRepoFixture):
def setUp(self):
super().setUp()
self.add_remote("origin", TEMPLATE_URL)
self.materialize_remote_ref("origin")
def test_clone_of_template_falls_back_without_fork_warning(self):
result = self.run_checker("--remote", "upstream")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Falling back to 'origin'", result.stdout)
self.assertNotIn("does not point to the ai-job-search template repo", result.stdout)
self.assertIn("up to date with origin/master", result.stdout)
class UpstreamRemotePresentTests(UpstreamCheckerRepoFixture):
def setUp(self):
super().setUp()
self.add_remote("origin", FORK_URL)
self.add_remote("upstream", TEMPLATE_URL)
self.materialize_remote_ref("upstream")
def test_explicit_upstream_remote_is_used_without_warning(self):
result = self.run_checker()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("Falling back to 'origin'", result.stdout)
self.assertNotIn("does not point to the ai-job-search template repo", result.stdout)
self.assertIn("up to date with upstream/master", result.stdout)
if __name__ == "__main__":
unittest.main()
+19 -1
View File
@@ -33,10 +33,16 @@ FRAMEWORK_FILES = [
"AGENTS.md", "AGENTS.md",
] ]
UPSTREAM_REPO_SLUG = "MadsLorentzen/ai-job-search"
def run_git(args: list[str]) -> tuple[int, str, str]: def run_git(args: list[str]) -> tuple[int, str, str]:
res = subprocess.run(["git"] + args, cwd=str(ROOT), capture_output=True, text=True) res = subprocess.run(["git"] + args, cwd=str(ROOT), capture_output=True, text=True)
return res.returncode, res.stdout, res.stderr return res.returncode, res.stdout, res.stderr
def get_remote_url(remote_name: str) -> str:
rc, stdout, _ = run_git(["remote", "get-url", remote_name])
return stdout.strip() if rc == 0 else ""
def get_framework_version_from_text(text: str) -> str | None: def get_framework_version_from_text(text: str) -> str | None:
if not text.startswith("---\n"): if not text.startswith("---\n"):
return None return None
@@ -76,6 +82,18 @@ def main() -> int:
print("Error: No git remotes found.") print("Error: No git remotes found.")
return 1 return 1
# A fork's own 'origin' can never reveal upstream updates: warn so the
# user is not misled by the final '[OK]' line below. (Direct clones of
# the template repo have origin == the upstream repo, so no warning.)
if remote != args.remote and UPSTREAM_REPO_SLUG not in get_remote_url(remote):
print(
f"Warning: Remote '{remote}' does not point to the ai-job-search "
f"template repo ({UPSTREAM_REPO_SLUG}), so this check compares your "
f"fork against itself and will never report upstream updates. "
f"Add the template repo as a remote to track upstream changes, e.g.:\n"
f" git remote add upstream https://github.com/{UPSTREAM_REPO_SLUG}.git"
)
if not args.no_fetch: if not args.no_fetch:
print(f"Fetching latest from remote '{remote}'...") print(f"Fetching latest from remote '{remote}'...")
rc, _, stderr = run_git(["fetch", remote]) rc, _, stderr = run_git(["fetch", remote])
@@ -143,7 +161,7 @@ def main() -> int:
print("Review these changes to see if they fit your personalized fork!") print("Review these changes to see if they fit your personalized fork!")
return 0 return 0
else: else:
print("[OK] All framework files are up to date with upstream!") print(f"[OK] All framework files are up to date with {ref}!")
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":