Skip to content

Commit 8cd9144

Browse files
authored
fix(operations.git): don't pull when already up to date (#1690)
1 parent 8641fea commit 8cd9144

19 files changed

Lines changed: 307 additions & 24 deletions

src/pyinfra/facts/git.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,49 @@ def process(self, output):
6969
if m:
7070
return m.group(1)
7171
return None
72+
73+
74+
_SHA_RE = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
75+
76+
77+
class GitLocalCommit(GitFactBase):
78+
"""
79+
Returns the SHA of ``ref`` (defaults to ``HEAD``) in a local git repository,
80+
or ``None`` when the repository does not exist, the ref is unknown, or the
81+
command fails.
82+
"""
83+
84+
@override
85+
def command(self, repo: str, ref: str = "HEAD") -> str:
86+
return "! test -d {0} || (cd {0} && git rev-parse {1} 2>/dev/null)".format(repo, ref)
87+
88+
@override
89+
def process(self, output: list[str]):
90+
if not output:
91+
return None
92+
line = output[0].strip()
93+
return line if _SHA_RE.fullmatch(line) else None
94+
95+
96+
class GitRemoteBranchCommit(GitFactBase):
97+
"""
98+
Returns the SHA of the tip of ``branch`` on ``remote`` as reported by
99+
``git ls-remote``. Returns ``None`` when the remote is unreachable, the
100+
branch does not exist on the remote, or the repository is missing.
101+
"""
102+
103+
@override
104+
def command(self, repo: str, remote: str = "origin", branch: str | None = None) -> str:
105+
ref = branch if branch else "HEAD"
106+
return ("! test -d {0} || (cd {0} && git ls-remote {1} {2} 2>/dev/null | head -n1)").format(
107+
repo, remote, ref
108+
)
109+
110+
@override
111+
def process(self, output: list[str]):
112+
if not output:
113+
return None
114+
parts = output[0].strip().split("\t", 1)
115+
if parts and _SHA_RE.fullmatch(parts[0]):
116+
return parts[0]
117+
return None

src/pyinfra/operations/git.py

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
from pyinfra import host
1010
from pyinfra.api import OperationError, QuoteString, StringCommand, operation
1111
from pyinfra.facts.files import Directory, File
12-
from pyinfra.facts.git import GitBranch, GitConfig, GitTag, GitTrackingBranch
12+
from pyinfra.facts.git import (
13+
GitBranch,
14+
GitConfig,
15+
GitLocalCommit,
16+
GitRemoteBranchCommit,
17+
GitTag,
18+
GitTrackingBranch,
19+
)
1320

