Skip to content

Commit 0180c12

Browse files
committed
Move auto-sync/commit stuff here from pim repo
1 parent b088355 commit 0180c12

File tree

3 files changed

+391
-0
lines changed

3 files changed

+391
-0
lines changed

bin/auto-commit-daemon

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env zsh
2+
3+
debug=
4+
if [[ "$1" == '-d' ]]; then
5+
debug=-d
6+
shift
7+
fi
8+
9+
if [ $# != 3 ]; then
10+
cat <<EOF >&2
11+
Usage: $(basename $0) [-d] REPO-DIR SLEEP MIN-AGE
12+
EOF
13+
exit 1
14+
fi
15+
16+
repo_dir="$1"
17+
sleep="$2"
18+
min_age="$3"
19+
20+
cd "$repo_dir"
21+
22+
for var in name email; do
23+
if ! git config user.$var >/dev/null; then
24+
echo >&2 "Error: user.$var not set in git config; aborting"
25+
exit 1
26+
fi
27+
done
28+
29+
while true; do
30+
git auto-commit $debug -m "$min_age"
31+
sleep "$sleep"
32+
done

bin/auto-sync-daemon

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env zsh
2+
3+
BRANCH=master # FIXME: parameterise at some point
4+
5+
process_inotify_batch () {
6+
while read dir action file; do
7+
try_sync
8+
9+
# This should not be unnecessary since we are relying on inotifywait
10+
# to only return a single event; the filtering is done by that
11+
# process, not this loop.
12+
break
13+
done
14+
}
15+
16+
try_sync () {
17+
if detect_ssh_agent; then
18+
# Do a sync regardless of whether auto-commit did anything,
19+
# because another remote may have pushed changes to our
20+
# synced/master branch. First give other remotes a chance
21+
# to completely finish their push, just in case of any races.
22+
sleep 5
23+
git-annex-clean-sync
24+
else
25+
echo >&2 "WARNING: can't connect to ssh-agent; skipping annex sync"
26+
fi
27+
}
28+
29+
main () {
30+
if ! inotifywait --help | grep -q -- '--include'; then
31+
echo >&2 "inotifywait doesn't support --include; aborting!"
32+
exit 1
33+
fi
34+
35+
if [ $# != 1 ]; then
36+
cat <<EOF >&2
37+
Usage: $(basename $me) REPO-DIR
38+
EOF
39+
exit 1
40+
fi
41+
42+
repo_dir="$1"
43+
cd "$repo_dir"
44+
45+
if [[ -e HEAD ]] && [[ -e info ]] && [[ -e objects ]] && [[ -e refs ]] &&
46+
[[ -e branches ]]
47+
then
48+
mode=bare
49+
git_dir=.
50+
elif [[ -d .git ]]; then
51+
mode=worktree
52+
git_dir=.git
53+
else
54+
echo >&2 "`pwd` isn't a git repo; aborting!"
55+
exit 1
56+
fi
57+
58+
# Do this at startup to allow easy checking at startup time that
59+
# the agent was detected correct.
60+
detect_ssh_agent
61+
62+
# This was a massive PITA to get right. It seems that if you
63+
# specify specific files then it will look up the inodes on
64+
# start-up and only monitor those. There's some similar weirdness
65+
# with moves too. Suffice to say that it's necessary to use
66+
# --include and watch the whole directory. -r is needed to catch
67+
# the synced/ subdirectory too. For more clues see
68+
# https://unix.stackexchange.com/questions/164794/why-doesnt-inotifywatch-detect-changes-on-added-files
69+
while true; do
70+
inotifywait \
71+
-q -r \
72+
--include "/($BRANCH|synced/($BRANCH|git-annex))\$" \
73+
-e create -e modify -e move -e delete \
74+
"$git_dir/refs/heads" |
75+
process_inotify_batch
76+
done
77+
}
78+
79+
me="$0"
80+
main "$@"

bin/git-auto-commit

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import datetime
5+
import logging
6+
import os.path
7+
import pygit2 # type: ignore
8+
import subprocess
9+
import sys
10+
from textwrap import dedent, wrap
11+
12+
13+
DEFAULT_MIN_AGE = datetime.timedelta(minutes=5).total_seconds()
14+
15+
STATUS_FLAGS = {
16+
pygit2.GIT_STATUS_CURRENT: "CURRENT",
17+
pygit2.GIT_STATUS_INDEX_NEW: "INDEX_NEW",
18+
pygit2.GIT_STATUS_INDEX_MODIFIED: "INDEX_MODIFIED",
19+
pygit2.GIT_STATUS_INDEX_DELETED: "INDEX_DELETED",
20+
pygit2.GIT_STATUS_WT_NEW: "WT_NEW",
21+
pygit2.GIT_STATUS_WT_MODIFIED: "WT_MODIFIED",
22+
pygit2.GIT_STATUS_WT_DELETED: "WT_DELETED",
23+
pygit2.GIT_STATUS_IGNORED: "IGNORED",
24+
pygit2.GIT_STATUS_CONFLICTED: "CONFLICTED",
25+
}
26+
27+
FORMAT = '%(levelname)-7s | %(message)s'
28+
logging.basicConfig(format=FORMAT)
29+
30+
31+
def get_status_output(flags):
32+
if flags & pygit2.GIT_STATUS_IGNORED:
33+
return "!!"
34+
elif flags & pygit2.GIT_STATUS_WT_NEW:
35+
return "??"
36+
elif flags & pygit2.GIT_STATUS_WT_MODIFIED:
37+
return " M"
38+
elif flags & pygit2.GIT_STATUS_WT_DELETED:
39+
return " D"
40+
elif flags & pygit2.GIT_STATUS_INDEX_NEW:
41+
return "A "
42+
elif flags & pygit2.GIT_STATUS_INDEX_MODIFIED:
43+
return "M "
44+
elif flags & pygit2.GIT_STATUS_INDEX_DELETED:
45+
return "D "
46+
elif flags & pygit2.GIT_STATUS_CONFLICTED:
47+
return "UU"
48+
elif flags & pygit2.GIT_STATUS_CURRENT:
49+
return ".."
50+
else:
51+
return " "
52+
53+
54+
def get_status_flags(flags):
55+
return [
56+
descr
57+
for flag, descr in STATUS_FLAGS.items()
58+
if flags & flag
59+
]
60+
61+
62+
def file_is_staged(flags):
63+
return flags & (pygit2.GIT_STATUS_INDEX_NEW
64+
| pygit2.GIT_STATUS_INDEX_MODIFIED
65+
| pygit2.GIT_STATUS_INDEX_DELETED)
66+
67+
68+
def file_is_conflicted(flags):
69+
return flags & pygit2.GIT_STATUS_CONFLICTED
70+
71+
72+
def get_hostname():
73+
nick_file = os.path.expanduser("~/.localhost-nickname")
74+
if os.path.isfile(nick_file):
75+
with open(nick_file) as f:
76+
return f.readline().rstrip("\n")
77+
else:
78+
return os.getenv("HOST") or os.getenv("HOSTNAME")
79+
80+
81+
def quit(msg):
82+
logging.debug(msg)
83+
sys.exit(1)
84+
85+
86+
def abort(msg):
87+
logging.error(msg)
88+
sys.exit(1)
89+
90+
91+
class GitAutoCommitter:
92+
def __init__(self, repo_path, min_age):
93+
self.repo_path = repo_path
94+
self.min_age = datetime.timedelta(seconds=min_age)
95+
logging.debug("GitAutoCommitter with min age %ds on repo %s"
96+
% (int(self.min_age.total_seconds()), self.repo_path))
97+
self.repo = pygit2.Repository(repo_path)
98+
self.check_config()
99+
100+
def check_config(self):
101+
if self.config_get("user.name") is None:
102+
abort("user.name is not set in git config; aborting!")
103+
if self.config_get("user.email") is None:
104+
abort("user.name is not set in git config; aborting!")
105+
106+
def manual_attention_required(self):
107+
found_issues = False
108+
for filepath, flags in self.process_files():
109+
if not filepath.endswith(".org"):
110+
# logging.debug(f"# {filepath} not an .org file")
111+
continue
112+
113+
if self.ignored(filepath):
114+
# logging.debug(f"# {filepath} ignored by git")
115+
continue
116+
117+
st = get_status_output(flags)
118+
if file_is_staged(flags):
119+
logging.debug(f"{st} {filepath}\t\t<-- staged")
120+
found_issues = True
121+
elif file_is_conflicted(flags):
122+
logging.debug(f"{st} {filepath}\t\t<-- conflicted")
123+
found_issues = True
124+
else:
125+
logging.debug(f"{st} {filepath}")
126+
127+
return found_issues
128+
129+
def auto_commit_changes(self):
130+
staged = self.stage_changes()
131+
if staged > 0:
132+
self.commit_changes()
133+
else:
134+
quit("Nothing to commit")
135+
136+
def stage_changes(self):
137+
staged = 0
138+
139+
for filepath, flags in self.process_files():
140+
if not filepath.endswith(".org"):
141+
# logging.debug(f"# {filepath} not an .org file")
142+
continue
143+
144+
if self.ignored(filepath):
145+
logging.debug(f"# {filepath} ignored by git")
146+
continue
147+
148+
# Flags can be found here:
149+
# https://github.com/libgit2/pygit2/blob/320ee5e733039d4a3cc952b287498dbc5737c353/src/pygit2.c#L312-L320
150+
if flags & pygit2.GIT_STATUS_WT_NEW:
151+
self.stage_file(filepath, "new")
152+
staged += 1
153+
elif flags & pygit2.GIT_STATUS_WT_MODIFIED:
154+
commit_age = self.time_since_last_commit(filepath)
155+
file_age = self.time_since_mtime(filepath)
156+
if commit_age < self.min_age:
157+
logging.debug(f"Not staging {filepath}, "
158+
f"last committed {commit_age} ago")
159+
elif file_age < self.min_age:
160+
logging.debug(f"Not staging {filepath}, "
161+
f"last modified {file_age} ago")
162+
else:
163+
self.stage_file(filepath, "changed")
164+
staged += 1
165+
166+
# else:
167+
# fl = " ".join(get_status_flags(flags))
168+
# logging.debug(f"Not staging {filepath} with flags {fl}")
169+
170+
if staged > 0:
171+
self.repo.index.write()
172+
return staged
173+
174+
def config_get(self, name):
175+
try:
176+
return self.repo.config[name]
177+
except KeyError:
178+
return None
179+
180+
def commit_changes(self):
181+
author = pygit2.Signature(
182+
self.repo.config["user.name"],
183+
self.repo.config["user.email"]
184+
)
185+
committer = pygit2.Signature(
186+
self.config_get("auto-commit.name")
187+
or self.config_get("user.name"),
188+
self.config_get("auto-commit.email")
189+
or self.config_get("user.email")
190+
)
191+
tree = self.repo.index.write_tree()
192+
head_oid = self.repo.head.resolve().target
193+
host = get_hostname()
194+
message = f"auto-commit on {host} by {__file__}"
195+
oid = self.repo.create_commit( # noqa
196+
"refs/heads/master", author, committer, message, tree,
197+
[head_oid] # parent commit(s)
198+
)
199+
# commit = self.repo.get(oid)
200+
# logging.debug(f"\n{commit.short_id} {message}")
201+
logging.debug("")
202+
subprocess.call(["git", "show", "--format=fuller", "--name-status"])
203+
204+
def process_files(self):
205+
for filepath, flags in self.repo.status().items():
206+
yield filepath, flags
207+
208+
def ignored(self, filepath):
209+
dirname, filename = os.path.split(filepath)
210+
if filename.startswith(".#"):
211+
# emacs lock file
212+
return True
213+
214+
return False
215+
216+
def time_since_mtime(self, filepath):
217+
now = datetime.datetime.now()
218+
last_change = datetime.datetime.fromtimestamp(
219+
os.stat(filepath).st_mtime)
220+
return now - last_change
221+
222+
def time_since_last_commit(self, filepath):
223+
descr = subprocess.check_output(
224+
["git", "describe", "--always", f"HEAD:{filepath}"],
225+
encoding='utf-8')
226+
ref, _path = descr.split(":", 1)
227+
rev = self.repo.revparse_single(ref)
228+
now = datetime.datetime.now()
229+
last_change = datetime.datetime.fromtimestamp(rev.commit_time)
230+
return now - last_change
231+
232+
def stage_file(self, filepath, reason):
233+
logging.debug(f"%-20s {filepath}" % f"Staged {reason} file:")
234+
self.repo.index.add(filepath)
235+
236+
237+
def parse_args():
238+
descr = "\n".join(wrap(dedent("""\
239+
Automatically commit .org files last modified or committed
240+
before a certain age. Doesn't do anything if any change
241+
is already staged in git's index.
242+
"""), width=int(os.getenv("COLUMNS", "70"))))
243+
244+
parser = argparse.ArgumentParser(
245+
description=descr,
246+
formatter_class=argparse.RawDescriptionHelpFormatter,
247+
)
248+
parser.add_argument(
249+
"-d", "--debug", action="store_true",
250+
help="Enable debug output"
251+
)
252+
parser.add_argument(
253+
"-m", "--min-age", metavar="SECS", type=int, default=DEFAULT_MIN_AGE,
254+
help="Minimum number of seconds since a file needs to have been "
255+
"last updated in order for it to get committed."
256+
)
257+
parser.add_argument(
258+
"repo_path", metavar="REPO-PATH", nargs="?",
259+
default=".", help="Path to repository to auto-commit"
260+
)
261+
262+
return parser.parse_known_args()
263+
264+
265+
def main():
266+
options, args = parse_args()
267+
if options.debug:
268+
logging.getLogger().setLevel(logging.DEBUG)
269+
270+
gac = GitAutoCommitter(options.repo_path, options.min_age)
271+
272+
if gac.manual_attention_required():
273+
abort("Manual attention required; aborting.")
274+
275+
logging.debug("")
276+
gac.auto_commit_changes()
277+
278+
279+
main()

0 commit comments

Comments
 (0)