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
207
208
209
210
211
212
213
214
215
216
|
"""Tests for mark_volatile_configs, the tail of the dotfiles install.
Four stowed configs get rewritten in place by the apps that own them (btop,
qalculate, calibre, waypaper). The dotfiles repo handles that with git's
skip-worktree bit, applied by its own `skip-volatile` make target, which
`make stow` runs as its last step.
archsetup stows inline rather than through that Makefile, and it never ran the
step -- so a machine installed by archsetup alone starts with a dotfiles repo
that goes dirty as soon as those apps run, and every later `git pull --ff-only`
trips over paths the user never edited. Both daily drivers carry the bits
today, but archsetup contains no code that sets them: they were applied by a
hand-run `make stow`.
The fix calls the dotfiles Makefile's target rather than reimplementing the
logic here, so the volatile list stays in one place. That is what these tests
pin: that archsetup delegates, and that it degrades quietly when the checkout
it was handed cannot answer.
These tests exercise the REAL function body, extracted from the `archsetup`
script at run time (not a copy), against a fixture git repo, with sudo stubbed
on PATH.
Run from repo root:
python3 -m unittest tests.installer-steps.test_mark_volatile_configs
"""
import os
import shutil
import subprocess
import tempfile
import textwrap
import unittest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
# The real target, copied from the dotfiles Makefile. The point of the fixture
# is that archsetup calls *a* skip-volatile target rather than doing the work
# itself; the body here mirrors the real one so the assertions are meaningful.
MAKEFILE = textwrap.dedent("""\
DOTFILES := $(shell pwd)
VOLATILE := $(shell sed 's/#.*//' $(DOTFILES)/volatile-configs 2>/dev/null)
skip-volatile:
\t@for f in $(VOLATILE); do \\
\t\tif git -C $(DOTFILES) ls-files --error-unmatch "$$f" >/dev/null 2>&1; then \\
\t\t\tgit -C $(DOTFILES) update-index --skip-worktree "$$f" && echo " skip-worktree: $$f"; \\
\t\telse \\
\t\t\techo " WARN: not tracked, skipping: $$f"; \\
\t\tfi; \\
\tdone
""")
# sudo isn't available (and isn't wanted) in a unit test. Drop `-u <user>` and
# exec the rest, so the function's real command line is what runs.
SUDO_STUB = textwrap.dedent("""\
#!/bin/bash
# Record the argv archsetup handed sudo, then drop `-u <user>` and exec the
# rest, so the function's real command line is what runs.
printf '%s\\n' "$*" >> "$SUDO_CALLS"
if [ "$1" = "-u" ]; then shift 2; fi
exec "$@"
""")
class MarkVolatileConfigsHarness(unittest.TestCase):
"""Source mark_volatile_configs out of the real archsetup script."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="volatile-test-")
self.repo = os.path.join(self.tmp, "dotfiles")
os.mkdir(self.repo)
self.bindir = os.path.join(self.tmp, "bin")
os.mkdir(self.bindir)
sudo = os.path.join(self.bindir, "sudo")
with open(sudo, "w") as f:
f.write(SUDO_STUB)
os.chmod(sudo, 0o755)
self.logfile = os.path.join(self.tmp, "install.log")
self.sudo_calls = os.path.join(self.tmp, "sudo-calls")
self.wrapper = os.path.join(self.tmp, "run.sh")
with open(self.wrapper, "w") as f:
f.write(
"#!/bin/bash\n"
'ARCHSETUP="$1"; shift\n'
"source <(sed -n "
"'/^mark_volatile_configs() {/,/^}/p' \"$ARCHSETUP\")\n"
'display() { echo "DISPLAY:$2"; }\n'
'error_warn() { echo "WARN:$1"; return 1; }\n'
'mark_volatile_configs "$REPO" testuser\n'
'echo "RETURNED=$?"\n'
)
os.chmod(self.wrapper, 0o755)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def git(self, *args):
return subprocess.run(["git", "-C", self.repo, *args],
capture_output=True, text=True)
def make_repo(self, tracked=("common/.config/btop/btop.conf",),
volatile=None, makefile: "str | None" = MAKEFILE):
self.git("init", "-q")
self.git("config", "user.email", "t@example.com")
self.git("config", "user.name", "t")
for rel in tracked:
path = os.path.join(self.repo, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("original\n")
listed = tracked if volatile is None else volatile
with open(os.path.join(self.repo, "volatile-configs"), "w") as f:
f.write("# apps rewrite these in place\n")
f.write("".join(rel + "\n" for rel in listed))
if makefile is not None:
with open(os.path.join(self.repo, "Makefile"), "w") as f:
f.write(makefile)
self.git("add", "-A")
self.git("commit", "-q", "-m", "fixture")
def run_step(self):
env = dict(os.environ)
env["PATH"] = self.bindir + os.pathsep + env["PATH"]
env["REPO"] = self.repo
env["logfile"] = self.logfile
env["SUDO_CALLS"] = self.sudo_calls
return subprocess.run(["bash", self.wrapper, ARCHSETUP],
capture_output=True, text=True, env=env)
def sudo_argv(self):
"""The argument lines sudo was called with, one per invocation."""
if not os.path.exists(self.sudo_calls):
return []
with open(self.sudo_calls) as f:
return [line.rstrip("\n") for line in f if line.strip()]
def skipped(self):
"""Paths carrying the skip-worktree bit."""
r = self.git("ls-files", "-v")
return sorted(line[2:] for line in r.stdout.splitlines()
if line.startswith("S "))
# ---------------------------------------------------------- normal ----
def test_sets_skip_worktree_on_every_volatile_path(self):
paths = ["common/.config/btop/btop.conf",
"hyprland/.config/waypaper/config.ini"]
self.make_repo(tracked=paths)
self.assertEqual(self.skipped(), []) # the bit is not set by default
r = self.run_step()
self.assertIn("RETURNED=0", r.stdout,
"stdout=%r stderr=%r" % (r.stdout, r.stderr))
self.assertEqual(self.skipped(), sorted(paths))
def test_runs_make_as_the_user_not_as_root(self):
# archsetup runs as root against a user-owned checkout. Letting root
# write .git/index leaves it root-owned, and the user's next git
# command then cannot update the index at all.
self.make_repo()
self.run_step()
self.assertEqual(self.sudo_argv(),
["-u testuser make -C %s skip-volatile" % self.repo])
def test_leaves_non_volatile_tracked_files_alone(self):
self.make_repo(tracked=["common/.config/btop/btop.conf",
"common/.bashrc"],
volatile=["common/.config/btop/btop.conf"])
self.run_step()
self.assertEqual(self.skipped(), ["common/.config/btop/btop.conf"])
# -------------------------------------------------------- boundary ----
def test_no_makefile_is_a_quiet_no_op(self):
# DOTFILES_DIR can point at a checkout that predates the target, or at
# a fork. Nothing to delegate to is not an error.
self.make_repo(makefile=None)
r = self.run_step()
self.assertIn("RETURNED=0", r.stdout)
self.assertNotIn("WARN:", r.stdout)
self.assertEqual(self.skipped(), [])
def test_empty_volatile_list_sets_nothing_and_does_not_warn(self):
self.make_repo(tracked=["common/.config/btop/btop.conf"], volatile=[])
r = self.run_step()
self.assertIn("RETURNED=0", r.stdout)
self.assertNotIn("WARN:", r.stdout)
self.assertEqual(self.skipped(), [])
def test_untracked_path_in_the_list_does_not_fail_the_step(self):
# The Makefile warns and carries on; a stale entry must not take the
# install down, and the tracked entries still get their bit.
self.make_repo(tracked=["common/.config/btop/btop.conf"],
volatile=["common/.config/btop/btop.conf",
"common/.config/gone/removed.conf"])
r = self.run_step()
self.assertIn("RETURNED=0", r.stdout)
self.assertEqual(self.skipped(), ["common/.config/btop/btop.conf"])
# ----------------------------------------------------------- error ----
def test_makefile_without_the_target_warns_and_returns(self):
self.make_repo(makefile="all:\n\t@true\n")
r = self.run_step()
self.assertIn("WARN:", r.stdout)
self.assertEqual(self.skipped(), [])
def test_make_output_goes_to_the_logfile_not_the_console(self):
self.make_repo(tracked=["common/.config/btop/btop.conf"])
r = self.run_step()
self.assertNotIn("skip-worktree:", r.stdout)
with open(self.logfile) as f:
self.assertIn("skip-worktree:", f.read())
if __name__ == "__main__":
unittest.main()
|