1421
from . import files, ssh
1522
from .util.files import chown, unix_path_join
@@ -154,14 +161,35 @@ def repo(
154161
# Ensuring existing repo
155162
else:
156163
is_tag = False
157-
if branch and host.get_fact(GitBranch, repo=dest) != branch:
164+
current_branch = host.get_fact(GitBranch, repo=dest)
165+
if branch is not None and current_branch != branch:
158166
git_commands.append("fetch") # fetch to ensure we have the branch locally
159167
git_commands.append(StringCommand("checkout", QuoteString(branch)))
160168
if branch and branch in (host.get_fact(GitTag, repo=dest) or []):
161169
git_commands.append(StringCommand("checkout", QuoteString(branch)))
162170
is_tag = True
163171
if pull and not is_tag:
164-
if rebase:
172+
skip_pull = False
173+
# Skip `git pull` when the local branch tip already matches the
174+
# remote tip, so pyinfra reports the operation unchanged rather
175+
# than always "Success". This still applies when we switch branch:
176+
# if the target branch already exists locally at the remote tip,
177+
# the fetch+checkout leaves nothing for pull to do.
178+
effective_branch = branch or current_branch
179+
if effective_branch:
180+
local_commit = host.get_fact(GitLocalCommit, repo=dest, ref=effective_branch)
181+
remote_commit = host.get_fact(
182+
GitRemoteBranchCommit,
183+
repo=dest,
184+
branch=effective_branch,
185+
)
186+
if local_commit and remote_commit and local_commit == remote_commit:
187+
skip_pull = True
188+
if skip_pull:
189+
host.noop(
190+
"git repository {0} is already up to date".format(dest),
191+
)
192+
elif rebase:
165193
git_commands.append("pull --rebase")
166194
else:
167195
git_commands.append("pull")
@@ -389,31 +417,62 @@ def worktree(
389417
# pull the worktree only if it's already linked to a tracking branch or
390418
# if a remote branch is set
391419
elif host.get_fact(GitTrackingBranch, repo=worktree) or from_remote_branch:
392-
pull_args: list[str | QuoteString] = [
393-
"cd",
394-
QuoteString(worktree),
395-
"&&",
396-
"git",
397-
"pull",
398-
]
399-
400-
if rebase:
401-
pull_args.append("--rebase")
420+
if from_remote_branch and (
421+
len(from_remote_branch) != 2 or type(from_remote_branch) not in (tuple, list)
422+
):
423+
raise OperationError(
424+
"The remote branch must be a 2-tuple (remote, branch) such as "
425+
'("origin", "master")',
426+
)
402427

428+
# Determine the remote ref we would pull from, so we can short-circuit
429+
# the pull when the worktree HEAD already matches the remote tip.
430+
remote_name: str | None = None
431+
remote_branch: str | None = None
403432
if from_remote_branch:
404-
if len(from_remote_branch) != 2 or type(from_remote_branch) not in (tuple, list):
405-
raise OperationError(
406-
"The remote branch must be a 2-tuple (remote, branch) such as "
407-
'("origin", "master")',
408-
)
409-
pull_args.extend(
410-
[
411-
QuoteString(from_remote_branch[0]),
412-
QuoteString(from_remote_branch[1]),
413-
]
433+
remote_name, remote_branch = from_remote_branch[0], from_remote_branch[1]
434+
else:
435+
tracking = host.get_fact(GitTrackingBranch, repo=worktree)
436+
if tracking and "/" in tracking:
437+
remote_name, remote_branch = tracking.split("/", 1)
438+
439+
skip_pull = False
440+
if remote_name and remote_branch:
441+
local_commit = host.get_fact(GitLocalCommit, repo=worktree)
442+
remote_commit = host.get_fact(
443+
GitRemoteBranchCommit,
444+
repo=worktree,
445+
remote=remote_name,
446+
branch=remote_branch,
447+
)
448+
if local_commit and remote_commit and local_commit == remote_commit:
449+
skip_pull = True
450+
451+
if skip_pull:
452+
host.noop(
453+
"git worktree {0} is already up to date".format(worktree),
414454
)
455+
else:
456+
pull_args: list[str | QuoteString] = [
457+
"cd",
458+
QuoteString(worktree),
459+
"&&",
460+
"git",
461+
"pull",
462+
]
463+
464+
if rebase:
465+
pull_args.append("--rebase")
466+
467+
if from_remote_branch:
468+
pull_args.extend(
469+
[
470+
QuoteString(from_remote_branch[0]),
471+
QuoteString(from_remote_branch[1]),
472+
]
473+
)
415474

416-
yield StringCommand(*pull_args)
475+
yield StringCommand(*pull_args)
417476

418477

419478
@operation()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"arg": {
3+
"repo": "myrepo",
4+
"ref": "mybranch"
5+
},
6+
"command": "! test -d myrepo || (cd myrepo && git rev-parse mybranch 2>/dev/null)",
7+
"requires_command": "git",
8+
"output": [
9+
"1a2b3c4d5e6f7890abcdef1234567890abcdef12"
10+
],
11+
"fact": "1a2b3c4d5e6f7890abcdef1234567890abcdef12"
12+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"arg": "myrepo",
3+
"command": "! test -d myrepo || (cd myrepo && git rev-parse HEAD 2>/dev/null)",
4+
"requires_command": "git",
5+
"output": [
6+
"1a2b3c4d5e6f7890abcdef1234567890abcdef12"
7+
],
8+
"fact": "1a2b3c4d5e6f7890abcdef1234567890abcdef12"
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"arg": "myrepo",
3+
"command": "! test -d myrepo || (cd myrepo && git rev-parse HEAD 2>/dev/null)",
4+
"requires_command": "git",
5+
"output": [],
6+
"fact": null
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"arg": {
3+
"repo": "myrepo",
4+
"branch": "main"
5+
},
6+
"command": "! test -d myrepo || (cd myrepo && git ls-remote origin main 2>/dev/null | head -n1)",
7+
"requires_command": "git",
8+
"output": [
9+
"1a2b3c4d5e6f7890abcdef1234567890abcdef12\trefs/heads/main"
10+
],
11+
"fact": "1a2b3c4d5e6f7890abcdef1234567890abcdef12"
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"arg": {
3+
"repo": "myrepo",
4+
"branch": "main"
5+
},
6+
"command": "! test -d myrepo || (cd myrepo && git ls-remote origin main 2>/dev/null | head -n1)",
7+
"requires_command": "git",
8+
"output": [],
9+
"fact": null
10+
}

tests/operations/git.repo/branch_pull.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
"git.GitTag": {
1717
"repo=/home/myrepo": [
1818
]
19+
},
20+
"git.GitLocalCommit": {
21+
"ref=mybranch, repo=/home/myrepo": null
22+
},
23+
"git.GitRemoteBranchCommit": {
24+
"branch=mybranch, remote=origin, repo=/home/myrepo": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1925
}
2026
},
2127
"commands": [
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"args": ["myrepo", "/home/myrepo"],
3+
"kwargs": {
4+
"branch": "mybranch"
5+
},
6+
"facts": {
7+
"files.Directory": {
8+
"path=/home/myrepo": {},
9+
"path=/home/myrepo/.git": {
10+
"mode": 0
11+
}
12+
},
13+
"git.GitBranch": {
14+
"repo=/home/myrepo": "master"
15+
},
16+
"git.GitTag": {
17+
"repo=/home/myrepo": [
18+
]
19+
},
20+
"git.GitLocalCommit": {
21+
"ref=mybranch, repo=/home/myrepo": "1a2b3c4d5e6f7890abcdef1234567890abcdef12"
22+
},
23+
"git.GitRemoteBranchCommit": {
24+
"branch=mybranch, remote=origin, repo=/home/myrepo": "1a2b3c4d5e6f7890abcdef1234567890abcdef12"
25+
}
26+
},
27+
"commands": [
28+
"cd /home/myrepo && git fetch",
29+
"cd /home/myrepo && git checkout mybranch"
30+
]
31+
}

tests/operations/git.repo/rebase.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
},
1313
"git.GitBranch": {
1414
"repo=/home/myrepo": "master"
15+
},
16+
"git.GitLocalCommit": {
17+
"ref=master, repo=/home/myrepo": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
18+
},
19+
"git.GitRemoteBranchCommit": {
20+
"branch=master, remote=origin, repo=/home/myrepo": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1521
}
1622
},
1723
"commands": [

0 commit comments

Comments
 (0)