Repository navigation
Expand file tree
/
Copy pathpost-commit
More file actions
executable file
·206 lines (179 loc) · 8.33 KB
/
Copy pathpost-commit
File metadata and controls
executable file
·206 lines (179 loc) · 8.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
#!/usr/bin/env python3
import pygit2
from pathlib import Path
import sys
import hashlib
# This hook is DELIBERATELY SELF-CONTAINED: pygit2 and the standard library,
# nothing from the rest of the extension. Git copies hooks into .git/hooks/,
# where an import of a sibling module resolves against the wrong directory and
# fails at commit time. Being standalone means `cp post-commit .git/hooks/`
# just works, with no shim and no sys.path games.
#
# The two constants below are repeated from the clean filter rather than
# imported. They are format constants, and a format change touches all three
# components anyway; a shared module would only trade that for an import that
# breaks when the hook is copied.
POINTER_MAGIC = b"DOCX-POINTER"
REF_NAMESPACE = "refs/docx"
# A pointer is a few hundred bytes to a few kilobytes. The cap keeps this hook
# from reading a 30 MB asset just to decide it is not one of ours.
MAX_POINTER_BYTES = 1 << 20
def ref_for(path):
"""
The ref holding this document's parts: refs/docx/<sha256 of the path>.
Hashed, not encoded. Git ref names cannot hold arbitrary paths - space,
~ ^ : ? * [ \\ and controls are all legal in a filename and illegal in a
ref, and refs further reject a leading dot, a trailing dot, a component
ending .lock, and `..` anywhere. Encoding around that is a module of string
rules; base64 or base32 of the path is legal but collapses the path into
one name component, which a filesystem caps at 255 bytes (base32 breaks
past ~155 characters of path, base64url past ~190) and fails as a refused
update-ref at commit time.
A hash is fixed length, always legal, and one line. Nothing needs to read
the name back out: the smudge filter resolves the parts tree from the
pointer's TREE line and never consults a ref. The cost is that
`git for-each-ref refs/docx` is opaque - to find a document's ref you hash
its path rather than read the listing.
sha256 because the pointer already uses it for HASH and the entry table:
one hash primitive in the codebase.
Renaming a document produces a new ref; the old one keeps pointing at the
old chain, so earlier versions stay reachable and the stale ref lingers.
"""
if not path:
raise ValueError("empty path has no ref name")
digest = hashlib.sha256(path.encode("utf-8", "surrogateescape")).hexdigest()
return "%s/%s" % (REF_NAMESPACE, digest)
def get_modified_docx_files(commit):
"""
Return a list of modified docx files for current commit.
"""
parents = commit.parents
if not parents:
diff = commit.tree.diff_to_tree(swap=True)
else:
diff = parents[0].tree.diff_to_tree(commit.tree)
modified = []
for patch in diff:
if patch.delta.status_char() in ('A', 'M'):
modified.append(patch.delta.new_file.path)
return modified
def is_pointer(repo, commit, path):
"""
True when the blob committed at `path` is one of our pointers.
Asking the data beats keeping a list of extensions in step with
.gitattributes by hand. A path routed through the filter but missing from
such a list is cleaned into a pointer whose ref is never created, and the
document becomes impossible to check out - which is exactly what happened
the first time .odt was enabled. This way .gitattributes is the only place
a format is turned on.
"""
try:
blob = repo[commit.tree[path].id]
except (KeyError, TypeError):
return False
if getattr(blob, 'is_binary', False) or blob.size > MAX_POINTER_BYTES:
return False
return blob.data[:len(POINTER_MAGIC)] == POINTER_MAGIC
def read_pointer_file_from_commit(repo, commit, path):
try:
entry = commit.tree[path]
blob = repo[entry.id]
return blob.data.decode("utf-8")
except (KeyError, UnicodeDecodeError) as e:
print(f"Error reading pointer file {path}: {e}", file=sys.stderr)
return None
def parse_tree_from_pointer(pointer_text):
"""
Read the parts-tree id the clean filter recorded in the pointer. Older
pointers have no TREE line; those fall back to the legacy side file.
"""
for line in pointer_text.splitlines():
if line.startswith("TREE:"):
return line[len("TREE:"):].strip()
return None
def parse_ref_from_pointer(pointer_text):
"""
Deprecated. Older pointers carried the ref name; current ones derive it from
the document's path instead, so there is only one source for that fact.
"""
for line in pointer_text.splitlines():
if line.startswith("DOCX-POINTER:"):
return line[len("DOCX-POINTER:"):].strip()
return None
def main():
repo = pygit2.Repository(pygit2.discover_repository(str(Path.cwd())))
commit = repo.head.peel()
# Check if there are any modified or added docx files:
modified_files = get_modified_docx_files(commit)
if not modified_files:
print("No documents added or modified in last commit.", file=sys.stderr)
return
for path in modified_files:
if not is_pointer(repo, commit, path):
continue
print(f"Processing {path}...", file=sys.stderr)
# Read pointer file:
pointer_text = read_pointer_file_from_commit(repo, commit, path)
if not pointer_text:
continue
# The ref name is the hash of the document's own path. It is derived
# here rather than read from the pointer so there is a single source
# for it, and hashed so that no filename - a space, a leading dot, a
# .lock component, 300 characters of nested directories - can produce
# a ref git refuses. See ref_for above.
refname = ref_for(path)
# Find git tree that contains xmls:
try:
tree_oid = parse_tree_from_pointer(pointer_text)
if tree_oid is None:
# legacy pointer written before TREE existed
legacy = Path(repo.path) / "docx-tree-oid"
print(f"{path}: pointer has no TREE line, falling back to {legacy}",
file=sys.stderr)
tree_oid = legacy.read_text(encoding="utf-8").strip()
obj = repo[tree_oid]
if obj.type_str == 'tree':
tree = obj
# TODO: test without elif block
elif obj.type_str == 'commit':
tree = obj.tree
else:
raise TypeError(f"Ref {refname} does not point to a tree or commit.")
except Exception as e:
print(f"Error resolving tree from ref {refname}, object type = {obj.type}, tree = {pygit2.Tree.type}, commit = {pygit2.Commit.type}: {e}", file=sys.stderr)
continue
# Chain onto this document's previous version, never onto the branch
# head. A commit parented on the branch becomes unreachable the moment
# this ref moves on, and its parts tree is collected at the next gc -
# which is why checking out an older revision used to hand back the
# newest document instead of the committed one.
#
# The chain is an append-only log for reachability, not a mirror of
# branch topology: a commit made on a side branch still chains onto
# whatever this ref held at the time.
parents = [repo.references[refname].target] if refname in repo.references else []
# Create commit (commit-tree functionallity):
author = pygit2.Signature("user", "user@mail.com")
commit_msg = f"Auto-commit for {path} tree"
try:
commit_oid = repo.create_commit(
None,
author,
author,
commit_msg,
tree.id,
parents
)
print(f"Created commit {commit_oid} for ref {refname}", file=sys.stderr)
except Exception as e:
print(f"Error creating commit for ref {refname}: {e}", file=sys.stderr)
if commit_oid is None:
print("commit oid is none")
obj = repo[commit_oid]
print(f"Created object: {commit_oid}, type = {obj.type}, class = {type(obj)}", file=sys.stderr)
# Create custom reference pointing to the commit oid:
if refname in repo.references:
repo.references.delete(refname)
repo.references.create(refname, commit_oid, force=True)
if __name__ == "__main__":
main()