Compare commits
639 Commits
v0.5.2
...
bbbacb0eb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbbacb0eb6 | ||
|
|
28820748f7 | ||
|
|
1f93a3d532 | ||
|
|
3104feb738 | ||
|
|
e1a58b6607 | ||
|
|
411c99532d | ||
|
|
607f43eaa0 | ||
|
|
0691c54965 | ||
|
|
74640ddaa8 | ||
|
|
354f3599b4 | ||
|
|
ae86431d50 | ||
|
|
9c7f6fbdf4 | ||
|
|
cf183d9bb0 | ||
|
|
65d0a95968 | ||
|
|
7fbbb442a0 | ||
|
|
a316723cfc | ||
|
|
7405044fb5 | ||
|
|
4fc9401741 | ||
|
|
c793925828 | ||
|
|
bc0a6747b8 | ||
|
|
d642870a66 | ||
|
|
038b109e7b | ||
|
|
519c6703d2 | ||
|
|
8a3fde8e07 | ||
|
|
3fa377efe2 | ||
|
|
02eebb02fe | ||
|
|
dd1d802605 | ||
|
|
a65f62ea9d | ||
|
|
014b330931 | ||
|
|
5b37057b61 | ||
|
|
4b54a45a76 | ||
|
|
eb63da9354 | ||
|
|
494eea998d | ||
|
|
15f05a1999 | ||
|
|
a01a2171ff | ||
|
|
f134361b6e | ||
|
|
ecda12fc49 | ||
|
|
be14cfdc29 | ||
|
|
302acbe9bb | ||
|
|
3cc90ff54e | ||
|
|
c36b738240 | ||
|
|
2cf5851231 | ||
|
|
4cfda23187 | ||
|
|
fb4b3f7ed1 | ||
|
|
a92ab1a29c | ||
|
|
0d3c9b5214 | ||
|
|
106e45d16b | ||
|
|
332de3a3f6 | ||
|
|
19c9e5485b | ||
|
|
fc9a30fed1 | ||
|
|
b235c6ca45 | ||
|
|
5ecbad8f27 | ||
|
|
ef554dde2d | ||
|
|
55867df599 | ||
|
|
cd92a961bd | ||
|
|
7bc7ee4746 | ||
|
|
8a6a21811a | ||
|
|
001983b76e | ||
|
|
ad5196420e | ||
|
|
692e205a63 | ||
|
|
ca3215f2c4 | ||
|
|
0112d9a0a6 | ||
|
|
e68575f15a | ||
|
|
ce1d253814 | ||
|
|
8f51cf368a | ||
|
|
d73049baa4 | ||
|
|
abfd6b817b | ||
|
|
bbe41a6d72 | ||
|
|
310979d799 | ||
|
|
f7af294d30 | ||
|
|
1abf298c47 | ||
|
|
28ba4aab70 | ||
|
|
665a488c3b | ||
|
|
3dc7924de5 | ||
|
|
bbc64eb756 | ||
|
|
ad8ec1bd6c | ||
|
|
7f8a5d24e3 | ||
|
|
5307e4d35f | ||
|
|
b070267bde | ||
|
|
cd2258e267 | ||
|
|
c887a573e0 | ||
|
|
9e6f98948e | ||
|
|
edd1c4357a | ||
|
|
2fc3f3d006 | ||
|
|
58f70860ee | ||
|
|
79d4577083 | ||
|
|
59caa62ac6 | ||
|
|
043b859a42 | ||
|
|
c36cae2e33 | ||
|
|
5fe37a7f12 | ||
|
|
7f46ab43ac | ||
|
|
82dde43f24 | ||
|
|
88b07ea934 | ||
|
|
4901f7b664 | ||
|
|
f186286a7e | ||
|
|
d8eaf4d058 | ||
|
|
eeeb9f7d8e | ||
|
|
f0dc55159b | ||
|
|
8a451cbaee | ||
|
|
e60000680b | ||
|
|
4a2f0ff0b8 | ||
|
|
4fb028cd81 | ||
|
|
4a422bd241 | ||
|
|
6719fb170b | ||
|
|
f3b1a7707a | ||
|
|
a381746cef | ||
|
|
75f636f9ba | ||
|
|
1e5de20a41 | ||
|
|
07d73660eb | ||
|
|
029175cb55 | ||
|
|
38f649e99b | ||
|
|
af491aa267 | ||
|
|
0dd38e5267 | ||
|
|
93918f3a39 | ||
|
|
3b8dadbd29 | ||
|
|
f9c853a4e9 | ||
|
|
799c8d167d | ||
|
|
feb8ca3434 | ||
|
|
dbb6c82562 | ||
|
|
c3f809a586 | ||
|
|
8fc831dfdf | ||
|
|
7e32cd83c5 | ||
|
|
3d71433630 | ||
|
|
c6c382afce | ||
|
|
665401f2bd | ||
|
|
d084abd636 | ||
|
|
a6034ce470 | ||
|
|
484db0781b | ||
|
|
4a8ef7e1f6 | ||
|
|
b3f66e9e2e | ||
|
|
9d6dacb0f8 | ||
|
|
195b936de6 | ||
|
|
f6b5186f31 | ||
|
|
ea16ba8430 | ||
|
|
96ce572792 | ||
|
|
d638f201fe | ||
|
|
e09bdd78c2 | ||
|
|
0034a6f4e2 | ||
|
|
aa66695665 | ||
|
|
a7e4c501e4 | ||
|
|
0dfacc31d4 | ||
|
|
1d26594010 | ||
|
|
c10ff251d5 | ||
|
|
8d34674415 | ||
|
|
8174c6a983 | ||
|
|
1d4c088edc | ||
|
|
040bf31b56 | ||
|
|
4355e7fd9d | ||
|
|
b7c353553a | ||
|
|
2f45d28acb | ||
|
|
904c1be192 | ||
|
|
f443e96f9e | ||
|
|
0076588e1f | ||
|
|
919f033c8b | ||
|
|
dbc77a1b34 | ||
|
|
d02d118b28 | ||
|
|
a952c4f6bf | ||
|
|
e733dff818 | ||
|
|
41584dec6a | ||
|
|
005209703e | ||
|
|
ee4388b0f4 | ||
|
|
a44a514007 | ||
|
|
9431cb78af | ||
|
|
46c52769a6 | ||
|
|
fdb57cd846 | ||
|
|
ff387280d5 | ||
|
|
f09c62d922 | ||
|
|
dd7d52a25d | ||
|
|
f1630ece79 | ||
|
|
11ee8cdc0d | ||
|
|
ceb08cbe22 | ||
|
|
057e1cfc59 | ||
|
|
89f3000d8b | ||
|
|
36e3cb6bbf | ||
|
|
9ebae75e7d | ||
|
|
35e1909790 | ||
|
|
3b633ed326 | ||
|
|
fdf2da84dd | ||
|
|
e561566b46 | ||
|
|
dcef34c17d | ||
|
|
72d11c4a47 | ||
|
|
c2457bae9f | ||
|
|
001bd32bb3 | ||
|
|
7080321081 | ||
|
|
9d9cf66de6 | ||
|
|
9e9a940825 | ||
|
|
257e974c38 | ||
|
|
615e31428c | ||
|
|
8c2a1d0f84 | ||
|
|
62c934774b | ||
|
|
d3d6974b7b | ||
|
|
474d69da0b | ||
|
|
db6a513d1d | ||
|
|
ae343c4cab | ||
|
|
a2b365fb6f | ||
|
|
4cb2006f41 | ||
|
|
66347d307f | ||
|
|
8f92a1b4f0 | ||
|
|
7a2df591c0 | ||
|
|
4923265dea | ||
|
|
79421580e9 | ||
|
|
cabde9e5f1 | ||
|
|
0d60ae9d1a | ||
|
|
d5317b8e17 | ||
|
|
3b8a5b4be4 | ||
|
|
6590a1eeff | ||
|
|
693ae5f05e | ||
|
|
da3002f199 | ||
|
|
feaaab2fa4 | ||
|
|
59f75711a4 | ||
|
|
f24030b51f | ||
|
|
71bb120a12 | ||
|
|
85f46e01b1 | ||
|
|
c5b24b9b38 | ||
|
|
68460af45e | ||
|
|
5614b6b8b3 | ||
|
|
570b063632 | ||
|
|
1d398587d0 | ||
|
|
085853faaa | ||
|
|
21b4e344a9 | ||
|
|
a6194dfe8b | ||
|
|
5692194fa2 | ||
|
|
11745098c2 | ||
|
|
b1bb0c996c | ||
|
|
a62039da50 | ||
|
|
4bfd1c60c2 | ||
|
|
f0e11abb5b | ||
|
|
ed397bdaf8 | ||
|
|
2f5e20d963 | ||
|
|
cc83a4e1fa | ||
|
|
dcc5cb0bc1 | ||
|
|
978416d1e4 | ||
|
|
1c12c73e4b | ||
|
|
1016002638 | ||
|
|
6431a864ad | ||
|
|
42f661cfbf | ||
|
|
7632591681 | ||
|
|
a9a5b81dc5 | ||
|
|
721c10cffd | ||
|
|
332aad8ad0 | ||
|
|
171b4c1fb8 | ||
|
|
60d4458bbc | ||
|
|
9ad171ab78 | ||
|
|
5918746059 | ||
|
|
4641843ffd | ||
|
|
1a181b907c | ||
|
|
fb7595d254 | ||
|
|
c8f0999035 | ||
|
|
46faec7857 | ||
|
|
e35d3fe6ba | ||
|
|
ba620e0f7f | ||
|
|
5700f55dc3 | ||
|
|
2c92010093 | ||
|
|
03490531d8 | ||
|
|
7bd9759e81 | ||
|
|
a808f56caf | ||
|
|
b7e2b45e69 | ||
|
|
63c45d7c3a | ||
|
|
096067ac62 | ||
|
|
8d6bf4c0c5 | ||
|
|
882b862780 | ||
|
|
e06a5d6300 | ||
|
|
7451e8c739 | ||
|
|
f034f773c5 | ||
|
|
681fd98dc3 | ||
|
|
e57b139e32 | ||
|
|
b64bfe2bb6 | ||
|
|
ee04b5788a | ||
|
|
2e8d431ab5 | ||
|
|
3465c37c0e | ||
|
|
bf6894e313 | ||
|
|
77e3f7f479 | ||
|
|
753f5027b0 | ||
|
|
23c012a527 | ||
|
|
895543641b | ||
|
|
72bfc5a2fd | ||
|
|
f5054cf41d | ||
|
|
b60703d496 | ||
|
|
9a8788fb80 | ||
|
|
1a44edcde1 | ||
|
|
383f2a9f32 | ||
|
|
f55828f1d4 | ||
|
|
d4f9633a0c | ||
|
|
b5138bcdd1 | ||
|
|
d9ee74bf14 | ||
|
|
2148697864 | ||
|
|
1c3403064e | ||
|
|
03d87807e0 | ||
|
|
f6c4e5e42e | ||
|
|
705a416d74 | ||
|
|
41f3f9d374 | ||
|
|
74cfd29272 | ||
|
|
9a3268b7c3 | ||
|
|
70c1666606 | ||
|
|
53ac09a5e9 | ||
|
|
c790e34b39 | ||
|
|
8428ad1c9c | ||
|
|
efea18327b | ||
|
|
84fb1d1462 | ||
|
|
5dc7b5068d | ||
|
|
ab603240e4 | ||
|
|
2e5c2ec018 | ||
|
|
349e75584f | ||
|
|
bea3a0b680 | ||
|
|
8b6bf92e9a | ||
|
|
133c3613e9 | ||
|
|
ff2e5ee064 | ||
|
|
22aed48d4e | ||
|
|
4c7e45a1d5 | ||
|
|
ca41ae7f85 | ||
|
|
fa61e7b10e | ||
|
|
8397dcacc5 | ||
|
|
3f2d343161 | ||
|
|
d91628f811 | ||
|
|
94bff3aa9d | ||
|
|
7253bdd634 | ||
|
|
8c6a17404f | ||
|
|
98edb54ca4 | ||
|
|
9b780ffac6 | ||
|
|
59f6239ea0 | ||
|
|
c2cb6081e1 | ||
|
|
95943deb82 | ||
|
|
08a71f657f | ||
|
|
2b48544e32 | ||
|
|
f1191d4b3c | ||
|
|
8173429131 | ||
|
|
82be04ad8a | ||
|
|
bb735763c6 | ||
|
|
0bbb82f9ba | ||
|
|
e42eff4e10 | ||
|
|
7e659f11fe | ||
|
|
c667cefd4c | ||
|
|
571c5799e9 | ||
|
|
604a3b2a20 | ||
|
|
7d36460851 | ||
|
|
8f634b9d07 | ||
|
|
12244abb56 | ||
|
|
fc2bbb1d6e | ||
|
|
f03e7670cf | ||
|
|
e202b7eae7 | ||
|
|
3fcbd21104 | ||
|
|
db0f604faf | ||
|
|
e5a6e43333 | ||
|
|
faa5f0b9ed | ||
|
|
7c13727978 | ||
|
|
2dba8b6496 | ||
|
|
2dadd4e064 | ||
|
|
a60ca62eba | ||
|
|
674ffb6bb2 | ||
|
|
5fa0fc037c | ||
|
|
8a537b8204 | ||
|
|
6e25f13e06 | ||
|
|
df8f6b1cb0 | ||
|
|
6050a9a7ac | ||
|
|
0abd9436ad | ||
|
|
af159f5b97 | ||
|
|
a657c5622e | ||
|
|
6f216cd916 | ||
|
|
8bf1864335 | ||
|
|
8931739e97 | ||
|
|
ab46cf7b2f | ||
|
|
d115714a9f | ||
|
|
e787abd3f3 | ||
|
|
1c0fe66944 | ||
|
|
148d18565f | ||
|
|
264d586863 | ||
|
|
76bee65475 | ||
|
|
8dac49548c | ||
|
|
5c5d204d15 | ||
|
|
e1f85973c1 | ||
|
|
3554806741 | ||
|
|
e4cc308d43 | ||
|
|
78271a9ed4 | ||
|
|
8f67188c19 | ||
|
|
c432b27c92 | ||
|
|
30a3bbb198 | ||
|
|
e7f9358f96 | ||
|
|
750c7773ae | ||
|
|
900277f426 | ||
|
|
208adeef6c | ||
|
|
086828b12f | ||
|
|
c73d556e6f | ||
|
|
357e2f60f2 | ||
|
|
243ba70b33 | ||
|
|
640da07089 | ||
|
|
69c38774fe | ||
|
|
44527850f6 | ||
|
|
8d96b2c31f | ||
|
|
0019e8c61c | ||
|
|
545f5fc3e9 | ||
|
|
5108de20c3 | ||
|
|
9656be1dde | ||
|
|
09890e8048 | ||
|
|
b672b4c526 | ||
|
|
6ca49327c3 | ||
|
|
3d47cc44af | ||
|
|
e8b471ac97 | ||
|
|
0e6915b860 | ||
|
|
0d48e517f8 | ||
|
|
f2469ecaaf | ||
|
|
c8451c7d3f | ||
|
|
d3025a34ca | ||
|
|
17426583e0 | ||
|
|
04c4a4c059 | ||
|
|
6ae0aaa6f9 | ||
|
|
d22361f2ac | ||
|
|
e21e608cca | ||
|
|
238658cf69 | ||
|
|
2a361d86e0 | ||
|
|
294fb3d4ce | ||
|
|
342da7e250 | ||
|
|
9c52108035 | ||
|
|
5b954b93e3 | ||
|
|
b596e063f5 | ||
|
|
965b35b78c | ||
|
|
9551b3acb4 | ||
|
|
27ac1c7782 | ||
|
|
f9d0b69bce | ||
|
|
a91ce82d77 | ||
|
|
d8b5d8163b | ||
|
|
6fca06d341 | ||
|
|
44080829e3 | ||
|
|
651e028d5b | ||
|
|
6f3186df2f | ||
|
|
92c61f928e | ||
|
|
d3cb45f879 | ||
|
|
314e2932fb | ||
|
|
8ee5165ccf | ||
|
|
8f2a9e6703 | ||
|
|
f8a6fb9ce2 | ||
|
|
bf248cd645 | ||
|
|
1563814cb0 | ||
|
|
91770998a7 | ||
|
|
d7aca1025f | ||
|
|
b3298d18c8 | ||
|
|
359147e7e8 | ||
|
|
937f1912d7 | ||
|
|
18c0e32309 | ||
|
|
1518cb1155 | ||
|
|
c5b7dd2918 | ||
|
|
b668b7eda2 | ||
|
|
a595ead2c6 | ||
|
|
cc1ee544c3 | ||
|
|
a0862936e8 | ||
|
|
f91f5743b6 | ||
|
|
eb74b90b42 | ||
|
|
c3c108bd50 | ||
|
|
06975f79f5 | ||
|
|
830106a168 | ||
|
|
aa2dfcc42f | ||
|
|
ed71fd6227 | ||
|
|
fd60528567 | ||
|
|
01934d5b00 | ||
|
|
bba0e3ed7d | ||
|
|
7db0b13d30 | ||
|
|
b1c6c8f835 | ||
|
|
7620f40cb2 | ||
|
|
cf9058be04 | ||
|
|
234532ebef | ||
|
|
ccddbb1316 | ||
|
|
4ef315d4e2 | ||
|
|
9b7fe331f6 | ||
|
|
eeff0b40fb | ||
|
|
0c933c0085 | ||
|
|
a3d94fcca6 | ||
|
|
9fe1d190da | ||
|
|
73981c2e75 | ||
|
|
107dec388e | ||
|
|
1df44aea40 | ||
|
|
3bc623be7c | ||
|
|
123e6cf82d | ||
|
|
5e9d134021 | ||
|
|
0a022fddcc | ||
|
|
f18a4dc16f | ||
|
|
9ac754fd40 | ||
|
|
2667053fde | ||
|
|
8874994feb | ||
|
|
5a8b92f556 | ||
|
|
a65c2e6a1f | ||
|
|
2d0304feb1 | ||
|
|
34fec63234 | ||
|
|
1d5f27f567 | ||
|
|
093db8bdac | ||
|
|
04602efd6e | ||
|
|
3265de0c1d | ||
|
|
e4a428e0c5 | ||
|
|
6cc28450a4 | ||
|
|
a0c7fbacb7 | ||
|
|
b750094934 | ||
|
|
d953a964cd | ||
|
|
aa95ffac82 | ||
|
|
9c345828b0 | ||
|
|
59c137c653 | ||
|
|
3d405542af | ||
|
|
88ff4b85ce | ||
|
|
461ac5f226 | ||
|
|
a7d2d0a3f3 | ||
|
|
191ce11d8e | ||
|
|
0d28a3ef9b | ||
|
|
ac34249afe | ||
|
|
d1f87be435 | ||
|
|
cbb6117d69 | ||
|
|
334a1db262 | ||
|
|
e3bee69b27 | ||
|
|
0ff5325e3e | ||
|
|
40efb2f58a | ||
|
|
4b20c7224e | ||
|
|
8e06beced6 | ||
|
|
b043f2a5ed | ||
|
|
3d59bc7a97 | ||
|
|
75dcad8ea4 | ||
|
|
4b38eb848f | ||
|
|
bfce04e63c | ||
|
|
846c5c202b | ||
|
|
600811ff01 | ||
|
|
26ce289e9b | ||
|
|
10a9c0f482 | ||
|
|
498ffa27f9 | ||
|
|
ce8b46f300 | ||
|
|
995eff975f | ||
|
|
2ab35e91e2 | ||
|
|
4867ac2c7c | ||
|
|
a68149a87b | ||
|
|
8be3cdb6a0 | ||
|
|
99760c27af | ||
|
|
4aa76ce444 | ||
|
|
82ae128429 | ||
|
|
92dc611bb1 | ||
|
|
dbf333c971 | ||
|
|
ca5dc3a4cc | ||
|
|
a267a9c0b8 | ||
|
|
21cbb2c697 | ||
|
|
00f49be42c | ||
|
|
37b55bf934 | ||
|
|
f2aca02b82 | ||
|
|
dd101259f0 | ||
|
|
b1f2742422 | ||
|
|
114b44d4b6 | ||
|
|
e07f3f0658 | ||
|
|
b21aef09be | ||
|
|
d964e65a7e | ||
|
|
024856e538 | ||
|
|
e530df7239 | ||
|
|
e817c816c9 | ||
|
|
14f0f8c121 | ||
|
|
4c97948e04 | ||
|
|
360e8185f7 | ||
|
|
da0958d455 | ||
|
|
70f5d24e0a | ||
|
|
6b281ef001 | ||
|
|
3ddd3207b3 | ||
|
|
8e58a3f5e2 | ||
|
|
a95c4b2446 | ||
|
|
97fe1686f5 | ||
|
|
76fcf5d0b7 | ||
|
|
bf1fc58e10 | ||
|
|
3559d825ca | ||
|
|
2d54086d89 | ||
|
|
c31c67a3eb | ||
|
|
e924cbefda | ||
|
|
6867ef78dc | ||
|
|
a76d09d3f6 | ||
|
|
b93eea0e24 | ||
|
|
7332aa4acd | ||
|
|
49e4d2844b | ||
|
|
236373b7d7 | ||
|
|
21a9e4312d | ||
|
|
628d9bc0c1 | ||
|
|
caf5ca04f5 | ||
|
|
7cbbf2bdd7 | ||
|
|
9d6e77fd5a | ||
|
|
e7d251f8a1 | ||
|
|
af950d042a | ||
|
|
23ae908e7d | ||
|
|
38ffaca8cc | ||
|
|
b0bde0eb44 | ||
|
|
763cf8aa0a | ||
|
|
9f0d3e0f3c | ||
|
|
937bb65a14 | ||
|
|
376fd88fc9 | ||
|
|
fca23274bf | ||
|
|
27d038512c | ||
|
|
d8291edae0 | ||
|
|
e6c0ac91cd | ||
|
|
74713f8b7c | ||
|
|
38a43ba7ff | ||
|
|
ca832a38e2 | ||
|
|
54b2b9b339 | ||
|
|
fede78c337 | ||
|
|
8a41dca46d | ||
|
|
d084128297 | ||
|
|
a141f9cbbd | ||
|
|
e415da13e4 | ||
|
|
10d65abf47 | ||
|
|
a03e580b36 | ||
|
|
79e982d198 | ||
|
|
453bd8fc04 | ||
|
|
9ced2921d9 | ||
|
|
fa99a89bbf | ||
|
|
f1a02b8afa | ||
|
|
6e4e60fc64 | ||
|
|
4f5e77d189 | ||
|
|
4fa7ce416b | ||
|
|
4d9f75b7b0 | ||
|
|
bfd83f2e87 | ||
|
|
f0c947a50b | ||
|
|
e19617be2f | ||
|
|
54697866f5 | ||
|
|
8c1f0bd05c | ||
|
|
c3999f92b1 | ||
|
|
c3f6bef152 | ||
|
|
c870bfe442 | ||
|
|
06846afa77 | ||
|
|
944988cb63 | ||
|
|
6cd51597a3 | ||
|
|
6209080e0c | ||
|
|
4a7e369c36 | ||
|
|
984da79210 | ||
|
|
38b40c0f50 | ||
|
|
d0097eb5b6 | ||
|
|
71303376ee | ||
|
|
8dbd5a9b21 | ||
|
|
f82dc905a2 | ||
|
|
bfbabc05d5 | ||
|
|
58767454b3 | ||
|
|
4776651b27 | ||
|
|
832eade857 | ||
|
|
44ff9a8b4b | ||
|
|
3f26a74c71 | ||
|
|
1041cb5160 | ||
|
|
04e7df1283 | ||
|
|
fd5d92eb12 | ||
|
|
46d2c72776 | ||
|
|
03f8765279 | ||
|
|
9f027ad164 | ||
|
|
68bd47126a | ||
|
|
f9c351f941 | ||
|
|
0440cd672d | ||
|
|
cb5a71213f |
221
.gitea/workflows/pr-validation.yml
Normal file
221
.gitea/workflows/pr-validation.yml
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
name: Pull Request Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.head_ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
|
GOTOOLCHAIN: auto
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/summary.md
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26.1'
|
||||||
|
check-latest: true
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Cache Go modules and build cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/bin
|
||||||
|
key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.mod', '**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-cache-
|
||||||
|
|
||||||
|
- name: Verify module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: Prepare test runtime
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ruby
|
||||||
|
git config --global user.name "gitea-actions[bot]"
|
||||||
|
git config --global user.email "gitea-actions[bot]@users.noreply.local"
|
||||||
|
|
||||||
|
- name: Run full unit test suite with coverage
|
||||||
|
id: coverage
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
|
||||||
|
|
||||||
|
set +e
|
||||||
|
awk '
|
||||||
|
/^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ {
|
||||||
|
pkg = $2
|
||||||
|
cov = $0
|
||||||
|
sub(/^.*coverage: /, "", cov)
|
||||||
|
sub(/% of statements.*$/, "", cov)
|
||||||
|
status = "target"
|
||||||
|
if (cov + 0 < 50) {
|
||||||
|
status = "fail"
|
||||||
|
fail = 1
|
||||||
|
} else if (cov + 0 < 65) {
|
||||||
|
status = "high-risk"
|
||||||
|
} else if (cov + 0 < 80) {
|
||||||
|
status = "warning"
|
||||||
|
}
|
||||||
|
printf "%s %.1f %s\n", pkg, cov + 0, status
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (fail) {
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' go-test-coverage.log > coverage-packages.raw
|
||||||
|
package_gate_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '| Package | Coverage | Status |'
|
||||||
|
echo '| --- | ---: | --- |'
|
||||||
|
} > coverage-packages.md
|
||||||
|
|
||||||
|
while read -r pkg cov status; do
|
||||||
|
case "$status" in
|
||||||
|
fail)
|
||||||
|
pretty='FAIL (<50%)'
|
||||||
|
;;
|
||||||
|
high-risk)
|
||||||
|
pretty='High risk (50%-64.99%)'
|
||||||
|
;;
|
||||||
|
warning)
|
||||||
|
pretty='Warning (65%-79.99%)'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pretty='Target (>=80%)'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md
|
||||||
|
done < coverage-packages.raw
|
||||||
|
|
||||||
|
if [[ "$package_gate_status" -ne 0 ]]; then
|
||||||
|
echo "Per-package coverage gate failed: one or more packages are below 50%." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check code formatting
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
fmt_output=$(go fmt ./...)
|
||||||
|
if [[ -n "$fmt_output" ]]; then
|
||||||
|
echo "Code formatting check failed. The following files need formatting:" >&2
|
||||||
|
echo "$fmt_output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run Gosec Security Scanner
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||||
|
gosec ./...
|
||||||
|
|
||||||
|
- name: Run Go Vulnerability Check
|
||||||
|
uses: golang/govulncheck-action@v1.0.4
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Check coverage artefacts
|
||||||
|
id: coverage-files
|
||||||
|
if: ${{ always() && steps.coverage.outcome == 'success' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -f coverage.out ]]; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "coverage.out was not produced; skipping coverage badge upload." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload coverage badge
|
||||||
|
id: badge
|
||||||
|
if: ${{ always() && steps.coverage.outcome == 'success' && steps.coverage-files.outputs.exists == 'true' }}
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-profile: coverage.out
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
|
||||||
|
- name: Validate changelog gate
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! awk '
|
||||||
|
/^## \[Unreleased\]/ { in_unreleased=1; next }
|
||||||
|
/^## \[/ && in_unreleased { exit 0 }
|
||||||
|
in_unreleased && /^- / { found=1 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' CHANGELOG.md; then
|
||||||
|
echo "Missing changelog entry under [Unreleased]." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Decorate PR
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.badge.outputs.total }}
|
||||||
|
badge-url: ${{ steps.badge.outputs.badge-url }}
|
||||||
|
enable-changelog-gate: 'false'
|
||||||
|
|
||||||
|
- name: Add coverage summary
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo '## Coverage'
|
||||||
|
echo
|
||||||
|
echo '- Total: `${{ steps.badge.outputs.total }}%`'
|
||||||
|
echo '- Report: ${{ steps.badge.outputs.report-url }}'
|
||||||
|
echo '- Badge: ${{ steps.badge.outputs.badge-url }}'
|
||||||
|
echo
|
||||||
|
echo '### Package Coverage'
|
||||||
|
cat coverage-packages.md
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
- name: Run behavior suite
|
||||||
|
run: ./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
if [[ -f "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
46
.gitea/workflows/prepare-release.yml
Normal file
46
.gitea/workflows/prepare-release.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Provide lowercase changelog compatibility
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||||
|
ln -s CHANGELOG.md changelog.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vociferate prepare
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Provide lowercase changelog compatibility
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||||
|
ln -s CHANGELOG.md changelog.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vociferate publish
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0
|
||||||
224
.gitea/workflows/push-validation.yml
Normal file
224
.gitea/workflows/push-validation.yml
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
name: Push Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags-ignore:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-open-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
outputs:
|
||||||
|
should_run: ${{ steps.detect.outputs.should_run }}
|
||||||
|
steps:
|
||||||
|
- name: Detect open PR for branch
|
||||||
|
id: detect
|
||||||
|
env:
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
api_url="${SERVER_URL}/api/v1/repos/${REPOSITORY}/pulls?state=open&head=${OWNER}:${BRANCH}"
|
||||||
|
if [ -n "${TOKEN:-}" ]; then
|
||||||
|
response="$(curl -fsSL -H "Authorization: token ${TOKEN}" -H "accept: application/json" "$api_url" || echo '[]')"
|
||||||
|
else
|
||||||
|
response="$(curl -fsSL -H "accept: application/json" "$api_url" || echo '[]')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
open_prs="$(printf '%s' "$response" | grep -o '"number":[0-9]\+' | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
|
if [ "$open_prs" -gt 0 ]; then
|
||||||
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Open PR detected for ${OWNER}:${BRANCH}; skipping push validation." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No open PR detected for ${OWNER}:${BRANCH}; running push validation." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
validate:
|
||||||
|
needs: check-open-pr
|
||||||
|
if: ${{ needs.check-open-pr.outputs.should_run == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
|
GOTOOLCHAIN: auto
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/summary.md
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26.1'
|
||||||
|
check-latest: true
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Cache Go modules and build cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/bin
|
||||||
|
key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.mod', '**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-cache-
|
||||||
|
|
||||||
|
- name: Verify module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: Check code formatting
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
fmt_output=$(go fmt ./...)
|
||||||
|
if [[ -n "$fmt_output" ]]; then
|
||||||
|
echo "Code formatting check failed. The following files need formatting:" >&2
|
||||||
|
echo "$fmt_output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run Gosec Security Scanner
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||||
|
gosec ./...
|
||||||
|
|
||||||
|
- name: Run Go Vulnerability Check
|
||||||
|
uses: golang/govulncheck-action@v1.0.4
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install AWS CLI v2
|
||||||
|
uses: ankurk91/install-aws-cli-action@v1
|
||||||
|
|
||||||
|
- name: Verify AWS CLI
|
||||||
|
run: aws --version
|
||||||
|
|
||||||
|
- name: Prepare test runtime
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ruby
|
||||||
|
git config --global user.name "gitea-actions[bot]"
|
||||||
|
git config --global user.email "gitea-actions[bot]@users.noreply.local"
|
||||||
|
|
||||||
|
- name: Run full unit test suite with coverage
|
||||||
|
id: coverage-tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
|
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
||||||
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
awk '
|
||||||
|
/^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ {
|
||||||
|
pkg = $2
|
||||||
|
cov = $0
|
||||||
|
sub(/^.*coverage: /, "", cov)
|
||||||
|
sub(/% of statements.*$/, "", cov)
|
||||||
|
status = "target"
|
||||||
|
if (cov + 0 < 50) {
|
||||||
|
status = "fail"
|
||||||
|
fail = 1
|
||||||
|
} else if (cov + 0 < 65) {
|
||||||
|
status = "high-risk"
|
||||||
|
} else if (cov + 0 < 80) {
|
||||||
|
status = "warning"
|
||||||
|
}
|
||||||
|
printf "%s %.1f %s\n", pkg, cov + 0, status
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (fail) {
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' go-test-coverage.log > coverage-packages.raw
|
||||||
|
package_gate_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '| Package | Coverage | Status |'
|
||||||
|
echo '| --- | ---: | --- |'
|
||||||
|
} > coverage-packages.md
|
||||||
|
|
||||||
|
while read -r pkg cov status; do
|
||||||
|
case "$status" in
|
||||||
|
fail)
|
||||||
|
pretty='FAIL (<50%)'
|
||||||
|
;;
|
||||||
|
high-risk)
|
||||||
|
pretty='High risk (50%-64.99%)'
|
||||||
|
;;
|
||||||
|
warning)
|
||||||
|
pretty='Warning (65%-79.99%)'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pretty='Target (>=80%)'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md
|
||||||
|
done < coverage-packages.raw
|
||||||
|
|
||||||
|
if [[ "$package_gate_status" -ne 0 ]]; then
|
||||||
|
echo "Per-package coverage gate failed: one or more packages are below 50%." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish coverage artefacts
|
||||||
|
id: coverage-badge
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-profile: coverage.out
|
||||||
|
coverage-html: coverage.html
|
||||||
|
coverage-badge: coverage-badge.svg
|
||||||
|
coverage-summary: coverage-summary.json
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
branch-name: ${{ github.ref_name }}
|
||||||
|
repository-name: ${{ github.repository }}
|
||||||
|
summary-file: ${{ env.SUMMARY_FILE }}
|
||||||
|
|
||||||
|
- name: Run behavior suite on main pushes
|
||||||
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
run: ./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
if [[ -f "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
93
.gitea/workflows/tag-build-artifacts.yml
Normal file
93
.gitea/workflows/tag-build-artifacts.yml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
name: Tag Build Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RUNNER_TOOL_CACHE: /cache/tools
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26.1'
|
||||||
|
check-latest: true
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
uses: crazy-max/ghaction-upx@v3
|
||||||
|
with:
|
||||||
|
install-only: true
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||||
|
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
|
||||||
|
go build -o "$output" ./cmd/homesick
|
||||||
|
|
||||||
|
- name: Compress binary with UPX
|
||||||
|
if: ${{ matrix.goos == 'linux' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||||
|
if ! upx --best --lzma "$output"; then
|
||||||
|
echo "::warning::UPX compression failed for ${output}; continuing with uncompressed binary"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Package artifact
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
tar -czf gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz gosick_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
|
||||||
|
- name: Publish workflow artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gosick_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
path: dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Provide lowercase changelog compatibility
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||||
|
ln -s CHANGELOG.md changelog.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vociferate publish
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0
|
||||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,19 +1,5 @@
|
|||||||
# rcov generated
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# rdoc generated
|
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
|
||||||
rdoc
|
|
||||||
|
|
||||||
# yard generated
|
|
||||||
doc
|
|
||||||
.yardoc
|
|
||||||
|
|
||||||
# jeweler generated
|
|
||||||
pkg
|
|
||||||
|
|
||||||
.bundle
|
|
||||||
|
|
||||||
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
|
|
||||||
#
|
#
|
||||||
# * Create a file at ~/.gitignore
|
# * Create a file at ~/.gitignore
|
||||||
# * Include files you want ignored
|
# * Include files you want ignored
|
||||||
@@ -39,3 +25,15 @@ pkg
|
|||||||
#
|
#
|
||||||
# For vim:
|
# For vim:
|
||||||
*.swp
|
*.swp
|
||||||
|
#
|
||||||
|
# For IDEA:
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
|
||||||
|
# Go scaffolding artifacts
|
||||||
|
dist/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
.github/*
|
||||||
328
CHANGELOG.md
Normal file
328
CHANGELOG.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in the automated release tooling.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `internal/homesick/version`: added `version_test.go` covering the `String` constant and semver format validation.
|
||||||
|
- `internal/homesick/cli`: added targeted tests for `list`, `generate`, `clone`, `status`, and `diff` CLI commands; coverage raised from 62.5% to 71.2%.
|
||||||
|
- `internal/homesick/core`: added `helpers_test.go` covering `runGit` pretend mode, `actionVerb`, `sayStatus`, `unlinkPath`, `linkPath`, `readSubdirs`, `matchesIgnoredDir`, `confirmDestroy` responses and read errors, `ExecAll` empty-command and no-repos-dir edge cases, and `Link`/`Unlink` default-castle wrappers; existing suites extended with `New` constructor and `PullAll` quiet-mode tests; coverage raised from 75.6% to 80.2%.
|
||||||
|
- PR validation now uses `vociferate/coverage-badge@v1.1.0` for coverage artefact upload and `vociferate/decorate-pr@v1.1.0` for PR comment decoration and changelog gate enforcement.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `gosec` security scanning in CI now invoked directly via `go install + gosec ./...` instead of the `securego/gosec` action, resolving compatibility issues with Go 1.26.1.
|
||||||
|
- `golang/govulncheck-action` pinned from `@v1` to `@v1.0.4` in push and PR validation; major-version tags do not resolve reliably in Gitea API.
|
||||||
|
- `GOTOOLCHAIN=auto` moved from per-step env to job-level env in push and PR validation workflows.
|
||||||
|
- Push validation `vociferate/coverage-badge` bumped from `v1.0.1` to `v1.1.0` for version consistency with PR validation.
|
||||||
|
- `vociferate/prepare` and `vociferate/publish` in `prepare-release.yml` and `tag-build-artifacts.yml` bumped from `v1.0.1` to `v1.1.0` for cross-workflow version consistency.
|
||||||
|
- `golang/govulncheck-action` in push and PR validation now passes explicit `go-package`, cache enablement, and `cache-dependency-path` inputs to match the required workflow pattern.
|
||||||
|
- CLI/core wiring now injects `stdin` through `core.NewApp`, `main` owns the `GIT_TERMINAL_PROMPT=0` side effect, and `Rc` force handling is passed per call instead of mutating shared app state.
|
||||||
|
- Core filesystem and git error paths now wrap underlying failures with command-specific context across listing, generation, tracking, linking, rc hook execution, and destroy confirmation flows.
|
||||||
|
- Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications.
|
||||||
|
- PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs.
|
||||||
|
- PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action.
|
||||||
|
- Push validation now triggers on all branches, not only `main`.
|
||||||
|
- Push and PR validation workflows now share a `concurrency` group keyed on the branch name (`github.ref_name` / `github.head_ref`) with `cancel-in-progress: true`; when a push to a PR branch fires both workflows, the second run cancels the first so only one validation executes per commit.
|
||||||
|
- Push validation now performs an open-PR branch check via the Gitea API and skips the heavy validation job when the branch already has an open PR, preventing duplicate full pipeline runs.
|
||||||
|
- Push validation open-PR detection is now POSIX-shell compatible (no bash-only `pipefail`/array/`[[ ... ]]` usage), fixing failures on runners that execute `run` scripts with `/bin/sh`.
|
||||||
|
- PR validation now checks that `coverage.out` exists before invoking `coverage-badge`; when missing, badge upload is skipped with a summary note instead of failing the workflow.
|
||||||
|
- PR decoration is now `continue-on-error` to avoid hard-failing validation when the external `decorate-pr` action's internal extractor step is unavailable.
|
||||||
|
- README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards.
|
||||||
|
- CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching.
|
||||||
|
- CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners.
|
||||||
|
- CI security scanner compatibility: gosec and govulncheck action steps now set `GOTOOLCHAIN=auto` so repositories requiring newer Go versions are analyzed successfully.
|
||||||
|
- Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected.
|
||||||
|
- Applied `go fmt` normalization to core tests (`list_test.go` and `track_test.go`) to satisfy the new formatting gate.
|
||||||
|
- Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0.
|
||||||
|
- CI workflows now include explicit caching for Go modules and build artifacts to reduce pipeline execution time.
|
||||||
|
- Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`.
|
||||||
|
- Security: `Open()` now executes the editor directly without shell intermediary to prevent injection through the `$EDITOR` environment variable.
|
||||||
|
|
||||||
|
- CI validation now runs `gosec` and `govulncheck` security scanning on push and pull request workflows.
|
||||||
|
- `cmd/homesick` now includes entrypoint-focused tests that exercise both the CLI run path and `main` process path.
|
||||||
|
- `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller.
|
||||||
|
- `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten.
|
||||||
|
- `exec` command: runs a shell command inside the target castle root directory.
|
||||||
|
- `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order.
|
||||||
|
- `pull --all` support: pulls updates for every cloned castle in sorted order.
|
||||||
|
- `rc --force` support: legacy `.homesickrc` compatibility hooks now require explicit force mode before execution.
|
||||||
|
- Global command flags restored: `--pretend` (with `--dry-run` alias) and `--quiet`.
|
||||||
|
- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`.
|
||||||
|
- Containerized behavior test suite for command parity validation.
|
||||||
|
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
|
||||||
|
- Just workflow support for building and running the Linux behavior binary.
|
||||||
|
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
||||||
|
- Pull requests now receive coverage report links in CI comments.
|
||||||
|
- Automated release orchestration now runs through vociferate prepare and publish workflows.
|
||||||
|
- `symlink` command alias compatibility for `link`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.1`) instead of repository-local releaseprep wrappers.
|
||||||
|
- Push and pull request validation now enforce per-package coverage gates (fail below 50%) and publish package-level coverage status tables in workflow summaries.
|
||||||
|
- Push and pull request validation now verify module hygiene (`go mod tidy`, `go mod verify`) and use a dedicated summary-file pattern with a final always-run summary step.
|
||||||
|
- CLI argument parsing migrated to Kong.
|
||||||
|
- Git operations for clone and track migrated to `go-git`.
|
||||||
|
- Build and behavior workflows now produce and run the `gosick` binary name.
|
||||||
|
- CI validation is unified into push events, running behavior tests only on `main` pushes.
|
||||||
|
- Gitea CI workflows now cache Go modules and build artifacts using a shared runner tool cache.
|
||||||
|
- Gitea workflow and README badge updated from `push-unit-tests` to `push-validation`.
|
||||||
|
- CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output.
|
||||||
|
- CLI help description now reflects Homesick's purpose for managing precious dotfiles.
|
||||||
|
- Release notes standardized to Keep a Changelog format.
|
||||||
|
- `commit` command now accepts legacy positional form `commit <castle> <message>` in addition to `-m`.
|
||||||
|
- `destroy` now prompts for confirmation by default and preserves the castle when declined.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `status` and `diff` now consistently write through configured app output writers.
|
||||||
|
- `pull --all` output now includes per-castle prefixes to match behavior expectations.
|
||||||
|
- Behavior-suite container now includes Ruby so `.homesickrc` parity wrapper execution works under `rc --force`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Legacy `script/prepare-release.sh` releaseprep wrapper and its dedicated script test.
|
||||||
|
- Legacy Ruby implementation and Ruby toolchain.
|
||||||
|
- Legacy in-repository `releaseprep` package and command implementation, now superseded by the standalone `vociferate` tool.
|
||||||
|
|
||||||
|
## [1.1.6] - 2017-12-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ensure `FileUtils` is imported correctly to avoid a potential error.
|
||||||
|
- Fix an issue where comparing a diff did not use the content of the new file.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Small documentation fixes.
|
||||||
|
|
||||||
|
## [1.1.5] - 2017-03-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Problem with version number being incorrect.
|
||||||
|
|
||||||
|
## [1.1.4] - 2017-03-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ensure symlink conflicts are explicitly communicated to users and symlinks are not silently overwritten.
|
||||||
|
- Fix a problem in diff when asking a user to resolve a conflict.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use real paths of symlinks when linking a castle into home.
|
||||||
|
- Code refactoring and fixes.
|
||||||
|
|
||||||
|
## [1.1.3] - 2015-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow a destination to be passed when cloning a castle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make sure `homesick edit` opens the default editor in the root of the given castle.
|
||||||
|
- Bug when diffing edited files.
|
||||||
|
- Crashing bug when attempting to diff directories.
|
||||||
|
- Ensure that messages are escaped correctly on `git commit all`.
|
||||||
|
|
||||||
|
## [1.1.2] - 2015-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `--force` option to the rc command to bypass confirmation checks when running a `.homesickrc` file.
|
||||||
|
- Check to ensure that at least Git 1.8.0 is installed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Stop Homesick failing silently when Git is not installed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Code refactoring and fixes.
|
||||||
|
|
||||||
|
## [1.1.0] - 2014-04-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `exec` and `exec_all` commands to run commands inside one or all cloned castles.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Code refactoring.
|
||||||
|
|
||||||
|
## [1.0.0] - 2014-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `version` command.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Support for Ruby 1.8.7.
|
||||||
|
|
||||||
|
## [0.9.8] - 2014-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick cd` command.
|
||||||
|
- `homesick open` command.
|
||||||
|
|
||||||
|
## [0.9.4] - 2013-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick unlink` command.
|
||||||
|
- `homesick rc` command.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use HTTPS protocol instead of git protocol.
|
||||||
|
|
||||||
|
## [0.9.3] - 2013-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Recursive option to `homesick clone`.
|
||||||
|
|
||||||
|
## [0.9.2] - 2013-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick show_path` command.
|
||||||
|
- `homesick status` command.
|
||||||
|
- `homesick diff` command.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set `dotfiles` as default castle name.
|
||||||
|
|
||||||
|
## [0.9.1] - 2013-06-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Small bugs: #35, #40.
|
||||||
|
|
||||||
|
## [0.9.0] - 2013-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `.homesick_subdir` (#39).
|
||||||
|
|
||||||
|
## [0.8.1] - 2013-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `homesick list` bug on Ruby 2.0 (#37).
|
||||||
|
|
||||||
|
## [0.8.0] - 2013-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `commit` and `push` command.
|
||||||
|
- Commit changes in a castle and push to remote.
|
||||||
|
- Enable recursive submodule update.
|
||||||
|
- Git add when using track.
|
||||||
|
|
||||||
|
## [0.7.0] - 2012-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New option for pull command: `--all`.
|
||||||
|
- Pull each castle instead of just one.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Double-cloning (#14).
|
||||||
|
|
||||||
|
## [0.6.1] - 2010-11-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- License.
|
||||||
|
|
||||||
|
## [0.6.0] - 2010-10-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `.homesickrc` support.
|
||||||
|
- Castles can now have a `.homesickrc` inside them.
|
||||||
|
- On clone, this is eval'd inside the destination directory.
|
||||||
|
- `track` command.
|
||||||
|
- Allows easily moving an existing file into a castle and symlinking it back.
|
||||||
|
|
||||||
|
## [0.5.0] - 2010-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias).
|
||||||
|
- A very basic `homesick generate <CASTLE>`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3).
|
||||||
|
|
||||||
|
## [0.4.1] - 2010-04-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improve error message when a castle's home dir does not exist.
|
||||||
|
|
||||||
|
## [0.4.0] - 2010-04-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick clone` can take a path to a directory on the filesystem, which is symlinked into place.
|
||||||
|
- `homesick clone` tries to run `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use `HOME` environment variable for where to store files, instead of assuming `~`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Missing dependency on thor and others.
|
||||||
|
|
||||||
|
## [0.3.0] - 2010-04-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Rename `link` to `symlink`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Conflict resolution when symlink destination exists and is a normal file.
|
||||||
|
|
||||||
|
## [0.2.0] - 2010-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Better support for recognizing git URLs (thanks jacobat).
|
||||||
|
- If it looks like a GitHub user/repo, use that.
|
||||||
|
- Otherwise hand off to git clone.
|
||||||
|
- Listing now displays in color and shows git remote.
|
||||||
|
- Support pretend, force, and quiet modes.
|
||||||
|
|
||||||
|
## [0.1.1] - 2010-03-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Trying to link against castles that do not exist.
|
||||||
|
- Linking now excludes `.` and `..` from the list of files to link (thanks Martinos).
|
||||||
|
|
||||||
|
## [0.1.0] - 2010-03-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release.
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 0.5.0
|
|
||||||
|
|
||||||
* Fixed listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3)
|
|
||||||
* Added `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias!)
|
|
||||||
* Added a very basic `homesick generate <CASTLE>`
|
|
||||||
|
|
||||||
# 0.4.1
|
|
||||||
|
|
||||||
* Improved error message when a castle's home dir doesn't exist
|
|
||||||
|
|
||||||
# 0.4.0
|
|
||||||
|
|
||||||
* `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place
|
|
||||||
* `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo
|
|
||||||
* Fixed missing dependency on thor and others
|
|
||||||
* Use HOME environment variable for where to store files, instead of assuming ~
|
|
||||||
|
|
||||||
# 0.3.0
|
|
||||||
|
|
||||||
* Renamed 'link' to 'symlink'
|
|
||||||
* Fixed conflict resolution when symlink destination exists and is a normal file
|
|
||||||
|
|
||||||
# 0.2.0
|
|
||||||
|
|
||||||
* Better support for recognizing git urls (thanks jacobat!)
|
|
||||||
* if it looks like a github user/repo, do that
|
|
||||||
* otherwise hand off to git clone
|
|
||||||
* Listing now displays in color, and show git remote
|
|
||||||
* Support pretend, force, and quiet modes
|
|
||||||
|
|
||||||
# 0.1.1
|
|
||||||
|
|
||||||
* Fixed trying to link against castles that don't exist
|
|
||||||
* Fixed linking, which tries to exclude . and .. from the list of files to
|
|
||||||
link (thanks Martinos!)
|
|
||||||
|
|
||||||
# 0.1.0
|
|
||||||
|
|
||||||
* Initial release
|
|
||||||
16
Gemfile
16
Gemfile
@@ -1,16 +0,0 @@
|
|||||||
# Add dependencies required to use your gem here.
|
|
||||||
group :runtime do
|
|
||||||
#gem 'bundler', '>= 0.9.5'
|
|
||||||
gem "thor", ">= 0.13.6"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add dependencies to develop your gem here.
|
|
||||||
# Include everything needed to run rake, tests, features, etc.
|
|
||||||
group :development do
|
|
||||||
gem "rake"
|
|
||||||
gem "rspec", ">= 1.2.9"
|
|
||||||
gem "bundler", ">= 0.9.5"
|
|
||||||
gem "jeweler", ">= 1.4.0"
|
|
||||||
gem "rcov", ">= 0"
|
|
||||||
gem "test-construct"
|
|
||||||
end
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# homesick
|
|
||||||
|
|
||||||
A man's home (directory) is his castle, so don't leave home with out it.
|
|
||||||
|
|
||||||
Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in `~/.homesick`. It then allows you to symlink all the dotfiles into place with a single command.
|
|
||||||
|
|
||||||
We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so:
|
|
||||||
|
|
||||||
* Contains a 'home' directory
|
|
||||||
* 'home' contains any number of files and directories that begin with '.'
|
|
||||||
|
|
||||||
To get started, install homesick first:
|
|
||||||
|
|
||||||
gem install homesick
|
|
||||||
|
|
||||||
Next, you use the homesick command to clone a castle:
|
|
||||||
|
|
||||||
homesick clone git://github.com/technicalpickles/pickled-vim.git
|
|
||||||
|
|
||||||
Alternatively, if it's on github, there's a slightly shorter way:
|
|
||||||
|
|
||||||
homesick clone technicalpickles/pickled-vim
|
|
||||||
|
|
||||||
With the castle cloned, you can now link its contents into your home dir:
|
|
||||||
|
|
||||||
homesick symlink pickled-vim
|
|
||||||
|
|
||||||
If uou use the shorthand syntax for GitHub repositories in your clone, please note you will instead need to run:
|
|
||||||
|
|
||||||
homesick symlink technicalpickles/pickled-vim
|
|
||||||
|
|
||||||
If you're not sure what castles you have around, you can easily list them:
|
|
||||||
|
|
||||||
homesick list
|
|
||||||
|
|
||||||
Not sure what else homesick has up its sleeve? There's always the built in help:
|
|
||||||
|
|
||||||
homesick help
|
|
||||||
|
|
||||||
## Note on Patches/Pull Requests
|
|
||||||
|
|
||||||
* Fork the project.
|
|
||||||
* Make your feature addition or bug fix.
|
|
||||||
* Add tests for it. This is important so I don't break it in a future version unintentionally.
|
|
||||||
* Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
|
||||||
* Send me a pull request. Bonus points for topic branches.
|
|
||||||
|
|
||||||
## Copyright
|
|
||||||
|
|
||||||
Copyright (c) 2010 Joshua Nichols. See LICENSE for details.
|
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# homesick
|
||||||
|
|
||||||
|
[](https://git.hrafn.xyz/aether/gosick/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
|
||||||
|
[](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html)
|
||||||
|
|
||||||
|
Your home directory is your castle. Don't leave your dotfiles behind.
|
||||||
|
|
||||||
|
This repository now contains a Go implementation of Homesick. A dotfiles repository is called a castle and should contain a `home/` directory with files to link into your `$HOME`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Build with just:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just go-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly with Go:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o dist/gosick ./cmd/homesick
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Implemented commands:
|
||||||
|
|
||||||
|
- `clone URI [CASTLE_NAME]`
|
||||||
|
- `list`
|
||||||
|
- `show_path [CASTLE]`
|
||||||
|
- `status [CASTLE]`
|
||||||
|
- `diff [CASTLE]`
|
||||||
|
- `link [CASTLE]`
|
||||||
|
- `unlink [CASTLE]`
|
||||||
|
- `track FILE [CASTLE]`
|
||||||
|
- `pull [--all|CASTLE]`
|
||||||
|
- `push [CASTLE]`
|
||||||
|
- `commit -m MESSAGE [CASTLE]`
|
||||||
|
- `destroy [CASTLE]`
|
||||||
|
- `cd [CASTLE]`
|
||||||
|
- `open [CASTLE]`
|
||||||
|
- `exec CASTLE COMMAND...`
|
||||||
|
- `exec_all COMMAND...`
|
||||||
|
- `generate PATH`
|
||||||
|
- `rc [--force] [CASTLE]`
|
||||||
|
- `version`
|
||||||
|
|
||||||
|
Global options:
|
||||||
|
|
||||||
|
- `--pretend` simulates command execution for shell/git-backed operations.
|
||||||
|
- `--dry-run` is an alias for `--pretend`.
|
||||||
|
- `--quiet` suppresses status output.
|
||||||
|
|
||||||
|
### rc behavior
|
||||||
|
|
||||||
|
- Runs executable scripts in `<castle>/.homesick.d/` in lexicographic order.
|
||||||
|
- Executes scripts with the castle root as the current working directory.
|
||||||
|
- Forwards script stdout/stderr to command output.
|
||||||
|
- If `<castle>/.homesickrc` exists, `--force` is required before legacy Ruby compatibility hooks are run.
|
||||||
|
- If `<castle>/.homesickrc` exists and `<castle>/.homesick.d/parity.rb` does not, generates `parity.rb` before execution.
|
||||||
|
- Never overwrites an existing `parity.rb` wrapper.
|
||||||
|
|
||||||
|
### exec behavior
|
||||||
|
|
||||||
|
- `exec CASTLE COMMAND...` runs the shell command inside the target castle root.
|
||||||
|
- `exec_all COMMAND...` runs the same shell command inside each cloned castle root in sorted order.
|
||||||
|
|
||||||
|
## Behavior Suite
|
||||||
|
|
||||||
|
The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands.
|
||||||
|
|
||||||
|
Run behavior suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbose behavior suite output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just behavior-verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run all Go tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just go-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See `LICENSE`.
|
||||||
56
Rakefile
56
Rakefile
@@ -1,56 +0,0 @@
|
|||||||
require 'rubygems'
|
|
||||||
require 'bundler'
|
|
||||||
begin
|
|
||||||
Bundler.setup(:runtime, :development)
|
|
||||||
rescue Bundler::BundlerError => e
|
|
||||||
$stderr.puts e.message
|
|
||||||
$stderr.puts "Run `bundle install` to install missing gems"
|
|
||||||
exit e.status_code
|
|
||||||
end
|
|
||||||
require 'rake'
|
|
||||||
|
|
||||||
require 'jeweler'
|
|
||||||
Jeweler::Tasks.new do |gem|
|
|
||||||
gem.name = "homesick"
|
|
||||||
gem.summary = %Q{A man's home is his castle. Never leave your dotfiles behind.}
|
|
||||||
gem.description = %Q{
|
|
||||||
A man’s home (directory) is his castle, so don’t leave home with out it.
|
|
||||||
|
|
||||||
Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command.
|
|
||||||
|
|
||||||
}
|
|
||||||
gem.email = "josh@technicalpickles.com"
|
|
||||||
gem.homepage = "http://github.com/technicalpickles/homesick"
|
|
||||||
gem.authors = ["Joshua Nichols"]
|
|
||||||
gem.version = "0.5.2"
|
|
||||||
# Have dependencies? Add them to Gemfile
|
|
||||||
|
|
||||||
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
|
||||||
end
|
|
||||||
Jeweler::GemcutterTasks.new
|
|
||||||
|
|
||||||
|
|
||||||
require 'spec/rake/spectask'
|
|
||||||
Spec::Rake::SpecTask.new(:spec) do |spec|
|
|
||||||
spec.libs << 'lib' << 'spec'
|
|
||||||
spec.spec_files = FileList['spec/**/*_spec.rb']
|
|
||||||
end
|
|
||||||
|
|
||||||
Spec::Rake::SpecTask.new(:rcov) do |spec|
|
|
||||||
spec.libs << 'lib' << 'spec'
|
|
||||||
spec.pattern = 'spec/**/*_spec.rb'
|
|
||||||
spec.rcov = true
|
|
||||||
end
|
|
||||||
|
|
||||||
task :default => :spec
|
|
||||||
|
|
||||||
require 'rake/rdoctask'
|
|
||||||
Rake::RDocTask.new do |rdoc|
|
|
||||||
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
|
||||||
|
|
||||||
rdoc.rdoc_dir = 'rdoc'
|
|
||||||
rdoc.title = "homesick #{version}"
|
|
||||||
rdoc.rdoc_files.include('README*')
|
|
||||||
rdoc.rdoc_files.include('lib/**/*.rb')
|
|
||||||
end
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
|
|
||||||
require 'pathname'
|
|
||||||
lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path
|
|
||||||
$LOAD_PATH.unshift lib.to_s
|
|
||||||
|
|
||||||
require 'homesick'
|
|
||||||
|
|
||||||
Homesick.start
|
|
||||||
18
cmd/homesick/main.go
Normal file
18
cmd/homesick/main.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = os.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
exitCode := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
return cli.Run(args, stdin, stdout, stderr)
|
||||||
|
}
|
||||||
53
cmd/homesick/main_test.go
Normal file
53
cmd/homesick/main_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunVersionCommand(t *testing.T) {
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
|
||||||
|
exitCode := run([]string{"version"}, bytes.NewBuffer(nil), stdout, stderr)
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("run(version) exit code = %d, want 0", exitCode)
|
||||||
|
}
|
||||||
|
if got := stdout.String(); got != version.String+"\n" {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, version.String+"\n")
|
||||||
|
}
|
||||||
|
if got := stderr.String(); got != "" {
|
||||||
|
t.Fatalf("stderr = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainVersionCommand(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_HELPER_PROCESS") == "1" {
|
||||||
|
os.Args = []string{"gosick", "version"}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainVersionCommand")
|
||||||
|
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("helper process failed: %v, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
if got := stdout.String(); got != version.String+"\n" {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, version.String+"\n")
|
||||||
|
}
|
||||||
|
if got := stderr.String(); got != "" {
|
||||||
|
t.Fatalf("stderr = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
docker/behavior/Dockerfile
Normal file
22
docker/behavior/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY go.mod go.sum /workspace/
|
||||||
|
RUN go mod download
|
||||||
|
COPY . /workspace
|
||||||
|
RUN mkdir -p /workspace/dist && \
|
||||||
|
go build -o /workspace/dist/gosick ./cmd/homesick
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
ruby
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY . /workspace
|
||||||
|
COPY --from=builder /workspace/dist/gosick /workspace/dist/gosick
|
||||||
|
|
||||||
|
ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"]
|
||||||
38
go.mod
Normal file
38
go.mod
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
module git.hrafn.xyz/aether/gosick
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
toolchain go1.26.1
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/kong v1.12.1
|
||||||
|
github.com/go-git/go-git/v5 v5.17.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
|
github.com/go-git/go-billy/v5 v5.8.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
112
go.sum
Normal file
112
go.sum
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0=
|
||||||
|
github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
|
||||||
|
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
|
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
|
||||||
|
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
|
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||||
|
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
|
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Generated by jeweler
|
|
||||||
# DO NOT EDIT THIS FILE DIRECTLY
|
|
||||||
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
|
||||||
# -*- encoding: utf-8 -*-
|
|
||||||
|
|
||||||
Gem::Specification.new do |s|
|
|
||||||
s.name = %q{homesick}
|
|
||||||
s.version = "0.5.2"
|
|
||||||
|
|
||||||
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
|
||||||
s.authors = ["Joshua Nichols"]
|
|
||||||
s.date = %q{2010-07-22}
|
|
||||||
s.default_executable = %q{homesick}
|
|
||||||
s.description = %q{
|
|
||||||
A man’s home (directory) is his castle, so don’t leave home with out it.
|
|
||||||
|
|
||||||
Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command.
|
|
||||||
|
|
||||||
}
|
|
||||||
s.email = %q{josh@technicalpickles.com}
|
|
||||||
s.executables = ["homesick"]
|
|
||||||
s.extra_rdoc_files = [
|
|
||||||
"ChangeLog.markdown",
|
|
||||||
"LICENSE",
|
|
||||||
"README.markdown"
|
|
||||||
]
|
|
||||||
s.files = [
|
|
||||||
".document",
|
|
||||||
".gitignore",
|
|
||||||
"ChangeLog.markdown",
|
|
||||||
"Gemfile",
|
|
||||||
"LICENSE",
|
|
||||||
"README.markdown",
|
|
||||||
"Rakefile",
|
|
||||||
"bin/homesick",
|
|
||||||
"homesick.gemspec",
|
|
||||||
"lib/homesick.rb",
|
|
||||||
"lib/homesick/actions.rb",
|
|
||||||
"lib/homesick/shell.rb",
|
|
||||||
"spec/homesick_spec.rb",
|
|
||||||
"spec/spec.opts",
|
|
||||||
"spec/spec_helper.rb"
|
|
||||||
]
|
|
||||||
s.homepage = %q{http://github.com/technicalpickles/homesick}
|
|
||||||
s.rdoc_options = ["--charset=UTF-8"]
|
|
||||||
s.require_paths = ["lib"]
|
|
||||||
s.rubygems_version = %q{1.3.6}
|
|
||||||
s.summary = %q{A man's home is his castle. Never leave your dotfiles behind.}
|
|
||||||
s.test_files = [
|
|
||||||
"spec/homesick_spec.rb",
|
|
||||||
"spec/spec_helper.rb"
|
|
||||||
]
|
|
||||||
|
|
||||||
if s.respond_to? :specification_version then
|
|
||||||
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
|
||||||
s.specification_version = 3
|
|
||||||
|
|
||||||
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
|
||||||
else
|
|
||||||
end
|
|
||||||
else
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
327
internal/homesick/cli/cli.go
Normal file
327
internal/homesick/cli/cli.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||||
|
"github.com/alecthomas/kong"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
model := &cliModel{}
|
||||||
|
|
||||||
|
app, err := core.NewApp(stdin, stdout, stderr)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parser, err := kong.New(
|
||||||
|
model,
|
||||||
|
kong.Name(programName()),
|
||||||
|
kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."),
|
||||||
|
kong.Writers(stdout, stderr),
|
||||||
|
kong.Exit(func(int) {}),
|
||||||
|
kong.ConfigureHelp(kong.HelpOptions{Compact: true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedArgs := normalizeArgs(args)
|
||||||
|
ctx, err := parser.Parse(normalizedArgs)
|
||||||
|
if err != nil {
|
||||||
|
var parseErr *kong.ParseError
|
||||||
|
if errors.As(err, &parseErr) {
|
||||||
|
if parseErr.ExitCode() == 0 || isHelpRequest(normalizedArgs) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
if parseErr.Context != nil {
|
||||||
|
_ = parseErr.Context.PrintUsage(false)
|
||||||
|
}
|
||||||
|
return parseErr.ExitCode()
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Quiet = model.Quiet
|
||||||
|
app.Pretend = model.Pretend || model.DryRun
|
||||||
|
|
||||||
|
if err := ctx.Run(app); err != nil {
|
||||||
|
var exitErr *cliExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", exitErr.err)
|
||||||
|
return exitErr.code
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliModel struct {
|
||||||
|
Pretend bool `help:"Preview actions without executing commands."`
|
||||||
|
DryRun bool `name:"dry-run" help:"Alias for --pretend."`
|
||||||
|
Quiet bool `help:"Suppress status output."`
|
||||||
|
|
||||||
|
Clone cloneCmd `cmd:"" help:"Clone a castle."`
|
||||||
|
List listCmd `cmd:"" help:"List castles."`
|
||||||
|
ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."`
|
||||||
|
Status statusCmd `cmd:"" help:"Show git status for a castle."`
|
||||||
|
Diff diffCmd `cmd:"" help:"Show git diff for a castle."`
|
||||||
|
Link linkCmd `cmd:"" help:"Symlink all dotfiles from a castle."`
|
||||||
|
Unlink unlinkCmd `cmd:"" help:"Unsymlink all dotfiles from a castle."`
|
||||||
|
Track trackCmd `cmd:"" help:"Track a file in a castle."`
|
||||||
|
Version versionCmd `cmd:"" help:"Display the current version."`
|
||||||
|
Pull pullCmd `cmd:"" help:"Pull the specified castle."`
|
||||||
|
Push pushCmd `cmd:"" help:"Push the specified castle."`
|
||||||
|
Commit commitCmd `cmd:"" help:"Commit the specified castle's changes."`
|
||||||
|
Destroy destroyCmd `cmd:"" help:"Destroy a castle."`
|
||||||
|
Cd cdCmd `cmd:"" help:"Print the path to a castle."`
|
||||||
|
Open openCmd `cmd:"" help:"Open a castle."`
|
||||||
|
Exec execCmd `cmd:"" help:"Execute a command in a castle."`
|
||||||
|
ExecAll execAllCmd `cmd:"" name:"exec_all" help:"Execute a command in every castle."`
|
||||||
|
Rc rcCmd `cmd:"" help:"Run rc hooks for a castle."`
|
||||||
|
Generate generateCmd `cmd:"" help:"Generate a castle."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloneCmd struct {
|
||||||
|
URI string `arg:"" name:"URI" help:"Castle URI to clone."`
|
||||||
|
Destination string `arg:"" optional:"" name:"CASTLE_NAME" help:"Optional local castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cloneCmd) Run(app *core.App) error {
|
||||||
|
return app.Clone(c.URI, c.Destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listCmd struct{}
|
||||||
|
|
||||||
|
func (c *listCmd) Run(app *core.App) error {
|
||||||
|
return app.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
type showPathCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *showPathCmd) Run(app *core.App) error {
|
||||||
|
return app.ShowPath(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statusCmd) Run(app *core.App) error {
|
||||||
|
return app.Status(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type diffCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *diffCmd) Run(app *core.App) error {
|
||||||
|
return app.Diff(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *linkCmd) Run(app *core.App) error {
|
||||||
|
return app.LinkCastle(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type unlinkCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *unlinkCmd) Run(app *core.App) error {
|
||||||
|
return app.Unlink(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackCmd struct {
|
||||||
|
File string `arg:"" name:"FILE" help:"File to track."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackCmd) Run(app *core.App) error {
|
||||||
|
return app.Track(c.File, defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type versionCmd struct{}
|
||||||
|
|
||||||
|
func (c *versionCmd) Run(app *core.App) error {
|
||||||
|
return app.Version(version.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pullCmd struct {
|
||||||
|
All bool `help:"Pull all castles."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pushCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commitCmd struct {
|
||||||
|
Message string `short:"m" required:"" name:"MESSAGE" help:"Commit message."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type destroyCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cdCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type execCmd struct {
|
||||||
|
Castle string `arg:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in the castle root."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type execAllCmd struct {
|
||||||
|
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rcCmd struct {
|
||||||
|
Force bool `help:"Bypass legacy .homesickrc safety confirmation."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateCmd struct {
|
||||||
|
Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pullCmd) Run(app *core.App) error {
|
||||||
|
if c.All {
|
||||||
|
if strings.TrimSpace(c.Castle) != "" {
|
||||||
|
return errors.New("pull accepts either --all or CASTLE, not both")
|
||||||
|
}
|
||||||
|
return app.PullAll()
|
||||||
|
}
|
||||||
|
return app.Pull(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) }
|
||||||
|
func (c *commitCmd) Run(app *core.App) error {
|
||||||
|
return app.Commit(defaultCastle(c.Castle), c.Message)
|
||||||
|
}
|
||||||
|
func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) }
|
||||||
|
func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) }
|
||||||
|
func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) }
|
||||||
|
func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) }
|
||||||
|
func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) }
|
||||||
|
func (c *rcCmd) Run(app *core.App) error {
|
||||||
|
return app.Rc(defaultCastle(c.Castle), c.Force)
|
||||||
|
}
|
||||||
|
func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) }
|
||||||
|
|
||||||
|
func defaultCastle(castle string) string {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
return "dotfiles"
|
||||||
|
}
|
||||||
|
return castle
|
||||||
|
}
|
||||||
|
|
||||||
|
func programName() string {
|
||||||
|
if len(os.Args) > 0 {
|
||||||
|
if name := strings.TrimSpace(filepath.Base(os.Args[0])); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "gosick"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeArgs(args []string) []string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"--help"}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, rest := splitLeadingGlobalFlags(args)
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rest[0] {
|
||||||
|
case "-h", "--help":
|
||||||
|
return []string{"--help"}
|
||||||
|
case "help":
|
||||||
|
if len(rest) == 1 {
|
||||||
|
return []string{"--help"}
|
||||||
|
}
|
||||||
|
normalized := append([]string{}, prefix...)
|
||||||
|
normalized = append(normalized, rest[1:]...)
|
||||||
|
return append(normalized, "--help")
|
||||||
|
case "-v", "--version":
|
||||||
|
return []string{"version"}
|
||||||
|
case "symlink":
|
||||||
|
normalized := append([]string{}, prefix...)
|
||||||
|
normalized = append(normalized, "link")
|
||||||
|
return append(normalized, rest[1:]...)
|
||||||
|
case "commit":
|
||||||
|
if len(rest) == 3 && !hasCommitMessageFlag(rest[1:]) {
|
||||||
|
normalized := append([]string{}, prefix...)
|
||||||
|
return append(normalized, "commit", "-m", rest[2], rest[1])
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
default:
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLeadingGlobalFlags(args []string) ([]string, []string) {
|
||||||
|
i := 0
|
||||||
|
for i < len(args) {
|
||||||
|
switch args[i] {
|
||||||
|
case "--pretend", "--dry-run", "--quiet":
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
return args[:i], args[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCommitMessageFlag(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-m" || strings.HasPrefix(arg, "--MESSAGE") || strings.HasPrefix(arg, "--message") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHelpRequest(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliExitError struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cliExitError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
272
internal/homesick/cli/cli_test.go
Normal file
272
internal/homesick/cli/cli_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CLISuite struct {
|
||||||
|
suite.Suite
|
||||||
|
homeDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLISuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CLISuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) SetupTest() {
|
||||||
|
s.homeDir = filepath.Join(s.T().TempDir(), "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.homeDir, 0o755))
|
||||||
|
require.NoError(s.T(), os.Setenv("HOME", s.homeDir))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git://example.com/test.git"}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_VersionAliases() {
|
||||||
|
for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} {
|
||||||
|
s.stdout.Reset()
|
||||||
|
s.stderr.Reset()
|
||||||
|
|
||||||
|
exitCode := cli.Run(args, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), version.String+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
|
||||||
|
exitCode := cli.Run([]string{"show_path"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Cd_DefaultCastle() {
|
||||||
|
exitCode := cli.Run([]string{"cd"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Cd_ExplicitCastle() {
|
||||||
|
exitCode := cli.Run([]string{"cd", "work"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), castleRoot)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_WithPretend_DoesNotExecute() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
target := filepath.Join(castleRoot, "should-not-exist")
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.NoFileExists(s.T(), target)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_WithDryRunAlias_DoesNotExecute() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
target := filepath.Join(castleRoot, "should-not-exist")
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.NoFileExists(s.T(), target)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Empty(s.T(), s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() {
|
||||||
|
exitCode := cli.Run([]string{"pull", "--all"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Rc_HomesickrcRequiresForce() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"rc", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.NotEqual(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stderr.String(), "--force")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Rc_WithForceRuns() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_CloneSubcommandHelp() {
|
||||||
|
exitCode := cli.Run([]string{"clone", "--help"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "clone")
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "URI")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
|
||||||
|
originalArgs := os.Args
|
||||||
|
s.T().Cleanup(func() { os.Args = originalArgs })
|
||||||
|
os.Args = []string{"gosick"}
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--help"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Usage: gosick")
|
||||||
|
require.NotContains(s.T(), s.stdout.String(), "Usage: homesick")
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "precious dotfiles")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"symlink", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
target := filepath.Join(s.homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(target)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() {
|
||||||
|
exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_List_NoArguments() {
|
||||||
|
s.createCastleRepo("dotfiles")
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"list"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "dotfiles")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Generate_CreatesNewCastle() {
|
||||||
|
castlePath := filepath.Join(s.T().TempDir(), "my-castle")
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"generate", castlePath}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
|
||||||
|
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Clone_WithoutArgs() {
|
||||||
|
exitCode := cli.Run([]string{"clone"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
// Clone requires arguments, should fail
|
||||||
|
require.NotEqual(s.T(), 0, exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Status_DefaultCastle() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"status"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "modified:")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Diff_DefaultCastle() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"diff"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "diff --git")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
103
internal/homesick/core/clone_test.go
Normal file
103
internal/homesick/core/clone_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloneSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CloneSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) createBareRemote(name string) string {
|
||||||
|
remotePath := filepath.Join(s.tmpDir, name+".git")
|
||||||
|
_, err := git.PlainInit(remotePath, true)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
workPath := filepath.Join(s.tmpDir, name+"-work")
|
||||||
|
repo, err := git.PlainInit(workPath, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
castleFile := filepath.Join(workPath, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(castleFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(castleFile, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
|
||||||
|
return remotePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) TestClone_FileURLWorks() {
|
||||||
|
remotePath := s.createBareRemote("castle")
|
||||||
|
|
||||||
|
err := s.app.Clone("file://"+remotePath, "parity-castle")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.FileExists(s.T(), filepath.Join(s.reposDir, "parity-castle", "home", ".vimrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) TestClone_DerivesDestinationWhenMissing() {
|
||||||
|
remotePath := s.createBareRemote("dotfiles")
|
||||||
|
|
||||||
|
err := s.app.Clone("file://"+remotePath, "")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.DirExists(s.T(), filepath.Join(s.reposDir, "dotfiles"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) TestClone_LocalPathSymlinksDirectory() {
|
||||||
|
localCastle := filepath.Join(s.tmpDir, "local-castle")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(localCastle, "home"), 0o755))
|
||||||
|
|
||||||
|
err := s.app.Clone(localCastle, "")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
destination := filepath.Join(s.reposDir, "local-castle")
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
}
|
||||||
112
internal/homesick/core/commit_test.go
Normal file
112
internal/homesick/core/commit_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommitSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CommitSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Commit Test",
|
||||||
|
Email: "commit@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutputAt(dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_CreatesCommitWithMessage() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
target := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(target, []byte("set number\nsyntax on\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Commit("dotfiles", "update vimrc"))
|
||||||
|
|
||||||
|
subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), "update vimrc\n", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_MessageEscaping() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
target := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(target, []byte("set number\nset relativenumber\n"), 0o644))
|
||||||
|
|
||||||
|
msg := "fix \"quoted\" message: keep spaces"
|
||||||
|
require.NoError(s.T(), s.app.Commit("dotfiles", msg))
|
||||||
|
|
||||||
|
subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), msg+"\n", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_RequiresMessage() {
|
||||||
|
err := s.app.Commit("dotfiles", " ")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), strings.ToLower(err.Error()), "message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Commit("missing", "msg")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
954
internal/homesick/core/core.go
Normal file
954
internal/homesick/core/core.go
Normal file
@@ -0,0 +1,954 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
HomeDir string
|
||||||
|
ReposDir string
|
||||||
|
Stdin io.Reader
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
Force bool
|
||||||
|
Quiet bool
|
||||||
|
Pretend bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) (*App, error) {
|
||||||
|
if stdin == nil {
|
||||||
|
return nil, errors.New("stdin reader cannot be nil")
|
||||||
|
}
|
||||||
|
if stdout == nil {
|
||||||
|
return nil, errors.New("stdout writer cannot be nil")
|
||||||
|
}
|
||||||
|
if stderr == nil {
|
||||||
|
return nil, errors.New("stderr writer cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &App{
|
||||||
|
HomeDir: home,
|
||||||
|
ReposDir: filepath.Join(home, ".homesick", "repos"),
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Version(version string) error {
|
||||||
|
_, err := fmt.Fprintln(a.Stdout, version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ShowPath(castle string) error {
|
||||||
|
_, err := fmt.Fprintln(a.Stdout, filepath.Join(a.ReposDir, castle))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Clone(uri string, destination string) error {
|
||||||
|
if uri == "" {
|
||||||
|
return errors.New("clone requires URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
if destination == "" {
|
||||||
|
destination = deriveDestination(uri)
|
||||||
|
}
|
||||||
|
if destination == "" {
|
||||||
|
return fmt.Errorf("unable to derive destination from uri %q", uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(a.ReposDir, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create repos directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationPath := filepath.Join(a.ReposDir, destination)
|
||||||
|
|
||||||
|
if fi, err := os.Stat(uri); err == nil && fi.IsDir() {
|
||||||
|
if err := os.Symlink(uri, destinationPath); err != nil {
|
||||||
|
return fmt.Errorf("symlink local castle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := git.PlainClone(destinationPath, false, &git.CloneOptions{
|
||||||
|
URL: uri,
|
||||||
|
RecurseSubmodules: git.DefaultSubmoduleRecursionDepth,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clone %q into %q failed: %w", uri, destinationPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) List() error {
|
||||||
|
if err := os.MkdirAll(a.ReposDir, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("ensure repos directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var castles []string
|
||||||
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if !d.IsDir() || d.Name() != ".git" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Dir(path)
|
||||||
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve castle path %q: %w", castleRoot, err)
|
||||||
|
}
|
||||||
|
castles = append(castles, rel)
|
||||||
|
return filepath.SkipDir
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("scan repos directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(castles)
|
||||||
|
for _, castle := range castles {
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
remote, remoteErr := gitOutput(castleRoot, "config", "remote.origin.url")
|
||||||
|
if remoteErr != nil {
|
||||||
|
remote = ""
|
||||||
|
}
|
||||||
|
_, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote))
|
||||||
|
if writeErr != nil {
|
||||||
|
return fmt.Errorf("write castle listing: %w", writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Status(castle string) error {
|
||||||
|
return a.runGit(filepath.Join(a.ReposDir, castle), "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Diff(castle string) error {
|
||||||
|
return a.runGit(filepath.Join(a.ReposDir, castle), "diff")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Pull(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.runGit(filepath.Join(a.ReposDir, castle), "pull")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PullAll() error {
|
||||||
|
if _, err := os.Stat(a.ReposDir); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var castles []string
|
||||||
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if !d.IsDir() || d.Name() != ".git" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Dir(path)
|
||||||
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
castles = append(castles, rel)
|
||||||
|
return filepath.SkipDir
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(castles)
|
||||||
|
for _, castle := range castles {
|
||||||
|
if !a.Quiet {
|
||||||
|
if _, err := fmt.Fprintf(a.Stdout, "%s:\n", castle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.runGit(filepath.Join(a.ReposDir, castle), "pull"); err != nil {
|
||||||
|
return fmt.Errorf("pull --all failed for %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Push(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.runGit(filepath.Join(a.ReposDir, castle), "push")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Commit(castle string, message string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedMessage := strings.TrimSpace(message)
|
||||||
|
if trimmedMessage == "" {
|
||||||
|
return errors.New("commit requires message")
|
||||||
|
}
|
||||||
|
|
||||||
|
castledir := filepath.Join(a.ReposDir, castle)
|
||||||
|
if err := a.runGit(castledir, "add", "--all"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.runGit(castledir, "commit", "-m", trimmedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Destroy(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
castleInfo, err := os.Lstat(castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("castle %q not found", castle)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Force {
|
||||||
|
confirmed, confirmErr := a.confirmDestroy(castle)
|
||||||
|
if confirmErr != nil {
|
||||||
|
return fmt.Errorf("confirm destroy for %q: %w", castle, confirmErr)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only attempt unlinking managed home files for regular castle directories.
|
||||||
|
if castleInfo.Mode()&os.ModeSymlink == 0 {
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() {
|
||||||
|
if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil {
|
||||||
|
return fmt.Errorf("unlink castle %q before destroy: %w", castle, unlinkErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.RemoveAll(castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) confirmDestroy(castle string) (bool, error) {
|
||||||
|
reader := a.Stdin
|
||||||
|
if reader == nil {
|
||||||
|
reader = os.Stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil {
|
||||||
|
return false, fmt.Errorf("write destroy prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := bufio.NewReader(reader).ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return false, fmt.Errorf("read destroy confirmation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAffirmativeResponse(line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAffirmativeResponse(input string) bool {
|
||||||
|
response := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
return response == "y" || response == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Open(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
||||||
|
if editor == "" {
|
||||||
|
return errors.New("the $EDITOR environment variable must be set to use this command")
|
||||||
|
}
|
||||||
|
|
||||||
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
||||||
|
if info, err := os.Stat(castleHome); err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not open %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
// #nosec G702,G204 -- EDITOR is user-controlled local configuration and command is executed directly without a shell.
|
||||||
|
cmd := exec.Command(editor, ".")
|
||||||
|
cmd.Dir = castleRoot
|
||||||
|
cmd.Stdout = a.Stdout
|
||||||
|
cmd.Stderr = a.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("open failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Exec(castle string, command []string) error {
|
||||||
|
commandString := strings.TrimSpace(strings.Join(command, " "))
|
||||||
|
if commandString == "" {
|
||||||
|
return errors.New("exec requires COMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
if _, err := os.Stat(castleRoot); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("castle %q not found", castle)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sayStatus("exec", fmt.Sprintf("%s command %q in castle %q", a.actionVerb(), commandString, castle))
|
||||||
|
if a.Pretend {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sh", "-c", commandString) // #nosec G204 — intentional shell command execution feature
|
||||||
|
cmd.Dir = castleRoot
|
||||||
|
cmd.Stdout = a.Stdout
|
||||||
|
cmd.Stderr = a.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("exec failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExecAll(command []string) error {
|
||||||
|
commandString := strings.TrimSpace(strings.Join(command, " "))
|
||||||
|
if commandString == "" {
|
||||||
|
return errors.New("exec_all requires COMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(a.ReposDir); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var castles []string
|
||||||
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if !d.IsDir() || d.Name() != ".git" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Dir(path)
|
||||||
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
castles = append(castles, rel)
|
||||||
|
return filepath.SkipDir
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(castles)
|
||||||
|
for _, castle := range castles {
|
||||||
|
if err := a.Exec(castle, []string{commandString}); err != nil {
|
||||||
|
return fmt.Errorf("exec_all failed for %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Generate(castlePath string) error {
|
||||||
|
trimmed := strings.TrimSpace(castlePath)
|
||||||
|
if trimmed == "" {
|
||||||
|
return errors.New("generate requires PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
absCastle, err := filepath.Abs(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve castle path %q: %w", trimmed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(absCastle, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create castle path %q: %w", absCastle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.runGit(absCastle, "init"); err != nil {
|
||||||
|
return fmt.Errorf("initialize git repository %q: %w", absCastle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
githubUser := ""
|
||||||
|
if out, cfgErr := gitOutput(absCastle, "config", "github.user"); cfgErr == nil {
|
||||||
|
githubUser = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if githubUser != "" {
|
||||||
|
repoName := filepath.Base(absCastle)
|
||||||
|
url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName)
|
||||||
|
if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil {
|
||||||
|
return fmt.Errorf("add origin remote for %q: %w", absCastle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(absCastle, "home"), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create home directory for %q: %w", absCastle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Link(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.LinkCastle(castle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LinkCastle(castle string) error {
|
||||||
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
||||||
|
info, err := os.Stat(castleHome)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read subdirs for castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
|
||||||
|
return fmt.Errorf("link castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
base := filepath.Join(castleHome, subdir)
|
||||||
|
if _, err := os.Stat(base); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkEach(castleHome, base, subdirs); err != nil {
|
||||||
|
return fmt.Errorf("link subdir %q for castle %q: %w", subdir, castle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Unlink(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.UnlinkCastle(castle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) UnlinkCastle(castle string) error {
|
||||||
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
||||||
|
info, err := os.Stat(castleHome)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read subdirs for castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
|
||||||
|
return fmt.Errorf("unlink castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
base := filepath.Join(castleHome, subdir)
|
||||||
|
if _, err := os.Stat(base); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
|
||||||
|
return fmt.Errorf("unlink subdir %q for castle %q: %w", subdir, castle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Track(filePath string, castle string) error {
|
||||||
|
return a.TrackPath(filePath, castle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) TrackPath(filePath string, castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedFile := strings.TrimSpace(filePath)
|
||||||
|
if trimmedFile == "" {
|
||||||
|
return errors.New("track requires FILE")
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
info, err := os.Stat(castleHome)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not track %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve tracked file %q: %w", trimmedFile, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(absolutePath); err != nil {
|
||||||
|
return fmt.Errorf("stat tracked file %q: %w", absolutePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve tracked file directory for %q: %w", absolutePath, err)
|
||||||
|
}
|
||||||
|
if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
|
||||||
|
return fmt.Errorf("track requires file under %s", a.HomeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
castleTargetDir := filepath.Join(castleHome, relativeDir)
|
||||||
|
if relativeDir == "." {
|
||||||
|
castleTargetDir = castleHome
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(castleTargetDir, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create tracked file directory %q: %w", castleTargetDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath))
|
||||||
|
if _, err := os.Lstat(trackedPath); err == nil {
|
||||||
|
return fmt.Errorf("%s already exists", trackedPath)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("stat tracked destination %q: %w", trackedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(absolutePath, trackedPath); err != nil {
|
||||||
|
return fmt.Errorf("move tracked file into castle %q: %w", trackedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subdirChanged := false
|
||||||
|
if relativeDir != "." {
|
||||||
|
subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
|
||||||
|
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record tracked subdir %q: %w", relativeDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkPath(trackedPath, absolutePath); err != nil {
|
||||||
|
return fmt.Errorf("relink tracked file %q: %w", absolutePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := git.PlainOpen(castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open git repository for castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
worktree, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open worktree for castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
|
||||||
|
if relativeDir == "." {
|
||||||
|
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
|
||||||
|
}
|
||||||
|
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil {
|
||||||
|
return fmt.Errorf("stage tracked file %q: %w", trackedRelativePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if subdirChanged {
|
||||||
|
if _, err := worktree.Add(".homesick_subdir"); err != nil {
|
||||||
|
return fmt.Errorf("stage subdir metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueSubdir(path string, subdir string) (bool, error) {
|
||||||
|
existing, err := readSubdirs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("load subdir metadata %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanSubdir := filepath.Clean(subdir)
|
||||||
|
for _, line := range existing {
|
||||||
|
if filepath.Clean(line) == cleanSubdir {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 — internal metadata file
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("open subdir metadata %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil {
|
||||||
|
return false, fmt.Errorf("write subdir metadata %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
|
||||||
|
entries, err := os.ReadDir(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read castle directory %q: %w", baseDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
source := filepath.Join(baseDir, name)
|
||||||
|
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check ignored directory %q: %w", source, err)
|
||||||
|
}
|
||||||
|
if ignore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relDir, err := filepath.Rel(castleHome, baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := filepath.Join(a.HomeDir, relDir, name)
|
||||||
|
if relDir == "." {
|
||||||
|
destination = filepath.Join(a.HomeDir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkPath(source, destination); err != nil {
|
||||||
|
return fmt.Errorf("link %q to %q: %w", source, destination, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
|
||||||
|
entries, err := os.ReadDir(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read castle directory %q: %w", baseDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
source := filepath.Join(baseDir, name)
|
||||||
|
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check ignored directory %q: %w", source, err)
|
||||||
|
}
|
||||||
|
if ignore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relDir, err := filepath.Rel(castleHome, baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := filepath.Join(a.HomeDir, relDir, name)
|
||||||
|
if relDir == "." {
|
||||||
|
destination = filepath.Join(a.HomeDir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unlinkPath(destination); err != nil {
|
||||||
|
return fmt.Errorf("unlink %q: %w", destination, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlinkPath(destination string) error {
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) linkPath(source string, destination string) error {
|
||||||
|
absSource, err := filepath.Abs(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve link source %q: %w", source, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create destination parent %q: %w", filepath.Dir(destination), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
if err == nil {
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
target, readErr := os.Readlink(destination)
|
||||||
|
if readErr == nil && target == absSource {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Force {
|
||||||
|
return fmt.Errorf("%s exists", destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rmErr := os.RemoveAll(destination); rmErr != nil {
|
||||||
|
return fmt.Errorf("remove existing destination %q: %w", destination, rmErr)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("stat destination %q: %w", destination, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(absSource, destination); err != nil {
|
||||||
|
return fmt.Errorf("create symlink %q -> %q: %w", destination, absSource, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSubdirs(path string) ([]string, error) {
|
||||||
|
data, err := os.ReadFile(path) // #nosec G304 — internal metadata file
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read subdirs %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
result := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, filepath.Clean(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) {
|
||||||
|
absCandidate, err := filepath.Abs(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("resolve candidate path %q: %w", candidate, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreSet := map[string]struct{}{}
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
clean := filepath.Clean(subdir)
|
||||||
|
for clean != "." && clean != string(filepath.Separator) {
|
||||||
|
ignoreSet[filepath.Join(castleHome, clean)] = struct{}{}
|
||||||
|
next := filepath.Dir(clean)
|
||||||
|
if next == clean {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
clean = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := ignoreSet[absCandidate]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error {
|
||||||
|
// #nosec G204 -- git is fixed binary; args are internal command parameters for expected git operations.
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runGit(dir string, args ...string) error {
|
||||||
|
if a.Pretend {
|
||||||
|
a.sayStatus("git", fmt.Sprintf("%s git %s in %s", a.actionVerb(), strings.Join(args, " "), dir))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return runGitWithIO(dir, a.Stdout, a.Stderr, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) actionVerb() string {
|
||||||
|
if a.Pretend {
|
||||||
|
return "Would execute"
|
||||||
|
}
|
||||||
|
return "Executing"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) sayStatus(action string, message string) {
|
||||||
|
if a.Quiet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(a.Stdout, "%s: %s\n", action, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutput(dir string, args ...string) (string, error) {
|
||||||
|
// #nosec G204 -- git is fixed binary; args are internal read-only git query parameters.
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rc runs the rc hooks for the given castle. It looks for executable files
|
||||||
|
// inside <castle>/.homesick.d and runs them in sorted (lexicographic) order
|
||||||
|
// with the castle root as the working directory, forwarding stdout and stderr
|
||||||
|
// to the App writers.
|
||||||
|
//
|
||||||
|
// If a .homesickrc file exists in the castle root and no parity.rb wrapper
|
||||||
|
// already exists in .homesick.d, a Ruby wrapper script named parity.rb is
|
||||||
|
// written there before execution so that it sorts first.
|
||||||
|
func (a *App) Rc(castle string, force bool) error {
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
if _, err := os.Stat(castleRoot); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("castle %q not found", castle)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat castle %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
|
||||||
|
if _, err := os.Stat(homesickRc); err == nil && !force {
|
||||||
|
return errors.New("refusing to run legacy .homesickrc without --force")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created
|
||||||
|
// (but do not overwrite an existing parity.rb).
|
||||||
|
if _, err := os.Stat(homesickRc); err == nil {
|
||||||
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
||||||
|
if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
if mkErr := os.MkdirAll(homesickD, 0o750); mkErr != nil {
|
||||||
|
return fmt.Errorf("create .homesick.d: %w", mkErr)
|
||||||
|
}
|
||||||
|
wrapperContent := "#!/usr/bin/env ruby\n" +
|
||||||
|
"# parity.rb — generated wrapper for legacy .homesickrc\n" +
|
||||||
|
"# Evaluates .homesickrc in the context of the castle root.\n" +
|
||||||
|
"rc_file = File.join(__dir__, '..', '.homesickrc')\n" +
|
||||||
|
"eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n"
|
||||||
|
if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o600); writeErr != nil {
|
||||||
|
return fmt.Errorf("write parity.rb: %w", writeErr)
|
||||||
|
}
|
||||||
|
// #nosec G302 -- script wrapper must be executable to run properly
|
||||||
|
if chmodErr := os.Chmod(wrapperPath, 0o700); chmodErr != nil {
|
||||||
|
return fmt.Errorf("chmod parity.rb: %w", chmodErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(homesickD); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat rc hooks directory %q: %w", homesickD, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(homesickD)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read rc hooks %q: %w", homesickD, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDir returns entries in sorted order already.
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, infoErr := entry.Info()
|
||||||
|
if infoErr != nil {
|
||||||
|
return fmt.Errorf("read rc hook metadata %q: %w", entry.Name(), infoErr)
|
||||||
|
}
|
||||||
|
if info.Mode()&0o111 == 0 {
|
||||||
|
// Not executable — skip.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(homesickD, entry.Name())
|
||||||
|
cmd := exec.Command(scriptPath) // #nosec G204 — path validated from app-controlled .homesick.d directory
|
||||||
|
cmd.Dir = castleRoot
|
||||||
|
cmd.Stdout = a.Stdout
|
||||||
|
cmd.Stderr = a.Stderr
|
||||||
|
if runErr := cmd.Run(); runErr != nil {
|
||||||
|
return fmt.Errorf("rc script %q failed: %w", entry.Name(), runErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveDestination(uri string) string {
|
||||||
|
candidate := strings.TrimSpace(uri)
|
||||||
|
candidate = strings.TrimPrefix(candidate, "https://github.com/")
|
||||||
|
candidate = strings.TrimPrefix(candidate, "http://github.com/")
|
||||||
|
candidate = strings.TrimPrefix(candidate, "git://github.com/")
|
||||||
|
|
||||||
|
candidate = strings.TrimPrefix(candidate, "file://")
|
||||||
|
|
||||||
|
candidate = strings.TrimSuffix(candidate, ".git")
|
||||||
|
candidate = strings.TrimSuffix(candidate, "/")
|
||||||
|
if candidate == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(candidate, "/")
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if strings.Contains(last, ":") {
|
||||||
|
a := strings.Split(last, ":")
|
||||||
|
last = a[len(a)-1]
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
90
internal/homesick/core/core_test.go
Normal file
90
internal/homesick/core/core_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAppRejectsNilReaders(t *testing.T) {
|
||||||
|
t.Run("nil stdin", func(t *testing.T) {
|
||||||
|
app, err := NewApp(nil, &bytes.Buffer{}, &bytes.Buffer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil stdin")
|
||||||
|
}
|
||||||
|
if app != nil {
|
||||||
|
t.Fatal("expected nil app for nil stdin")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil stdout", func(t *testing.T) {
|
||||||
|
app, err := NewApp(new(bytes.Buffer), nil, &bytes.Buffer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil stdout")
|
||||||
|
}
|
||||||
|
if app != nil {
|
||||||
|
t.Fatal("expected nil app for nil stdout")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil stderr", func(t *testing.T) {
|
||||||
|
app, err := NewApp(new(bytes.Buffer), &bytes.Buffer{}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil stderr")
|
||||||
|
}
|
||||||
|
if app != nil {
|
||||||
|
t.Fatal("expected nil app for nil stderr")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveDestination(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uri string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "github short", uri: "technicalpickles/pickled-vim", want: "pickled-vim"},
|
||||||
|
{name: "https", uri: "https://github.com/technicalpickles/pickled-vim.git", want: "pickled-vim"},
|
||||||
|
{name: "git ssh", uri: "git@github.com:technicalpickles/pickled-vim.git", want: "pickled-vim"},
|
||||||
|
{name: "file", uri: "file:///tmp/dotfiles.git", want: "dotfiles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := deriveDestination(tt.uri); got != tt.want {
|
||||||
|
t.Fatalf("deriveDestination(%q) = %q, want %q", tt.uri, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAppInitializesApp(t *testing.T) {
|
||||||
|
stdin := new(bytes.Buffer)
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
|
||||||
|
app, err := NewApp(stdin, stdout, stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
t.Fatal("expected app instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Stdin != stdin {
|
||||||
|
t.Fatal("expected stdin reader to be assigned")
|
||||||
|
}
|
||||||
|
if app.Stdout != stdout {
|
||||||
|
t.Fatal("expected stdout writer to be assigned")
|
||||||
|
}
|
||||||
|
if app.Stderr != stderr {
|
||||||
|
t.Fatal("expected stderr writer to be assigned")
|
||||||
|
}
|
||||||
|
if app.HomeDir == "" {
|
||||||
|
t.Fatal("expected home directory to be set")
|
||||||
|
}
|
||||||
|
if app.ReposDir != filepath.Join(app.HomeDir, ".homesick", "repos") {
|
||||||
|
t.Fatalf("unexpected repos dir: %q", app.ReposDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/homesick/core/destroy_test.go
Normal file
105
internal/homesick/core/destroy_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DestroySuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestroySuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(DestroySuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdin: strings.NewReader("y\n"),
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
|
||||||
|
_, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_RemovesCastleDirectory() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.DirExists(s.T(), castleRoot)
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("y\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
require.NoDirExists(s.T(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Destroy("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_UnlinksDotfilesBeforeRemoval() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
tracked := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(tracked, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.LinkCastle("dotfiles"))
|
||||||
|
homePath := filepath.Join(s.homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NotZero(s.T(), info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("y\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
|
||||||
|
_, err = os.Lstat(homePath)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.True(s.T(), os.IsNotExist(err))
|
||||||
|
require.NoDirExists(s.T(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_RemovesSymlinkedCastleOnly() {
|
||||||
|
target := filepath.Join(s.tmpDir, "local-castle")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(target, 0o755))
|
||||||
|
|
||||||
|
symlinkCastle := filepath.Join(s.reposDir, "dotfiles")
|
||||||
|
require.NoError(s.T(), os.Symlink(target, symlinkCastle))
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("y\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
require.NoFileExists(s.T(), symlinkCastle)
|
||||||
|
require.DirExists(s.T(), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_DeclineConfirmationKeepsCastle() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.DirExists(s.T(), castleRoot)
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("n\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
require.DirExists(s.T(), castleRoot)
|
||||||
|
}
|
||||||
76
internal/homesick/core/diff_test.go
Normal file
76
internal/homesick/core/diff_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiffSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(DiffSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiffSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiffSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiffSuite) TestDiff_WritesGitDiffToAppStdout() {
|
||||||
|
castleRoot := s.createCastleRepo("castle_repo")
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Diff("castle_repo"))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "diff --git")
|
||||||
|
}
|
||||||
109
internal/homesick/core/exec_test.go
Normal file
109
internal/homesick/core/exec_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ExecSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) createCastle(name string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, name)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_UnknownCastleReturnsError() {
|
||||||
|
err := s.app.Exec("nonexistent", []string{"pwd"})
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_RunsCommandInCastleRoot() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"pwd"}))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_ForwardsStdoutAndStderr() {
|
||||||
|
s.createCastle("dotfiles")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"echo out && echo err >&2"}))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "out")
|
||||||
|
require.Contains(s.T(), s.stderr.String(), "err")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() {
|
||||||
|
zeta := s.createCastle("zeta")
|
||||||
|
alpha := s.createCastle("alpha")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(zeta, ".git"), 0o755))
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(alpha, ".git"), 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""}))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "alpha")
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "zeta")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
target := filepath.Join(castleRoot, "should-not-exist")
|
||||||
|
|
||||||
|
s.app.Pretend = true
|
||||||
|
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"touch should-not-exist"}))
|
||||||
|
require.NoFileExists(s.T(), target)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExecAll_RequiresCommand() {
|
||||||
|
err := s.app.ExecAll(nil)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "exec_all requires COMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExecAll_NoReposDirIsNoop() {
|
||||||
|
missingRepos := filepath.Join(s.T().TempDir(), "missing", "repos")
|
||||||
|
app := &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: missingRepos,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := app.ExecAll([]string{"echo hi"})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
}
|
||||||
78
internal/homesick/core/generate_test.go
Normal file
78
internal/homesick/core/generate_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerateSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(GenerateSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: filepath.Join(s.tmpDir, "home"),
|
||||||
|
ReposDir: filepath.Join(s.tmpDir, "home", ".homesick", "repos"),
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_CreatesGitRepoAndHomeDir() {
|
||||||
|
castlePath := filepath.Join(s.tmpDir, "my-castle")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Generate(castlePath))
|
||||||
|
require.DirExists(s.T(), castlePath)
|
||||||
|
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
|
||||||
|
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_AddsOriginWhenGitHubUserConfigured() {
|
||||||
|
castlePath := filepath.Join(s.tmpDir, "my-castle")
|
||||||
|
gitConfig := filepath.Join(s.tmpDir, "gitconfig")
|
||||||
|
require.NoError(s.T(), os.WriteFile(gitConfig, []byte("[github]\n\tuser = octocat\n"), 0o644))
|
||||||
|
s.T().Setenv("GIT_CONFIG_GLOBAL", gitConfig)
|
||||||
|
s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Generate(castlePath))
|
||||||
|
|
||||||
|
configPath := filepath.Join(castlePath, ".git", "config")
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Contains(s.T(), string(content), "git@github.com:octocat/my-castle.git")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_DoesNotAddOriginWhenGitHubUserMissing() {
|
||||||
|
castlePath := filepath.Join(s.tmpDir, "my-castle")
|
||||||
|
s.T().Setenv("GIT_CONFIG_GLOBAL", filepath.Join(s.tmpDir, "nonexistent-config"))
|
||||||
|
s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Generate(castlePath))
|
||||||
|
|
||||||
|
configPath := filepath.Join(castlePath, ".git", "config")
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NotContains(s.T(), string(content), "[remote \"origin\"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_WrapsCastlePathCreationError() {
|
||||||
|
blocker := filepath.Join(s.tmpDir, "blocker")
|
||||||
|
require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
err := s.app.Generate(filepath.Join(blocker, "castle"))
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "create castle path")
|
||||||
|
}
|
||||||
279
internal/homesick/core/helpers_test.go
Normal file
279
internal/homesick/core/helpers_test.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type errReader struct{}
|
||||||
|
|
||||||
|
func (errReader) Read(_ []byte) (int, error) {
|
||||||
|
return 0, errors.New("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
type errWriter struct{}
|
||||||
|
|
||||||
|
func (errWriter) Write(_ []byte) (int, error) {
|
||||||
|
return 0, errors.New("boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGitPretendWritesStatus(t *testing.T) {
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true}
|
||||||
|
|
||||||
|
err := app.runGit("/tmp", "status")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, stdout.String(), "Would execute git status in /tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionVerb(t *testing.T) {
|
||||||
|
app := &App{Pretend: true}
|
||||||
|
require.Equal(t, "Would execute", app.actionVerb())
|
||||||
|
|
||||||
|
app.Pretend = false
|
||||||
|
require.Equal(t, "Executing", app.actionVerb())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSayStatusHonorsQuiet(t *testing.T) {
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
app := &App{Stdout: stdout, Quiet: true}
|
||||||
|
app.sayStatus("git", "status")
|
||||||
|
require.Empty(t, stdout.String())
|
||||||
|
|
||||||
|
app.Quiet = false
|
||||||
|
app.sayStatus("git", "status")
|
||||||
|
require.Contains(t, stdout.String(), "git: status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlinkPath(t *testing.T) {
|
||||||
|
t.Run("missing destination", func(t *testing.T) {
|
||||||
|
err := unlinkPath(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("regular file is preserved", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "regular")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
err := unlinkPath(target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.FileExists(t, target)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("symlink is removed", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := filepath.Join(dir, "source")
|
||||||
|
destination := filepath.Join(dir, "dest")
|
||||||
|
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(source, destination))
|
||||||
|
|
||||||
|
err := unlinkPath(destination)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, statErr := os.Lstat(destination)
|
||||||
|
require.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkPath(t *testing.T) {
|
||||||
|
t.Run("existing symlink to same source is no-op", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := filepath.Join(dir, "source")
|
||||||
|
destination := filepath.Join(dir, "dest")
|
||||||
|
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
absSource, err := filepath.Abs(source)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Symlink(absSource, destination))
|
||||||
|
|
||||||
|
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||||
|
err = app.linkPath(source, destination)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("conflict without force errors", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := filepath.Join(dir, "source")
|
||||||
|
destination := filepath.Join(dir, "dest")
|
||||||
|
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644))
|
||||||
|
|
||||||
|
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||||
|
err := app.linkPath(source, destination)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "exists")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("force replaces existing destination", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := filepath.Join(dir, "source")
|
||||||
|
destination := filepath.Join(dir, "dest")
|
||||||
|
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644))
|
||||||
|
|
||||||
|
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), Force: true}
|
||||||
|
err := app.linkPath(source, destination)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
info, statErr := os.Lstat(destination)
|
||||||
|
require.NoError(t, statErr)
|
||||||
|
require.True(t, info.Mode()&os.ModeSymlink != 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create destination parent error includes context", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
source := filepath.Join(dir, "source")
|
||||||
|
blocker := filepath.Join(dir, "blocker")
|
||||||
|
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||||
|
err := app.linkPath(source, filepath.Join(blocker, "dest"))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "create destination parent")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
meta := filepath.Join(dir, ".homesick_subdir")
|
||||||
|
require.NoError(t, os.WriteFile(meta, []byte(" .config/myapp \n\n"), 0o644))
|
||||||
|
|
||||||
|
subdirs, err := readSubdirs(meta)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{filepath.Clean(".config/myapp")}, subdirs)
|
||||||
|
|
||||||
|
castleHome := filepath.Join(dir, "castle", "home")
|
||||||
|
candidate := filepath.Join(castleHome, ".config")
|
||||||
|
ignored, err := matchesIgnoredDir(castleHome, candidate, subdirs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ignored)
|
||||||
|
|
||||||
|
notIgnored, err := matchesIgnoredDir(castleHome, filepath.Join(castleHome, ".vim"), subdirs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, notIgnored)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSubdirsReadErrorIncludesContext(t *testing.T) {
|
||||||
|
_, err := readSubdirs(t.TempDir())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "read subdirs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullAndPushDefaultCastlePretend(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
app := &App{
|
||||||
|
HomeDir: dir,
|
||||||
|
ReposDir: filepath.Join(dir, ".homesick", "repos"),
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: bytes.NewBuffer(nil),
|
||||||
|
Pretend: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, app.Pull(""))
|
||||||
|
require.NoError(t, app.Push(""))
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
require.Contains(t, out, "git pull")
|
||||||
|
require.Contains(t, out, "git push")
|
||||||
|
require.Contains(t, out, filepath.Join(app.ReposDir, "dotfiles"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRequiresPath(t *testing.T) {
|
||||||
|
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||||
|
err := app.Generate(" ")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "generate requires PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkAndUnlinkDefaultCastle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
homeDir := filepath.Join(dir, "home")
|
||||||
|
reposDir := filepath.Join(homeDir, ".homesick", "repos")
|
||||||
|
|
||||||
|
castleHome := filepath.Join(reposDir, "dotfiles", "home")
|
||||||
|
require.NoError(t, os.MkdirAll(castleHome, 0o755))
|
||||||
|
source := filepath.Join(castleHome, ".vimrc")
|
||||||
|
require.NoError(t, os.WriteFile(source, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||||
|
require.NoError(t, app.Link(""))
|
||||||
|
|
||||||
|
destination := filepath.Join(homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, info.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
require.NoError(t, app.Unlink(""))
|
||||||
|
_, err = os.Lstat(destination)
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkAndUnlinkCastleMissingError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
app := &App{
|
||||||
|
HomeDir: filepath.Join(dir, "home"),
|
||||||
|
ReposDir: filepath.Join(dir, "home", ".homesick", "repos"),
|
||||||
|
Stdout: bytes.NewBuffer(nil),
|
||||||
|
Stderr: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := app.LinkCastle("missing")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "could not symlink")
|
||||||
|
|
||||||
|
err = app.UnlinkCastle("missing")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "could not symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmDestroyResponses(t *testing.T) {
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
app := &App{Stdout: stdout, Stdin: strings.NewReader("yes\n")}
|
||||||
|
|
||||||
|
ok, err := app.confirmDestroy("dotfiles")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Contains(t, stdout.String(), "Destroy castle \"dotfiles\"?")
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
app.Stdin = strings.NewReader("n\n")
|
||||||
|
ok, err = app.confirmDestroy("dotfiles")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmDestroyReadError(t *testing.T) {
|
||||||
|
app := &App{Stdout: bytes.NewBuffer(nil), Stdin: errReader{}}
|
||||||
|
ok, err := app.confirmDestroy("dotfiles")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Contains(t, err.Error(), "read destroy confirmation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmDestroyWriteError(t *testing.T) {
|
||||||
|
app := &App{Stdout: errWriter{}, Stdin: strings.NewReader("yes\n")}
|
||||||
|
ok, err := app.confirmDestroy("dotfiles")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Contains(t, err.Error(), "write destroy prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecAllWrapsCastleError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
homeDir := filepath.Join(dir, "home")
|
||||||
|
reposDir := filepath.Join(homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(reposDir, "broken", ".git"), 0o755))
|
||||||
|
|
||||||
|
app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
|
||||||
|
err := app.ExecAll([]string{"exit 3"})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "exec_all failed for \"broken\"")
|
||||||
|
}
|
||||||
118
internal/homesick/core/link_test.go
Normal file
118
internal/homesick/core/link_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(LinkSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) createCastle(castle string) string {
|
||||||
|
castleHome := filepath.Join(s.reposDir, castle, "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
|
||||||
|
return castleHome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) writeFile(path string, content string) {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_SymlinksTopLevelFiles() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".vimrc")
|
||||||
|
s.writeFile(dotfile, "set number\n")
|
||||||
|
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
homePath := filepath.Join(s.homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
target, err := os.Readlink(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), dotfile, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_RespectsHomesickSubdir() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
configDir := filepath.Join(castleHome, ".config")
|
||||||
|
appDir := filepath.Join(configDir, "myapp")
|
||||||
|
s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n")
|
||||||
|
s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n")
|
||||||
|
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
configInfo, err := os.Lstat(filepath.Join(s.homeDir, ".config"))
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.False(s.T(), configInfo.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
homeApp := filepath.Join(s.homeDir, ".config", "myapp")
|
||||||
|
appInfo, err := os.Lstat(homeApp)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), appInfo.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
target, err := os.Readlink(homeApp)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), appDir, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_ForceReplacesExistingFile() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".zshrc")
|
||||||
|
s.writeFile(dotfile, "export EDITOR=vim\n")
|
||||||
|
s.writeFile(filepath.Join(s.homeDir, ".zshrc"), "existing\n")
|
||||||
|
|
||||||
|
s.app.Force = true
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
homePath := filepath.Join(s.homeDir, ".zshrc")
|
||||||
|
info, err := os.Lstat(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_NoForceErrorsOnConflict() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".gitconfig")
|
||||||
|
s.writeFile(dotfile, "[user]\n")
|
||||||
|
s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n")
|
||||||
|
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
82
internal/homesick/core/list_test.go
Normal file
82
internal/homesick/core/list_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ListSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) createCastleRepo(castle string, remoteURL string) {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, filepath.FromSlash(castle))
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
if remoteURL != "" {
|
||||||
|
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remoteURL}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() {
|
||||||
|
s.createCastleRepo("zomg", "git://github.com/technicalpickles/zomg.git")
|
||||||
|
s.createCastleRepo("wtf/zomg", "git://github.com/technicalpickles/wtf-zomg.git")
|
||||||
|
s.createCastleRepo("alpha", "git://github.com/technicalpickles/alpha.git")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.List())
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
s.T(),
|
||||||
|
"alpha git://github.com/technicalpickles/alpha.git\n"+
|
||||||
|
"wtf/zomg git://github.com/technicalpickles/wtf-zomg.git\n"+
|
||||||
|
"zomg git://github.com/technicalpickles/zomg.git\n",
|
||||||
|
s.stdout.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) TestList_WrapsReposDirCreationError() {
|
||||||
|
blocker := filepath.Join(s.tmpDir, "repos-blocker")
|
||||||
|
require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644))
|
||||||
|
s.app.ReposDir = filepath.Join(blocker, "repos")
|
||||||
|
|
||||||
|
err := s.app.List()
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "ensure repos directory")
|
||||||
|
}
|
||||||
86
internal/homesick/core/open_test.go
Normal file
86
internal/homesick/core/open_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(OpenSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
|
||||||
|
_, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) TestOpen_RequiresEditorEnv() {
|
||||||
|
s.createCastleRepo("dotfiles")
|
||||||
|
s.T().Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
err := s.app.Open("dotfiles")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "$EDITOR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) TestOpen_MissingCastleReturnsError() {
|
||||||
|
s.T().Setenv("EDITOR", "vim")
|
||||||
|
|
||||||
|
err := s.app.Open("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "could not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) TestOpen_RunsEditorInCastleRoot() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
|
||||||
|
capture := filepath.Join(s.tmpDir, "open_capture.txt")
|
||||||
|
editorScript := filepath.Join(s.tmpDir, "editor.sh")
|
||||||
|
require.NoError(s.T(), os.WriteFile(editorScript, []byte("#!/bin/sh\npwd > \""+capture+"\"\necho \"$1\" >> \""+capture+"\"\n"), 0o755))
|
||||||
|
s.T().Setenv("EDITOR", editorScript)
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Open("dotfiles"))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(capture)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), castleRoot+"\n.\n", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.Writer
|
||||||
156
internal/homesick/core/pull_test.go
Normal file
156
internal/homesick/core/pull_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PullSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(PullSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) createRemoteWithClone(castle string) (string, string) {
|
||||||
|
remotePath := filepath.Join(s.tmpDir, castle+".git")
|
||||||
|
_, err := git.PlainInit(remotePath, true)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedPath := filepath.Join(s.tmpDir, castle+"-seed")
|
||||||
|
seedRepo, err := git.PlainInit(seedPath, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedFile := filepath.Join(seedPath, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := seedRepo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Pull Test",
|
||||||
|
Email: "pull@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
|
||||||
|
clonePath := filepath.Join(s.reposDir, castle)
|
||||||
|
_, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return remotePath, clonePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) addRemoteCommit(remotePath string, castle string) {
|
||||||
|
workPath := filepath.Join(s.tmpDir, castle+"-work")
|
||||||
|
repo, err := git.PlainClone(workPath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(workPath, "home", ".zshrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("export EDITOR=vim\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.zshrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Pull Test",
|
||||||
|
Email: "pull@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPull_UpdatesCastleFromOrigin() {
|
||||||
|
remotePath, clonePath := s.createRemoteWithClone("dotfiles")
|
||||||
|
s.addRemoteCommit(remotePath, "dotfiles")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Pull("dotfiles"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(clonePath, "home", ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPull_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Pull("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_UpdatesAllCastlesFromOrigin() {
|
||||||
|
remoteA, cloneA := s.createRemoteWithClone("alpha")
|
||||||
|
remoteB, cloneB := s.createRemoteWithClone("zeta")
|
||||||
|
s.addRemoteCommit(remoteA, "alpha")
|
||||||
|
s.addRemoteCommit(remoteB, "zeta")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
require.FileExists(s.T(), filepath.Join(cloneA, "home", ".zshrc"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(cloneB, "home", ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_NoCastlesIsNoop() {
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() {
|
||||||
|
_, _ = s.createRemoteWithClone("alpha")
|
||||||
|
_, _ = s.createRemoteWithClone("zeta")
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
s.app.Stdout = stdout
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
require.Contains(s.T(), stdout.String(), "alpha:")
|
||||||
|
require.Contains(s.T(), stdout.String(), "zeta:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_QuietSuppressesCastlePrefixes() {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "alpha", ".git"), 0o755))
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "zeta", ".git"), 0o755))
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
s.app.Stdout = stdout
|
||||||
|
s.app.Quiet = true
|
||||||
|
s.app.Pretend = true
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
require.NotContains(s.T(), stdout.String(), "alpha:")
|
||||||
|
require.NotContains(s.T(), stdout.String(), "zeta:")
|
||||||
|
}
|
||||||
116
internal/homesick/core/push_test.go
Normal file
116
internal/homesick/core/push_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PushSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(PushSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) createRemoteAndClone(castle string) (string, string) {
|
||||||
|
remotePath := filepath.Join(s.tmpDir, castle+".git")
|
||||||
|
_, err := git.PlainInit(remotePath, true)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedPath := filepath.Join(s.tmpDir, castle+"-seed")
|
||||||
|
seedRepo, err := git.PlainInit(seedPath, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedFile := filepath.Join(seedPath, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := seedRepo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Push Test",
|
||||||
|
Email: "push@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
|
||||||
|
clonePath := filepath.Join(s.reposDir, castle)
|
||||||
|
_, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return remotePath, clonePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) createLocalCommit(clonePath string) {
|
||||||
|
repo, err := git.PlainOpen(clonePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
localFile := filepath.Join(clonePath, "home", ".zshrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(localFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(localFile, []byte("export EDITOR=vim\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.zshrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Push Test",
|
||||||
|
Email: "push@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) TestPush_UpdatesRemoteFromLocalChanges() {
|
||||||
|
remotePath, clonePath := s.createRemoteAndClone("dotfiles")
|
||||||
|
s.createLocalCommit(clonePath)
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Push("dotfiles"))
|
||||||
|
|
||||||
|
verifyPath := filepath.Join(s.tmpDir, "dotfiles-verify")
|
||||||
|
_, err := git.PlainClone(verifyPath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.FileExists(s.T(), filepath.Join(verifyPath, "home", ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) TestPush_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Push("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
242
internal/homesick/core/rc_test.go
Normal file
242
internal/homesick/core/rc_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RcSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRcSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(RcSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RcSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RcSuite) createCastle(name string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, name)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.Writer
|
||||||
|
|
||||||
|
// TestRc_UnknownCastleReturnsError ensures Rc returns an error when the
|
||||||
|
// castle directory does not exist.
|
||||||
|
func (s *RcSuite) TestRc_UnknownCastleReturnsError() {
|
||||||
|
err := s.app.Rc("nonexistent", false)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_NoScriptsAndNoHomesickrc is a no-op when neither .homesick.d nor
|
||||||
|
// .homesickrc are present.
|
||||||
|
func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() {
|
||||||
|
s.createCastle("dotfiles")
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run
|
||||||
|
// unless force mode is enabled.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcRequiresForce() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
|
||||||
|
err := s.app.Rc("dotfiles", false)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "--force")
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcRunsWithForce ensures legacy .homesickrc handling proceeds
|
||||||
|
// when force mode is enabled.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcRunsWithForce() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", true))
|
||||||
|
require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside
|
||||||
|
// .homesick.d are run in lexicographic (sorted) order.
|
||||||
|
func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
|
||||||
|
orderFile := filepath.Join(s.tmpDir, "order.txt")
|
||||||
|
scriptA := filepath.Join(homesickD, "10_a.sh")
|
||||||
|
scriptB := filepath.Join(homesickD, "20_b.sh")
|
||||||
|
|
||||||
|
require.NoError(s.T(), os.WriteFile(scriptA, []byte("#!/bin/sh\necho a >> "+orderFile+"\n"), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(scriptB, []byte("#!/bin/sh\necho b >> "+orderFile+"\n"), 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", false))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(orderFile)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), "a\nb\n", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_SkipsNonExecutableFiles ensures that files without the executable bit
|
||||||
|
// are not run.
|
||||||
|
func (s *RcSuite) TestRc_SkipsNonExecutableFiles() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
|
||||||
|
notExec := filepath.Join(homesickD, "10_script.sh")
|
||||||
|
// Write a script that would exit 1 if actually run — verify it is skipped.
|
||||||
|
require.NoError(s.T(), os.WriteFile(notExec, []byte("#!/bin/sh\nexit 1\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes
|
||||||
|
// a Ruby wrapper called parity.rb to be written into .homesick.d before execution.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", true))
|
||||||
|
|
||||||
|
wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb")
|
||||||
|
require.FileExists(s.T(), wrapperPath)
|
||||||
|
|
||||||
|
info, err := os.Stat(wrapperPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NotZero(s.T(), info.Mode()&0o111, "wrapper must be executable")
|
||||||
|
|
||||||
|
content, err := os.ReadFile(wrapperPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Contains(s.T(), string(content), ".homesickrc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcWrapperNotOverwrittenIfExists verifies that an existing
|
||||||
|
// parity.rb is not overwritten when Rc is called again.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
||||||
|
originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n")
|
||||||
|
require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", true))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(wrapperPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), originalContent, content, "existing parity.rb must not be overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcWrapperCreatedBeforeExecution ensures parity.rb is present
|
||||||
|
// in .homesick.d before any scripts in that directory are executed.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
|
||||||
|
// A sentinel script that records whether the wrapper already exists.
|
||||||
|
orderFile := filepath.Join(s.tmpDir, "check.txt")
|
||||||
|
sentinel := filepath.Join(homesickD, "50_check.sh")
|
||||||
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
||||||
|
require.NoError(s.T(), os.WriteFile(sentinel, []byte(
|
||||||
|
"#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n",
|
||||||
|
), 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", true))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(orderFile)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), "present\n", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_FailingScriptReturnsError ensures that a non-zero exit from a script
|
||||||
|
// propagates as an error.
|
||||||
|
func (s *RcSuite) TestRc_FailingScriptReturnsError() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
|
||||||
|
failing := filepath.Join(homesickD, "10_fail.sh")
|
||||||
|
require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755))
|
||||||
|
|
||||||
|
err := s.app.Rc("dotfiles", false)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_ScriptOutputForwarded verifies that stdout and stderr from scripts
|
||||||
|
// are forwarded to the App's writers.
|
||||||
|
func (s *RcSuite) TestRc_ScriptOutputForwarded() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
|
||||||
|
script := filepath.Join(homesickD, "10_output.sh")
|
||||||
|
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\necho hello\necho world >&2\n"), 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", false))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "hello")
|
||||||
|
require.Contains(s.T(), s.stderr.String(), "world")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_ScriptsRunWithCwdSetToCastleRoot verifies scripts execute with the
|
||||||
|
// castle root as the working directory.
|
||||||
|
func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
|
||||||
|
script := filepath.Join(homesickD, "10_pwd.sh")
|
||||||
|
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles", false))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RcSuite) TestRc_ReadHooksErrorIncludesContext() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickD, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
err := s.app.Rc("dotfiles", false)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "read rc hooks")
|
||||||
|
}
|
||||||
51
internal/homesick/core/show_path_test.go
Normal file
51
internal/homesick/core/show_path_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShowPathSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowPathSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ShowPathSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShowPathSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShowPathSuite) TestShowPath_OutputsCastlePath() {
|
||||||
|
require.NoError(s.T(), s.app.ShowPath("castle_repo"))
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
s.T(),
|
||||||
|
filepath.Join(s.reposDir, "castle_repo")+"\n",
|
||||||
|
s.stdout.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
79
internal/homesick/core/status_test.go
Normal file
79
internal/homesick/core/status_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(StatusSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusSuite) TestStatus_WritesGitStatusToAppStdout() {
|
||||||
|
castleRoot := s.createCastleRepo("castle_repo")
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Status("castle_repo"))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "modified:")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.Writer
|
||||||
113
internal/homesick/core/track_test.go
Normal file
113
internal/homesick/core/track_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
// NB: this has nothing to do with jogging
|
||||||
|
func TestTrackSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(TrackSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
|
||||||
|
_, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) writeFile(path string, content string) {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) TestTrack_AfterRelinkTracksFileAndUpdatesSubdir() {
|
||||||
|
castleRoot := s.createCastleRepo("parity-castle")
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
|
||||||
|
s.writeFile(filepath.Join(castleHome, ".vimrc"), "set number\n")
|
||||||
|
s.writeFile(filepath.Join(castleRoot, ".homesick_subdir"), ".config\n")
|
||||||
|
s.writeFile(filepath.Join(castleHome, ".config", "myapp", "config.toml"), "ok=true\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("parity-castle"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("parity-castle"))
|
||||||
|
require.NoError(s.T(), s.app.Link("parity-castle"))
|
||||||
|
|
||||||
|
toolPath := filepath.Join(s.homeDir, ".local", "bin", "tool")
|
||||||
|
s.writeFile(toolPath, "#!/usr/bin/env bash\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Track(toolPath, "parity-castle"))
|
||||||
|
|
||||||
|
expectedTarget := filepath.Join(castleHome, ".local", "bin", "tool")
|
||||||
|
info, err := os.Lstat(toolPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
target, err := os.Readlink(toolPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), expectedTarget, target)
|
||||||
|
|
||||||
|
subdirData, err := os.ReadFile(filepath.Join(castleRoot, ".homesick_subdir"))
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Contains(s.T(), string(subdirData), ".local/bin\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) TestTrack_DefaultCastleName() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
|
||||||
|
filePath := filepath.Join(s.homeDir, ".tmux.conf")
|
||||||
|
s.writeFile(filePath, "set -g mouse on\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Track(filePath, ""))
|
||||||
|
|
||||||
|
expectedTarget := filepath.Join(castleHome, ".tmux.conf")
|
||||||
|
require.FileExists(s.T(), expectedTarget)
|
||||||
|
|
||||||
|
linkTarget, err := os.Readlink(filePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), expectedTarget, linkTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) TestTrack_WrapsSubdirRecordingError() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, ".homesick_subdir"), 0o755))
|
||||||
|
|
||||||
|
filePath := filepath.Join(s.homeDir, ".config", "myapp", "config.toml")
|
||||||
|
s.writeFile(filePath, "ok=true\n")
|
||||||
|
|
||||||
|
err := s.app.Track(filePath, "dotfiles")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "record tracked subdir")
|
||||||
|
}
|
||||||
106
internal/homesick/core/unlink_test.go
Normal file
106
internal/homesick/core/unlink_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UnlinkSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlinkSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(UnlinkSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) createCastle(castle string) string {
|
||||||
|
castleHome := filepath.Join(s.reposDir, castle, "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
|
||||||
|
return castleHome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) writeFile(path string, content string) {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_RemovesTopLevelSymlinks() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".vimrc")
|
||||||
|
s.writeFile(dotfile, "set number\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("glencairn"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".vimrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_RemovesNonDotfileSymlinks() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
binFile := filepath.Join(castleHome, "bin")
|
||||||
|
s.writeFile(binFile, "#!/usr/bin/env bash\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("glencairn"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, "bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_RespectsHomesickSubdir() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
appDir := filepath.Join(castleHome, ".config", "myapp")
|
||||||
|
s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n")
|
||||||
|
s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("glencairn"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
|
||||||
|
require.DirExists(s.T(), filepath.Join(s.homeDir, ".config"))
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".config", "myapp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_DefaultCastleName() {
|
||||||
|
castleHome := s.createCastle("dotfiles")
|
||||||
|
dotfile := filepath.Join(castleHome, ".zshrc")
|
||||||
|
s.writeFile(dotfile, "export EDITOR=vim\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("dotfiles"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink(""))
|
||||||
|
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_IgnoresNonSymlinkDestination() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".gitconfig")
|
||||||
|
s.writeFile(dotfile, "[user]\n")
|
||||||
|
s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(s.homeDir, ".gitconfig"))
|
||||||
|
}
|
||||||
46
internal/homesick/core/version_test.go
Normal file
46
internal/homesick/core/version_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(VersionSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VersionSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VersionSuite) TestVersion_WritesVersionToAppStdout() {
|
||||||
|
require.NoError(s.T(), s.app.Version("1.2.3"))
|
||||||
|
require.Equal(s.T(), "1.2.3\n", s.stdout.String())
|
||||||
|
}
|
||||||
3
internal/homesick/version/version.go
Normal file
3
internal/homesick/version/version.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
const String = "1.1.6"
|
||||||
21
internal/homesick/version/version_test.go
Normal file
21
internal/homesick/version/version_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStringConstant(t *testing.T) {
|
||||||
|
// Test that the version constant is not empty
|
||||||
|
assert.NotEmpty(t, String, "version.String should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringMatchesSemVer(t *testing.T) {
|
||||||
|
// Test that the version string matches semantic versioning pattern (major.minor.patch)
|
||||||
|
semverPattern := `^\d+\.\d+\.\d+$`
|
||||||
|
matched, err := regexp.MatchString(semverPattern, String)
|
||||||
|
assert.NoError(t, err, "regex should be valid")
|
||||||
|
assert.True(t, matched, "version.String should match semantic versioning pattern (major.minor.patch), got: %s", String)
|
||||||
|
}
|
||||||
33
justfile
Normal file
33
justfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
go-build:
|
||||||
|
@mkdir -p dist
|
||||||
|
go build -o dist/gosick ./cmd/homesick
|
||||||
|
|
||||||
|
go-build-linux:
|
||||||
|
@mkdir -p dist
|
||||||
|
GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/gosick ./cmd/homesick
|
||||||
|
|
||||||
|
go-test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
go-mod-hygiene:
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
go-security:
|
||||||
|
gosec ./...
|
||||||
|
govulncheck ./...
|
||||||
|
|
||||||
|
behavior:
|
||||||
|
./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
|
behavior-verbose:
|
||||||
|
./script/run-behavior-suite-docker.sh --verbose
|
||||||
|
|
||||||
|
prepare-release version:
|
||||||
|
@echo "Release preparation is handled by vociferate workflows."
|
||||||
130
lib/homesick.rb
130
lib/homesick.rb
@@ -1,130 +0,0 @@
|
|||||||
require 'thor'
|
|
||||||
|
|
||||||
class Homesick < Thor
|
|
||||||
autoload :Shell, 'homesick/shell'
|
|
||||||
autoload :Actions, 'homesick/actions'
|
|
||||||
|
|
||||||
include Thor::Actions
|
|
||||||
include Homesick::Actions
|
|
||||||
|
|
||||||
add_runtime_options!
|
|
||||||
|
|
||||||
GITHUB_NAME_REPO_PATTERN = /\A([A-Za-z_-]+\/[A-Za-z_-]+)\Z/
|
|
||||||
|
|
||||||
def initialize(args=[], options={}, config={})
|
|
||||||
super
|
|
||||||
self.shell = Homesick::Shell.new
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "clone URI", "Clone +uri+ as a castle for homesick"
|
|
||||||
def clone(uri)
|
|
||||||
inside repos_dir do
|
|
||||||
destination = nil
|
|
||||||
if File.exist?(uri)
|
|
||||||
destination = Pathname.new(uri).basename
|
|
||||||
|
|
||||||
ln_s uri, destination
|
|
||||||
elsif uri =~ GITHUB_NAME_REPO_PATTERN
|
|
||||||
destination = Pathname.new($1)
|
|
||||||
git_clone "git://github.com/#{$1}.git", :destination => destination
|
|
||||||
else
|
|
||||||
if uri =~ /\/([^\/]*).git\Z/
|
|
||||||
destination = Pathname.new($1)
|
|
||||||
end
|
|
||||||
|
|
||||||
git_clone uri
|
|
||||||
end
|
|
||||||
|
|
||||||
if destination.join('.gitmodules').exist?
|
|
||||||
inside destination do
|
|
||||||
git_submodule_init
|
|
||||||
git_submodule_update
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "pull NAME", "Update the specified castle"
|
|
||||||
def pull(name)
|
|
||||||
check_castle_existance(name, "pull")
|
|
||||||
|
|
||||||
inside repos_dir.join(name) do
|
|
||||||
git_pull
|
|
||||||
git_submodule_init
|
|
||||||
git_submodule_update
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "symlink NAME", "Symlinks all dotfiles from the specified castle"
|
|
||||||
def symlink(name)
|
|
||||||
check_castle_existance(name, "symlink")
|
|
||||||
|
|
||||||
inside castle_dir(name) do
|
|
||||||
files = Pathname.glob('.*').reject{|a| [".",".."].include?(a.to_s)}
|
|
||||||
files.each do |path|
|
|
||||||
absolute_path = path.expand_path
|
|
||||||
|
|
||||||
inside home_dir do
|
|
||||||
adjusted_path = (home_dir + path).basename
|
|
||||||
|
|
||||||
ln_s absolute_path, adjusted_path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "list", "List cloned castles"
|
|
||||||
def list
|
|
||||||
#require 'ruby-debug'; breakpoint
|
|
||||||
Pathname.glob("#{repos_dir}/**/*/.git") do |git_dir|
|
|
||||||
castle = git_dir.dirname
|
|
||||||
Dir.chdir castle do # so we can call git config from the right contxt
|
|
||||||
say_status castle.relative_path_from(repos_dir), `git config remote.origin.url`.chomp, :cyan
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc "generate PATH", "generate a homesick-ready git repo at PATH"
|
|
||||||
def generate(castle)
|
|
||||||
castle = Pathname.new(castle).expand_path
|
|
||||||
|
|
||||||
github_user = `git config github.user`.chomp
|
|
||||||
github_user = nil if github_user == ""
|
|
||||||
github_repo = castle.basename
|
|
||||||
|
|
||||||
|
|
||||||
empty_directory castle
|
|
||||||
inside castle do
|
|
||||||
git_init
|
|
||||||
if github_user
|
|
||||||
url = "git@github.com:#{github_user}/#{github_repo}.git"
|
|
||||||
git_remote_add 'origin', url
|
|
||||||
end
|
|
||||||
|
|
||||||
empty_directory "home"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
protected
|
|
||||||
|
|
||||||
def home_dir
|
|
||||||
@home_dir ||= Pathname.new(ENV['HOME'] || '~').expand_path
|
|
||||||
end
|
|
||||||
|
|
||||||
def repos_dir
|
|
||||||
@repos_dir ||= home_dir.join('.homesick', 'repos').expand_path
|
|
||||||
end
|
|
||||||
|
|
||||||
def castle_dir(name)
|
|
||||||
repos_dir.join(name, 'home')
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_castle_existance(name, action)
|
|
||||||
unless castle_dir(name).exist?
|
|
||||||
say_status :error, "Could not #{action} #{name}, expected #{castle_dir(name)} exist and contain dotfiles", :red
|
|
||||||
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
class Homesick
|
|
||||||
module Actions
|
|
||||||
# TODO move this to be more like thor's template, empty_directory, etc
|
|
||||||
def git_clone(repo, config = {})
|
|
||||||
config ||= {}
|
|
||||||
destination = config[:destination] || begin
|
|
||||||
repo =~ /([^\/]+)\.git$/
|
|
||||||
$1
|
|
||||||
end
|
|
||||||
|
|
||||||
destination = Pathname.new(destination) unless destination.kind_of?(Pathname)
|
|
||||||
FileUtils.mkdir_p destination.dirname
|
|
||||||
|
|
||||||
if ! destination.directory?
|
|
||||||
say_status 'git clone', "#{repo} to #{destination.expand_path}", :green unless options[:quiet]
|
|
||||||
system "git clone -q #{repo} #{destination}" unless options[:pretend]
|
|
||||||
else
|
|
||||||
say_status :exist, destination.expand_path, :blue unless options[:quiet]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_init(path = ".")
|
|
||||||
path = Pathname.new(path)
|
|
||||||
|
|
||||||
inside path do
|
|
||||||
unless path.join('.git').exist?
|
|
||||||
say_status 'git init', '' unless options[:quiet]
|
|
||||||
system "git init >/dev/null" unless options[:pretend]
|
|
||||||
else
|
|
||||||
say_status 'git init', 'already initialized', :blue unless options[:quiet]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_remote_add(name, url)
|
|
||||||
existing_remote = `git config remote.#{name}.url`.chomp
|
|
||||||
existing_remote = nil if existing_remote == ''
|
|
||||||
|
|
||||||
unless existing_remote
|
|
||||||
say_status 'git remote', "add #{name} #{url}" unless options[:quiet]
|
|
||||||
system "git remote add #{name} #{url}" unless options[:pretend]
|
|
||||||
else
|
|
||||||
say_status 'git remote', "#{name} already exists", :blue unless options[:quiet]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_submodule_init(config = {})
|
|
||||||
say_status 'git submodule', 'init', :green unless options[:quiet]
|
|
||||||
system "git submodule --quiet init" unless options[:pretend]
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_submodule_update(config = {})
|
|
||||||
say_status 'git submodule', 'update', :green unless options[:quiet]
|
|
||||||
system "git submodule --quiet update >/dev/null 2>&1" unless options[:pretend]
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_pull(config = {})
|
|
||||||
say_status 'git pull', '', :green unless options[:quiet]
|
|
||||||
system "git pull --quiet" unless options[:pretend]
|
|
||||||
end
|
|
||||||
|
|
||||||
def ln_s(source, destination, config = {})
|
|
||||||
source = Pathname.new(source)
|
|
||||||
destination = Pathname.new(destination)
|
|
||||||
|
|
||||||
if destination.symlink?
|
|
||||||
if destination.readlink == source
|
|
||||||
say_status :identical, destination.expand_path, :blue unless options[:quiet]
|
|
||||||
else
|
|
||||||
say_status :conflict, "#{destination} exists and points to #{destination.readlink}", :red unless options[:quiet]
|
|
||||||
|
|
||||||
if shell.file_collision(destination) { source }
|
|
||||||
system "ln -sf #{source} #{destination}" unless options[:pretend]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elsif destination.exist?
|
|
||||||
say_status :conflict, "#{destination} exists", :red unless options[:quiet]
|
|
||||||
|
|
||||||
if shell.file_collision(destination) { source }
|
|
||||||
system "ln -sf #{source} #{destination}" unless options[:pretend]
|
|
||||||
end
|
|
||||||
else
|
|
||||||
say_status :symlink, "#{source.expand_path} to #{destination.expand_path}", :green unless options[:quiet]
|
|
||||||
system "ln -s #{source} #{destination}" unless options[:pretend]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
class Homesick
|
|
||||||
# Hack in support for diffing symlinks
|
|
||||||
class Shell < Thor::Shell::Color
|
|
||||||
|
|
||||||
def show_diff(destination, content)
|
|
||||||
destination = Pathname.new(destination)
|
|
||||||
|
|
||||||
if destination.symlink?
|
|
||||||
say "- #{destination.readlink}", :red, true
|
|
||||||
say "+ #{content.expand_path}", :green, true
|
|
||||||
else
|
|
||||||
super
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
end
|
|
||||||
57
script/run-behavior-suite-docker.sh
Executable file
57
script/run-behavior-suite-docker.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${HOMESICK_CMD:=/workspace/dist/gosick}"
|
||||||
|
behavior_verbose="${BEHAVIOR_VERBOSE:-0}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-v|--verbose)
|
||||||
|
echo "Enabling verbose output for behavior suite"
|
||||||
|
behavior_verbose=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
echo "Usage: $0 [--verbose]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
|
||||||
|
run_docker_build() {
|
||||||
|
echo "Building Docker image for behavior suite..."
|
||||||
|
local build_log
|
||||||
|
local -a build_cmd
|
||||||
|
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
build_cmd=(docker buildx build --load -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root")
|
||||||
|
else
|
||||||
|
build_cmd=(docker build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$behavior_verbose" == "1" ]]; then
|
||||||
|
"${build_cmd[@]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
build_log="$(mktemp)"
|
||||||
|
if ! "${build_cmd[@]}" >"$build_log" 2>&1; then
|
||||||
|
cat "$build_log" >&2
|
||||||
|
rm -f "$build_log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$build_log"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_docker_build
|
||||||
|
|
||||||
|
echo "Running behavior suite in Docker container..."
|
||||||
|
docker run --rm \
|
||||||
|
-e HOMESICK_CMD="$HOMESICK_CMD" \
|
||||||
|
-e BEHAVIOR_VERBOSE="$behavior_verbose" \
|
||||||
|
homesick-behavior:latest
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
require 'spec_helper'
|
|
||||||
|
|
||||||
describe Homesick do
|
|
||||||
before do
|
|
||||||
@homesick = Homesick.new
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "clone" do
|
|
||||||
it "should symlink existing directories" do
|
|
||||||
somewhere = create_construct
|
|
||||||
somewhere.directory('wtf')
|
|
||||||
wtf = somewhere + 'wtf'
|
|
||||||
|
|
||||||
@homesick.should_receive(:ln_s).with(wtf.to_s, wtf.basename)
|
|
||||||
|
|
||||||
@homesick.clone wtf.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should clone git repo like git://host/path/to.git" do
|
|
||||||
@homesick.should_receive(:git_clone).with('git://github.com/technicalpickles/pickled-vim.git')
|
|
||||||
|
|
||||||
@homesick.clone "git://github.com/technicalpickles/pickled-vim.git"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should clone git repo like git@host:path/to.git" do
|
|
||||||
@homesick.should_receive(:git_clone).with('git@github.com:technicalpickles/pickled-vim.git')
|
|
||||||
|
|
||||||
@homesick.clone 'git@github.com:technicalpickles/pickled-vim.git'
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should clone git repo like http://host/path/to.git" do
|
|
||||||
@homesick.should_receive(:git_clone).with('http://github.com/technicalpickles/pickled-vim.git')
|
|
||||||
|
|
||||||
@homesick.clone 'http://github.com/technicalpickles/pickled-vim.git'
|
|
||||||
end
|
|
||||||
|
|
||||||
it "should clone a github repo" do
|
|
||||||
@homesick.should_receive(:git_clone).with('git://github.com/wfarr/dotfiles.git', :destination => Pathname.new('wfarr/dotfiles'))
|
|
||||||
|
|
||||||
@homesick.clone "wfarr/dotfiles"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "list" do
|
|
||||||
|
|
||||||
# FIXME only passes in isolation. need to setup data a bit better
|
|
||||||
xit "should say each castle in the castle directory" do
|
|
||||||
@user_dir.directory '.homesick/repos' do |repos_dir|
|
|
||||||
repos_dir.directory 'zomg' do |zomg|
|
|
||||||
Dir.chdir do
|
|
||||||
system "git init >/dev/null 2>&1"
|
|
||||||
system "git remote add origin git://github.com/technicalpickles/zomg.git >/dev/null 2>&1"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
repos_dir.directory 'wtf/zomg' do |zomg|
|
|
||||||
Dir.chdir do
|
|
||||||
system "git init >/dev/null 2>&1"
|
|
||||||
system "git remote add origin git://github.com/technicalpickles/zomg.git >/dev/null 2>&1"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@homesick.should_receive(:say_status).with("zomg", "git://github.com/technicalpickles/zomg.git", :cyan)
|
|
||||||
@homesick.should_receive(:say_status).with("wtf/zomg", "git://github.com/technicalpickles/zomg.git", :cyan)
|
|
||||||
|
|
||||||
@homesick.list
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
--color
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
|
||||||
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
|
||||||
require 'homesick'
|
|
||||||
require 'spec'
|
|
||||||
require 'spec/autorun'
|
|
||||||
require 'construct'
|
|
||||||
|
|
||||||
Spec::Runner.configure do |config|
|
|
||||||
config.include Construct::Helpers
|
|
||||||
|
|
||||||
config.before do
|
|
||||||
@user_dir = create_construct
|
|
||||||
ENV['HOME'] = @user_dir.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
config.after do
|
|
||||||
@user_dir.destroy!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
328
test/behavior/behavior_suite.sh
Executable file
328
test/behavior/behavior_suite.sh
Executable file
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}"
|
||||||
|
: "${BEHAVIOR_VERBOSE:=0}"
|
||||||
|
|
||||||
|
RUN_OUTPUT=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
printf ' \033[32mPassed\033[0m\n'
|
||||||
|
else
|
||||||
|
echo " Passed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-v|--verbose)
|
||||||
|
BEHAVIOR_VERBOSE=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_git() {
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" ]]; then
|
||||||
|
git "$@"
|
||||||
|
else
|
||||||
|
git "$@" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_path_exists() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -e "$path" ]] || fail "expected path to exist: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_path_missing() {
|
||||||
|
local path="$1"
|
||||||
|
[[ ! -e "$path" ]] || fail "expected path to be missing: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_symlink_target() {
|
||||||
|
local link_path="$1"
|
||||||
|
local expected_target="$2"
|
||||||
|
[[ -L "$link_path" ]] || fail "expected symlink: $link_path"
|
||||||
|
local actual_target
|
||||||
|
actual_target="$(readlink "$link_path")"
|
||||||
|
[[ "$actual_target" == "$expected_target" ]] || fail "expected symlink target '$expected_target' but got '$actual_target'"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_homesick() {
|
||||||
|
local out_file
|
||||||
|
local output
|
||||||
|
out_file="$(mktemp)"
|
||||||
|
if ! bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then
|
||||||
|
cat "$out_file" >&2
|
||||||
|
rm -f "$out_file"
|
||||||
|
fail "homesick command failed: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="$(cat "$out_file")"
|
||||||
|
RUN_OUTPUT="$output"
|
||||||
|
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_homesick_with_stdin() {
|
||||||
|
local stdin_data="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local out_file
|
||||||
|
local output
|
||||||
|
out_file="$(mktemp)"
|
||||||
|
if ! printf '%b' "$stdin_data" | bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then
|
||||||
|
cat "$out_file" >&2
|
||||||
|
rm -f "$out_file"
|
||||||
|
fail "homesick command failed: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="$(cat "$out_file")"
|
||||||
|
RUN_OUTPUT="$output"
|
||||||
|
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_homesick_with_env() {
|
||||||
|
local env_prefix="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local out_file
|
||||||
|
local output
|
||||||
|
out_file="$(mktemp)"
|
||||||
|
if ! bash -lc "$env_prefix $HOMESICK_CMD $*" >"$out_file" 2>&1; then
|
||||||
|
cat "$out_file" >&2
|
||||||
|
rm -f "$out_file"
|
||||||
|
fail "homesick command failed: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="$(cat "$out_file")"
|
||||||
|
RUN_OUTPUT="$output"
|
||||||
|
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_remote_castle() {
|
||||||
|
local remote_dir="$1"
|
||||||
|
local work_dir="$2"
|
||||||
|
|
||||||
|
mkdir -p "$remote_dir"
|
||||||
|
run_git init --bare "$remote_dir/base.git"
|
||||||
|
|
||||||
|
mkdir -p "$work_dir/base"
|
||||||
|
pushd "$work_dir/base" >/dev/null
|
||||||
|
run_git init
|
||||||
|
run_git config user.email "behavior@test.local"
|
||||||
|
run_git config user.name "Behavior Test"
|
||||||
|
|
||||||
|
mkdir -p home/.config/myapp
|
||||||
|
echo "set number" > home/.vimrc
|
||||||
|
echo "export PATH=\"$PATH:$HOME/bin\"" > home/.zshrc
|
||||||
|
echo "option=true" > home/.config/myapp/config.toml
|
||||||
|
printf '.config\n' > .homesick_subdir
|
||||||
|
|
||||||
|
run_git add .
|
||||||
|
run_git commit -m "initial castle"
|
||||||
|
run_git remote add origin "$remote_dir/base.git"
|
||||||
|
run_git push -u origin master
|
||||||
|
popd >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_local_test_file() {
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
echo "#!/usr/bin/env bash" > "$HOME/.local/bin/tool"
|
||||||
|
chmod +x "$HOME/.local/bin/tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_suite() {
|
||||||
|
local tmp_root
|
||||||
|
tmp_root="$(mktemp -d)"
|
||||||
|
trap "rm -rf '$tmp_root'" EXIT
|
||||||
|
|
||||||
|
export HOME="$tmp_root/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
local remote_root="$tmp_root/remote"
|
||||||
|
local work_root="$tmp_root/work"
|
||||||
|
|
||||||
|
setup_remote_castle "$remote_root" "$work_root"
|
||||||
|
|
||||||
|
echo "[1/18] help"
|
||||||
|
run_homesick "help"
|
||||||
|
[[ "$RUN_OUTPUT" == *"Usage:"* || "$RUN_OUTPUT" == *"Commands:"* ]] || fail "expected help output to include command usage information"
|
||||||
|
run_homesick "help clone"
|
||||||
|
[[ "$RUN_OUTPUT" == *"clone"* ]] || fail "expected command help output for clone"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[2/18] clone"
|
||||||
|
run_homesick "clone file://$remote_root/base.git parity-castle"
|
||||||
|
run_homesick "clone file://$remote_root/base.git parity-castle-2"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle-2/.git"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle" config user.email "behavior@test.local"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle" config user.name "Behavior Test"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.email "behavior@test.local"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.name "Behavior Test"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[3/18] link"
|
||||||
|
run_homesick "link parity-castle"
|
||||||
|
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc"
|
||||||
|
assert_path_exists "$HOME/.config/myapp"
|
||||||
|
assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[4/18] unlink"
|
||||||
|
run_homesick "unlink parity-castle"
|
||||||
|
assert_path_missing "$HOME/.vimrc"
|
||||||
|
assert_path_missing "$HOME/.zshrc"
|
||||||
|
assert_path_exists "$HOME/.config"
|
||||||
|
assert_path_missing "$HOME/.config/myapp"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[5/18] symlink alias"
|
||||||
|
run_homesick "symlink parity-castle"
|
||||||
|
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[6/18] relink + track"
|
||||||
|
run_homesick "link parity-castle"
|
||||||
|
setup_local_test_file
|
||||||
|
run_homesick "track $HOME/.local/bin/tool parity-castle"
|
||||||
|
assert_symlink_target "$HOME/.local/bin/tool" "$HOME/.homesick/repos/parity-castle/home/.local/bin/tool"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/.homesick_subdir"
|
||||||
|
grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[7/18] list and show_path"
|
||||||
|
local list_output
|
||||||
|
run_homesick "list"
|
||||||
|
list_output="$RUN_OUTPUT"
|
||||||
|
[[ "$list_output" == *"parity-castle"* ]] || fail "expected list output to include parity-castle"
|
||||||
|
local show_path_output
|
||||||
|
run_homesick "show_path parity-castle"
|
||||||
|
show_path_output="$RUN_OUTPUT"
|
||||||
|
[[ "$show_path_output" == "$HOME/.homesick/repos/parity-castle" ]] || fail "expected show_path output to equal parity-castle root path"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[8/18] status and diff"
|
||||||
|
echo "change" >> "$HOME/.vimrc"
|
||||||
|
local status_output
|
||||||
|
run_homesick "status parity-castle"
|
||||||
|
status_output="$RUN_OUTPUT"
|
||||||
|
[[ "$status_output" == *"modified:"* ]] || fail "expected status output to include modified file"
|
||||||
|
local diff_output
|
||||||
|
run_homesick "diff parity-castle"
|
||||||
|
diff_output="$RUN_OUTPUT"
|
||||||
|
[[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[9/18] pull --all"
|
||||||
|
local pull_all_output
|
||||||
|
run_homesick "pull --all"
|
||||||
|
pull_all_output="$RUN_OUTPUT"
|
||||||
|
[[ "$pull_all_output" == *"parity-castle:"* ]] || fail "expected pull --all output to include parity-castle"
|
||||||
|
[[ "$pull_all_output" == *"parity-castle-2:"* ]] || fail "expected pull --all output to include parity-castle-2"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[10/18] single-castle pull"
|
||||||
|
pushd "$work_root/base" >/dev/null
|
||||||
|
echo "single-castle-pull" > home/.pull-single
|
||||||
|
run_git add .
|
||||||
|
run_git commit -m "single-castle pull fixture"
|
||||||
|
run_git push
|
||||||
|
popd >/dev/null
|
||||||
|
run_homesick "pull parity-castle"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.pull-single"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[11/18] exec"
|
||||||
|
local exec_marker="$HOME/.homesick/repos/parity-castle/.exec-marker"
|
||||||
|
run_homesick "exec parity-castle touch .exec-marker"
|
||||||
|
assert_path_exists "$exec_marker"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[12/18] exec_all"
|
||||||
|
local exec_all_marker_a="$HOME/.homesick/repos/parity-castle/.exec-all-marker"
|
||||||
|
local exec_all_marker_b="$HOME/.homesick/repos/parity-castle-2/.exec-all-marker"
|
||||||
|
run_homesick "exec_all touch .exec-all-marker"
|
||||||
|
assert_path_exists "$exec_all_marker_a"
|
||||||
|
assert_path_exists "$exec_all_marker_b"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[13/18] generate"
|
||||||
|
local generated_castle="$HOME/generated-castle"
|
||||||
|
run_homesick "generate $generated_castle"
|
||||||
|
assert_path_exists "$generated_castle/.git"
|
||||||
|
assert_path_exists "$generated_castle/home"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[14/18] commit and push"
|
||||||
|
echo "commit-change" >> "$HOME/.zshrc"
|
||||||
|
run_homesick "commit parity-castle behavior-suite-commit"
|
||||||
|
run_homesick "push parity-castle"
|
||||||
|
local remote_head
|
||||||
|
remote_head="$(git --git-dir "$remote_root/base.git" log --oneline -1)"
|
||||||
|
[[ "$remote_head" == *"behavior-suite-commit"* ]] || fail "expected pushed commit in remote history"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[15/18] open"
|
||||||
|
run_homesick_with_env "EDITOR=true" "open parity-castle"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[16/18] cd"
|
||||||
|
run_homesick_with_env "SHELL=/bin/true" "cd parity-castle"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[17/18] rc --force"
|
||||||
|
local rc_marker="$HOME/rc-force-was-here"
|
||||||
|
cat > "$HOME/.homesick/repos/parity-castle/.homesickrc" <<EOF
|
||||||
|
File.write('$rc_marker', 'ok\n')
|
||||||
|
EOF
|
||||||
|
run_homesick "rc --force parity-castle"
|
||||||
|
assert_path_exists "$rc_marker"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[18/18] destroy confirmation + version"
|
||||||
|
run_homesick_with_stdin "n\n" "destroy parity-castle"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle"
|
||||||
|
run_homesick_with_stdin "y\n" "destroy parity-castle"
|
||||||
|
assert_path_missing "$HOME/.homesick/repos/parity-castle"
|
||||||
|
assert_path_missing "$HOME/.vimrc"
|
||||||
|
local version_output
|
||||||
|
run_homesick "version"
|
||||||
|
version_output="$RUN_OUTPUT"
|
||||||
|
[[ "$version_output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "expected semantic version output, got: $version_output"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "PASS: behavior suite completed for command: $HOMESICK_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
run_suite
|
||||||
Reference in New Issue
Block a user