From 5e5d4d86251545305076b6d6f56b3d48f2f37f84 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 11 Aug 2026 18:32:01 +1000 Subject: [PATCH 1/2] [patch] Sync to ktsu.Sdk 2.21.1 Adopt the SDK's synced style configuration and the metadata it derives from it. The .gitattributes rewrite normalizes the repository to LF and routes *.png through the Git LFS clean filter, so icon.png is now stored as a pointer rather than 16 KB of raw bytes. Add the direct package references the KTSU0006 transitive-package analyzer requires for types already in use. --- .editorconfig | 13 ++++-- .gitattributes | 22 ++++++--- .runsettings | 21 +-------- Directory.Packages.props | 53 ++-------------------- ProjectDirector/AzureDevOpsRepository.cs | 4 +- ProjectDirector/DictionaryOfHashSets.cs | 4 +- ProjectDirector/GitHubRepository.cs | 4 +- ProjectDirector/GitRepository.cs | 4 +- ProjectDirector/PopupPropagateFile.cs | 4 +- ProjectDirector/ProjectDirector.cs | 6 +-- ProjectDirector/ProjectDirector.csproj | 3 ++ ProjectDirector/ProjectDirectorOptions.cs | 4 +- global.json | 16 +++---- icon.png | Bin 16069 -> 130 bytes 14 files changed, 50 insertions(+), 108 deletions(-) diff --git a/.editorconfig b/.editorconfig index c04a279..2cd5f5b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,9 @@ charset = utf-8 indent_style = space indent_size = 4 tab_width = 4 -end_of_line = crlf +# LF on every platform, matching the `* text=auto eol=lf` default in .gitattributes. +# Overrides below must stay in step with the eol pins in that file. +end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true @@ -17,7 +19,7 @@ trim_trailing_whitespace = true [*.{cs,csx,cake,fs,fsx,vb,vbx}] indent_style = tab -file_header_template = Copyright (c) ktsu.dev\nAll rights reserved.\nLicensed under the MIT license. +file_header_template = Copyright (c) 2023-2026 ktsu-dev contributors # Default severity for all .NET Code Style rules dotnet_analyzer_diagnostic.severity = error @@ -503,4 +505,9 @@ indent_style = tab # Shell scripts [*.sh] end_of_line = lf -indent_size = 2 \ No newline at end of file +indent_size = 2 + +# Windows batch scripts and Visual Studio solution files keep CRLF on every platform. +# These match the eol=crlf pins in .gitattributes. +[*.{cmd,bat,sln}] +end_of_line = crlf \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index b272e2b..a0bea35 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,17 +4,25 @@ # Git Line Endings # ############################### -# Set default behaviour to automatically normalize line endings. -* text=auto +# Normalize all text files to LF in the repository, and check them out as LF on every +# platform. The explicit eol overrides each machine's core.autocrlf, so the working tree +# is byte-identical on Windows, Linux and macOS. This must stay in step with the +# end_of_line settings in .editorconfig. +* text=auto eol=lf + +# Force bash scripts to always use LF line endings so that if a repo is accessed +# in Unix via a file share from Windows, the scripts will work. Redundant with the +# default above, kept explicit because these files break outright with CRLF. +*.sh text eol=lf # Force batch scripts to always use CRLF line endings so that if a repo is accessed # in Windows via a file share from Linux, the scripts will work. -*.{cmd,[cC][mM][dD]} text eol=crlf -*.{bat,[bB][aA][tT]} text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf -# Force bash scripts to always use LF line endings so that if a repo is accessed -# in Unix via a file share from Windows, the scripts will work. -*.sh text eol=lf +# Visual Studio rewrites solution files with CRLF regardless of the checkout, so pin +# them to avoid a spurious whole-file diff every time the solution is opened. +*.sln text eol=crlf ############################### # Git Large File System (LFS) # diff --git a/.runsettings b/.runsettings index 3c3169d..9bd9f05 100644 --- a/.runsettings +++ b/.runsettings @@ -1,25 +1,6 @@ - - .\coverage + TestResults - - - .\coverage - - - - - - - - opencover - coverage.opencover.xml - [*Test*]*,[*Tests*]* - **/obj/**/* - - - - diff --git a/Directory.Packages.props b/Directory.Packages.props index 60ac2e3..92ae7bc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,65 +3,22 @@ true - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + diff --git a/ProjectDirector/AzureDevOpsRepository.cs b/ProjectDirector/AzureDevOpsRepository.cs index e4a1731..c05d23f 100644 --- a/ProjectDirector/AzureDevOpsRepository.cs +++ b/ProjectDirector/AzureDevOpsRepository.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; diff --git a/ProjectDirector/DictionaryOfHashSets.cs b/ProjectDirector/DictionaryOfHashSets.cs index d4336d8..f0911db 100644 --- a/ProjectDirector/DictionaryOfHashSets.cs +++ b/ProjectDirector/DictionaryOfHashSets.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; diff --git a/ProjectDirector/GitHubRepository.cs b/ProjectDirector/GitHubRepository.cs index e590bdd..1a77a11 100644 --- a/ProjectDirector/GitHubRepository.cs +++ b/ProjectDirector/GitHubRepository.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; diff --git a/ProjectDirector/GitRepository.cs b/ProjectDirector/GitRepository.cs index d77e9a9..6339ae5 100644 --- a/ProjectDirector/GitRepository.cs +++ b/ProjectDirector/GitRepository.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; diff --git a/ProjectDirector/PopupPropagateFile.cs b/ProjectDirector/PopupPropagateFile.cs index 8be2d97..4ef6530 100644 --- a/ProjectDirector/PopupPropagateFile.cs +++ b/ProjectDirector/PopupPropagateFile.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 0326daa..02574b6 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; @@ -539,7 +537,7 @@ private void ShowRepos(GitHubOwnerName owner) if (gitHubRepo.OwnerName == owner) { bool isCloned = Options.ClonedRepos.ContainsKey(repo.LocalPath); - ImGuiWidgets.ColorIndicator(Color.Palette.Basic.Green, isCloned); + ImGuiWidgets.ColorIndicator(Palette.Basic.Green, isCloned); ImGui.SameLine(); bool isSelected = Options.BaseRepo == repoName; if (ImGui.Selectable(gitHubRepo.RepoName, ref isSelected)) diff --git a/ProjectDirector/ProjectDirector.csproj b/ProjectDirector/ProjectDirector.csproj index 29e681b..4fa2275 100644 --- a/ProjectDirector/ProjectDirector.csproj +++ b/ProjectDirector/ProjectDirector.csproj @@ -18,6 +18,9 @@ + + + diff --git a/ProjectDirector/ProjectDirectorOptions.cs b/ProjectDirector/ProjectDirectorOptions.cs index d0eed23..b624423 100644 --- a/ProjectDirector/ProjectDirectorOptions.cs +++ b/ProjectDirector/ProjectDirectorOptions.cs @@ -1,6 +1,4 @@ -// Copyright (c) ktsu.dev -// All rights reserved. -// Licensed under the MIT license. +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.ProjectDirector; diff --git a/global.json b/global.json index d661e7f..a6c6219 100644 --- a/global.json +++ b/global.json @@ -5,14 +5,14 @@ }, "msbuild-sdks": { "MSTest.Sdk": "4.3.3", - "ktsu.Sdk": "2.18.0", - "ktsu.Sdk.ConsoleApp": "2.18.0", - "ktsu.Sdk.App": "2.18.0", - "ktsu.Sdk.Windows": "2.18.0", - "ktsu.Sdk.Linux": "2.18.0", - "ktsu.Sdk.macOS": "2.18.0", - "ktsu.Sdk.iOS": "2.18.0", - "ktsu.Sdk.Android": "2.18.0" + "ktsu.Sdk": "2.21.1", + "ktsu.Sdk.ConsoleApp": "2.21.1", + "ktsu.Sdk.App": "2.21.1", + "ktsu.Sdk.Windows": "2.21.1", + "ktsu.Sdk.Linux": "2.21.1", + "ktsu.Sdk.macOS": "2.21.1", + "ktsu.Sdk.iOS": "2.21.1", + "ktsu.Sdk.Android": "2.21.1" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/icon.png b/icon.png index 91246f6de58218048776e760c1d1a347d5bac3a3..4372ef9bedd8e408d12e1681f6e13cf5dde54a87 100644 GIT binary patch literal 130 zcmWN?K@!3s3;@78uiyg~6KJ6Q4S^ucsC0z(;OliSd&y_C{?>KQW9-H}+Ppo>SpL_O zm(*Wo9Foihs<*61jkwaDoHz$IWU(AQ6`vT<=D6A{#3EKa@|Ft*U`YXx12N`E1!64L MtI@uqf$p@a9~!qPuK)l5 literal 16069 zcmbWe2Ut_z6Cg?xY0{Cr*MAuuz(Zy9W*vHW@SkDL%?1qqb z04XW}5D*9yC?zdwCut`Mla>?)LLpEIF$hdd94Z2ll97PRKw-dtzd-ord>ou)3{=(s z-5CCtJjlh*&s#=JEHE%oG!Q20<>M?SE-fuB27!t}p(1z)5#JzBKRc9&r!UWcFsM5E zB79uE{an2~fqyaD*?al>$%F8u{)-0>@BgCp^!>M-@DdY4*?EhJi$ebL^beo|;=gd- z{yy&iH12>9b98s~aP;)^#lwpK7uMUw%g@W##q0kC^?(2VpBli+R#*4GZTxR(@$mR> z6TW_G0eCk4Es*~$w69T+x1*SWqpz3055iF`08b{*Uv9i*RD2xm{JeaOyu951(^C)r zLoyJ0S5zDdykqL>>EIRUEAT%&;izio=O_>Q+ifEFK7&dai9=-W!eqpuLgL~w5Xiqk zb-f&1or3-sP)Q?kxD5VtSL%NP#Vd`2ouA$R5!eACV z9A#9z+`WA84dbbU{X>PWu8fwaub-VK!cj|A9)xFF)Ya8N1}ZHr33C*e60w(th>JKl zN#L&>r4b@h5J`u-P#8km$wB%*&#QVN{Qq|Cf1Y>v|M|SWk1O7`?A-sa@%**8zd9kK z>FSH`t)PFKgrOty-&gLgz<<<0#t!jUz2!lOzy0dy0Q&cB*Z*q?{0nWMiz6QC|3`QK z3(VKc$uH2($MK#sURwWWeG18=UWCaKdlPC>6Q%++wNb4OPKBr4gUETByS+h*6YLw84}1$x zRX+c3Zybo)+TdW4QwyR-wVC75de*66`E=`?-#wxnP3Llvhr_HeL>4M;n%Vp3j1U9n zM{&myH$%6R1ufFzoeK5I+51m%PRQJ<#xmJW@A*egPF3`KZ~MI-unDJx<>vlTiF)(Q zD8b1J;|R+xgbPox*rWQ!!(P4Yv5Fd<;9x8~Eze_lgO%NUA(n34>al8{zT;)K%Dl>w z-p@@XkZ+zy+bLtcLl`ol#u;CCsyfot{8;gOej2(k5I2-)yv*Tnjp>BQU6peGq9o^9>zVS!U}r&E5$oJCmvaeBQhJ6d38TK}TBv^%Kc^N<SUmei4#;g{1- z5$$~Mr&AFzShrk4r&wWDVT;wbfpX5#{OkwKT4PW0t=%H^M>8P9Au3L|*U^CkRF&02 z9)8R304%r34(O3kM3xCa58_~b3|PM5(|=LgBXs!wy4m0uvs(0arl_#soAD8RFOz;= z;8E=O`)$6bb^X$gK+IjOE;C;8)Q*0EdzOHgNVVqgg$rpqr{9N019x_Rx6XFI+Xef~ z=hM$~S37-5Klk&E_NS&NKfqz%Xc4u*UO!Jr^N=`Xj{11>(REmO$U(!LLbGY$s9nsQ zuCgM_p^5-EN_#!O`_|+nhp$8lzxbp;iID8XR^D?e^GURsG!ohPpsGpFwt=bt0)Ze* z;9x#{YnE7k)KF|0NFPK)x0kvb#*-s3a>coR_1ccuqO-ucD&C2YB0*UD0NweiWi3z* z+4s(AH$GUtt3eId5_&g3%QC#^j#w61CYgz{_AMOG6@*d^7#Iw-?^OK)Fk9R|iSmoC@e`NRtF+*V{n7{~mt#8lFuaQevvSnOpMnu>G zhu8y99!nP$g<+xb#Cz!i)5i%fbd0|QZvg5ywIh7#BFS%#(Z1!nmEXt^+Vqqj37HEiHLg-=j9CwJHLk6(Vu6=|J)>`>K*7V!~KP z40CtE`P@&Kw)8-?_J38F;DTCZ&c0hr^Sq#gH(dwimdND9&J$VP0eL`cmU#P_hGvlq zXB-5>fRVCgN3>{05IT`QET=8Jp+-?iK{u0|OgqiLMTT{z9OSAxv(NvsEG#vaiEfDx zqaG3CcJfY4yUTj%Xqq(tw5k>|iF7qz%cXVH4io53FKV3aqcq7}6(RveQwueDn2SBK z+$=`iE9Tz^;SCYqyEAy^8MdSB*k7x@#V{{!%g0}*T__D(xeL~FLK`pZLiP9{E8t~b zbqN*K`{MEJ2?>iQG6#i+J@VY^DNd)Kxg2suXON62?J#qx)8llVFSAAo_OnB^r|njmh)j4@v~vb0lUj-dT2~JV-NeJcme)r@dvW(uPmsG{YQ9 zQmvLBaM5uOQ{aWcfT`DSQBdI{_a-aUyk$z71Z*y0`;!9xHL~$Yeb?AZzqja6P~5XK zCAU7Z=ArTtWdUS1m&pWbH{AU0xmdn|9ef`EP>suow(pRpix7Q9rcQ|{4%|J^b3WQ$ z^WqF6b2%u9_H~c!^a-~wD6F$~!t`V6mF%-nGiWKk4wO|a3Fi>Pq4Htx`#j9f`W>gL z-uFZ*V*tqwgGbx~bSD1PDUl3)v}7YXSV3o;czWT@l020vAL;}b9^CD{jM5|Z;r8KI7WBvML7{&2GN>Ls+7JdyHRILe!vvyHR?F~}V=^strlwWNZI}l%K|TYT!b{&p z%f~`bL{7Wx2!$n&QRuCLlVLD$6}v7%;d{BKvZ8l1GjE}e4er>0E&YA}eE~9ZV(hj! zRks;0wz&vCOe=&%;@nuew169q0PtZuFqvUbYuN!OxMDn{L=UCnrMGP{CQ4-L zdis{A0(C)!OJLtHXslJ9;7dFn7G5$QLfs`K!iJ=wMDV9=h!QbMq%@0BC*iOwe#12rk2k2}zgLa1_KCrgX?KX&fX7vJX{=``e{2Q?Q0C zcg^@Kqq(tz_K8qf%jq9TDzXqh438wVkAXiLqM{Rt=Cp0Ov&5cPnfk3q##VK44Q$Pb zaA>2;lVd&5vJJ%=)=Gm9qI4o6373B|iQnTk7t_u77@dc_Z*S9afRM^5cUKAD%v!;g z-(+iYaL%cB7e31Q6@37_B~1&oUM-9QOb5MM+-jB90*Nl#d^DL`{AM@AE3zwg9&jRVC zDTioJe=9J#Bh$E}Y57?;1FN_WLbp3bY2;^0O*@#F!J#MJjtWl@n6}4VG>k~-3OtfU z42n$vo8%!ASv0Hq6HUn&{DfcMoA03_#Kk*kttbY0yLQ(Oov2Hm@K%p5u>Cd?aeZ=J zFLg=Ne)wh~eBITv=?%AC3lmMoYNJhDqYj7)rGm}?V^&^M%1v5j`&^I92XKj^mTWua zQvw=SzSFn=u`zDk{Q`9bJ@F=k=-EdQtoUmcO7m!b1b4Y-^LdcNR&vYGCN%3mZeUo| z!oN^|J!pG-^<-72`Fdwx>hw2H zec&uozmw&4_1>Gj#)Q?oqK5Hcmd^45=0O*6`2v*<+>v?GC=RCw?Z5=DUMNvKeVvb4J^gNx+wG7h!C#r1JmmR_UqHh0*M?RMax7fUi;^<33j`i zAZdPDp`p=PE2-m87osg5d%wP3FM5Y8mz%sYjn5Ux)nJ^sv8e_lyp>yl%(8h0z zsj$rAY5Q-C;Pv3hF-#_RpST;9S2tkBYYjF*kK@NS+m9qC#Y4YrMt1YUtOMyk(^l2Wv2&+~k1YyK>}b(D=|%c5W7Q0e)P86+_85v%t4bxFx#Q!II~+UiEQM>1Z2bR<$q0O^_x8ZSw{ zQqeQJ1lYC_j=?b`lb-r#%;ABl1vPz|^asS9s5B%!lBkXpwgSjAkJb{niUX~-KfD;5 zR{4j(yHhy_2`j~~M~)>!H#NOx7V>ry7rymfkosuHX9J0tCdpK$yJ|Z9c54h=cNLv_ z4E0<0+PprRAM->NguK4;VN~41rkZ#^eRebC&lg3T#WsH5PzyU0D%zlu1TZZ9PM9~g zZ)a9mCxOy0z-_oZPabQ9327x7~<@LBU?;m{9 z(K-tZ-Q1*$thUX%+CRM>IW-+O`51)ig_#bIe%olLhj&I^c-F9cST_kccUP-|BWB4I#PwfY{Zgk+Hyyo%{ix^Cg??BKAH|T)Z zjWJG0%)*e$hTCz9q?5UwQl1hn`$BT*Pv#`c9j=Y2)b_^+M>|!L&xr@UYyJCr2xPCiKIyF&JcUD`!SV#?JQ-2_|oP;uMa! zMVmH3CqHL;CngxHP5$02k`Ku(F5ciJ-H~HF`+8k*Z+R(sTd)j+;&H-Zzz4y~Xo(rz zY`Sj&zx*XDp6f_^e2ll>gI=d#3dTQr48UZb_XD-CfKvm_lu|V>lN_yiUHxu~4QKqQ{t0TXT-6A-|2TDy^54EoBlqX{M1DJ#=I7i|QmvmqKXRp_ zz0KXmX=M4V;>oA>iBLP4xigsIvx5}LgmX>)OiQs$iRpPW55_PqxAUE-7Zw85gGC}) zW~qje@S3mGp@)&wBR|EIjB8qcJ@#x}Gx_K-6KlTud!u;c(Zc!d6uDnyK}EjDUrl|t z<2=_8To-`B!9j@v-CuGW9g)O;uJ*1KQshqn+$1JyQO>8#e+m>Woyv;5Kc6t}^$2Pe z_TlGn7`z(H72!Pw>PpGBcek+Obaf|B7!GAHcV~H4l*Sy((hI*?$$V*C=Q)Mk%8qsu zjkGYXV)H?Xf#+oiaY92%%c}IfxsikqNmwX{dmD8Tt1a3FslT0ycyIlvGzxugEo=o# zJ-T%GDz@PD`3Ki~&)+i(MSpO_{bpi|J;5UO}rQLRK0+i|JCwlkFnTabv2-JbQH^S2D`S2Ge~ zDhfJbxjNFn&Qv^$Qd}H;p;DD!Sm@yGojMY_4r{r--0K}8<9_@0tx~J?MuN>BkiOgj z$%{MQ)FqvU&Av~rM{-zLs_Ohzo`Ug1Op7YL*wz|u*4}n9?de7F!0#(V`J%ZTm^7QK ze_{y%!ec%%Q2oPBgSXt$cO65|Cb^ZTnCKS@8Un;xg~ZEnL`-xZUCaB_c)m{zL0Jw! z^Y1fB=Cu%0sNCn0nt+EKRp)CdAC(u9vK;10>$kGwPC^g)cN?!$s1>|dvMaJ1c8l!q z-*>#;3=22^OKT+nfqlUzkBFov8p;hd~^Z8;BhT+%;QO7dv|7!87 zA|&(_BdWh6oLflg7i^0Y2*O*=l-zpb?z>DH>n!BlOM!w(-Xl{EbBc7hED4}O3krI- z`JfwJ{}Uu?+JVYiO&{oPpB~%XRIa^NseZs^LA_Rn=DnS8ul)fPRhH!*&t(nrk7@cJw3d`MF(b*nWEiEDOxxxk!fj`4hmGTr8y zyfRf)dh}`0EjCH-wJg&*RFP@*-puTJC! z4P&U6M#`Sz^h%|?^f+ajHBF+S?^6Q`9^&PAO8S~cu-#zrNV#}#%nCViSh8_CN)|t} zaDy>|kdk$9D=lT6E>xDo@5q~++wDOYV)(p@Td+|$Qk1LN|D!B)s{S1fim47;!s}W; z9&Xp}=Kh#@T4QEZkbIbiJ3ZQ=Co(*>YZB7H;wZm1hhSI?&o&iu15*O zMecS|-6)$KADi>h)7uKaMV;QZv@2s`2W`pBf_ z{1|dSqefQceubtyi=$Z2#tR4c9h@v4w=QwuM+Ku(f`4R{~4I{SkKap&*Waj=*`t+&Ng|VU7Nupih9X*F~b1 zA8RE2FO{A5VM(EjzXBds`&aKg+?{Vqm7xz!LKaj zE*P?fQ%q+cw)2Lha<}Km&lOB9G^T&tuHbkq(S3)l_EP%lBF*h=Ag2xIE4H)o#^wT1 zgj#;cC+UH`h~~-2D03YU++dH2ib_mQ`m*H*M{NS}!U+z4COl|%S5k7|>EO<0Wv$UgK_1oI)bRvHz1zBR(!Rg@L+sY%V<}N);&!=ltLru=@drt2>z{+Z z*V#Pt{9tl&TreZvOx=2|skz({G4n{PnzxoZ>WW~mmc#MUZ@YkxihVDJ16X^2^4k?& zgFpZ5EU;|YTzywWoDM(RnQ2au(5W;M)GYb>m=I6llPAf0PxfSY=04@5rO|zKpBr$T z5px_b*Uc1i{Qgr?D?PPLU<{}VS#cbePKLeat+T_zECVf@c$eNRV94kq4-Uu`e)sDi zZ0U#Rd(4O~_NDR6hMmsIUF`T82|7x8e7|pFlO(t2bGFYNus7em)UDwnkIs zBf)wrTQ*Yh$}{fswtV~fq5o^%WTak;BN0nQXX;o+eP!x-sij8ewkC=RLw4%dwjUF6 z5_*zd{A_3NL{4r;($mZAlHsstlb`ul9R(`$pWXq`FR=yIfdy zodfe%2Wl^?A5XYUO>zLbr*$E}Ey`sWBs?-7F95mPoGh_!Tm?cUX!=HHP9pBvSJzc{MuJpM&0@r5NU^=0LFmDKC3_`=~QbY{(dbyzQ1yDk+>c7qmn;p8N;9VOnzYR&lXh5GW)<(!Kgfw0zY@#h*u@~ zpdk+)?r1a_p@nM zD)te72PzMnKV%7BA8bsh;$C9(LEL$7tmwa6i)D`n2i zxsf3#z^l_R#3wsDDduG#Le|$Q2P7UrPIC0CL)kZNT8)Q>oyYc2rnlNOtKsJ*FZe)x zC{e?vPufSqpgPI>^Tm9xuGC!Lx&OASh}hw*@5sTZ3Jr3xgK#L3Cqv_}N>EZ#y3aLb zJ^rBn!D8jBePUu#b?Mx$&*muiNgHIOiM!W(Zs{4^4eLsKJI&W~`g?T;EB$bUfV1NOg<4iXL&WcSz{R)_;69&u@lS_| zy)wnT9wCiYG9Ddv^Wv2#Q{K@1*U;Rt!6bPN3O;AzXY3@Jqq{62)XZ{6_fvY-&?L&C zn?DlUk1G|_Z^~Cd7tlLXS>rCZM&LySvR5;?f;IJt2u`+dnne4!gZ>ip~B(Y?JHqR>gOxi?bCT;Xn-gf?c zZph&nz7fX$!+O{N@SN@Ss>!m87mr2=Tiy@rAv0I8M?(709{~@2C{Z$SBKq3YJ}7eQ zTjI}0REY>Mq=V|$EPm=ktYW0y6@rxE41Y1Ws}i}E{Lu45(E9YVS>L@=Gj~xK+=!p% z#+pwaHsn{ybBju-40J##j9`P^EiD>spBN3;WWp)Kd^TNdSDG^Gd&~;p!_idicgCM% z?t)v$1Eki7KFJSQtvE2<$vl6Gm2ns2a`gR8p1jWN^W0auevaHiQc21vH>F$nHZl9I zW#0ptLGJ+caX)31e({ScHLRN!(E*f@K2v-7GXz$?!E^XovxrT@IptUDUUL>a) zYTAdq^$Mw1m|`Kmnzno;ya4L%@dOrWV~9roT>&@pJs%S-&GJlu*nQzjLRd?3v=ont zdWkRBU3be^4WJT5+2gd)@@(BY(V+a8UnVDkwN5ots{EN-Sk}3zUcXN%rLE-D=Yv7v zHhGjf3q5ZZfAV4Q2^;(%;6{97WN+_XgLfwGi(DM_?n?_19*lauS|Z_w95>0y?#!T6 z`?0QiY~1Gd8*w@4(B>3CB}&EUwXwFjdbVRUKr1~Otc6AgOq#i?{_48p*abJ2h@*W2 z;_S9V+50hOl4Xx!{AEmdKCwPC!+*7!y<{QntENyT&Csg((Oke9N0(+ycR1{7BrZ@F z8x3=a{k7+?ZDl8=`cP$Al}J_f$Ad!-LC@s6of|TCxjesLvTs^UVEW^L}h zsq?&)k@T1zI*TPIy&1Q;w~fGO8GK%LwOwAgN*37Cb(4_>-S-yn@g~vftBzmV!YKrs z@R})4KKAp7kjr-I;r{-OhA&%1QKtv!)I~UF2FmRfdYU+EaQ>m_c$!fAWsreI6?91i zS_06^3b>NpN2{omW@zNv!`CY-lwHuX#6g?4sX2a#C^S zPv+eCq96VqXFOwY!b$#KCaoJ58CSeFw%g&;JLOwu>+@}sDb2|@&BToymaH`%9UPu@ zbxsf#=8uo5ziP)UrE!yzlI9f`ukL0wh5G1zpvWT)>=MLh;K)9n zxX`C02$ZaP!}`XUoNd{UWry5IQ@!;ja7_mp9O&!ux(x%-ZtF=cb^zu%-I>E7e5Zuv&Cmc;KV0tUmV}K50oV^@Q8zlBM8VJL?R-MZVgdtL_+4Z(g8dA zfStIc?Zp8PUS&wI8dkRMRI~FF=gxk|xV(&2Df;b&_j4~7o_2iNh&#KQ{}cV2+YYLS zX5N~yhR-2!kn>r3163l}{s1z<@E%0t>!(+II&db3AESH820nuhKT;b>c|RaU7q^zb z_B>p?v;QN_jHVxjVF6i?5B*j4x)1ZkPSYRgX81IfTFkp>Z#WOJJA|RqGj@9ly=gqjQ9Ap7==w^%0jn-D9eJ_)2 zqESWvKow0oeDC9ApWNygkL`O-7A^_TnS9^wd*K?!>2_z+XDA#H|8RMpWOp4Ohi;nK+64A8RbR$V*0jIm$Zsf z>CY*tTPb@;2_!o|gc!R;F(z(hiSv6x-fGq(uzbhNab2lP zpqCE9J|;JRgPoABvjg1Wt;$Y_$Wn<%qZ!2+a#f(Jkvs-wPr>YT8p(#qb`x`8;w?sU zD-%pcZs0d!HT)2tFJonzGbzc>3p|k2YDLr$>ToQPj73ENItf4c;42(%m)ucZ2Ikwf zl#q?O%Vsyf9rf9HMs0cNx9a<8)iUszGfFAqCN|FncP#hi50yqUgXRM)N%sr4m8v& z;lzMc`tK=>UfaC7xI}kol?dmHT)e`^Q+iQzx(2D96k|0cyy#E5)udca3j0a&k(DGR!OEn&9(DB#4FToM6-QxKCY)85>mFTcGQ*_t+@kbP)a zOzqwdv?bdkvX)n3%(f@R3?k!I50C6)AuW_-WOb?%OOQfJKvBSE_kENa zcr%u@Q?6^DLRH#A4dcQou*|fH&9wD@im{GXH`6aJ#*VLpL43H-W&4!7;KJC}5G|b8 zL~deIgi1@bXM@6$4b9qpeU_jZaklVoL3Uzo33R zUul%C3;*WpF#nKIS&7cBX6fuSrCbiWa^|(y_j^ADKETV38Gm@H(fIDf!+Nxuu?)Nl zyd(VbEd|DhT{?EtJjNw?$bX-NLT3v{k*u^X8?hM+jm%V)#%w=~&MeA05bU#Lruvmj zyAM~RC-PC}gc5PqK5Z&~vF?mKLVdVH5v03}i2KU{hUs!A)T8g_ti@x1_Kg8zH?p^X_j6xRaFwK8_T!jfnr-* z&on%JPs0c&OXbjY9DTITU@_3ht_W(~6~9oGSeZRF>g>)zaWt8cM4=)8y3G{Iw6QnnD)h|k;nD4W& zo5;*3uaUz(C_iQ@N&33zu~y>IP0zXIvr!SNkI6ak=IGPpBsQqRwP|7!kM#Ab8}2GE z+69ykm)rQw9OziZws&wPBTM=tO}88lZ?}$|72?Li-|r05bL;i~F-VEy z(mm_epg+;$m-wFSvPhC3lIs;b^gL6VdN^o3o1?g)wYR4LhR+KBD`##fj!#|@VP8Ks z{?-bs5@n?N8N>WOZQewmH$CdTuqx&7Ab6+@5+ET^HQ-EiKp4lwFR<8LaS5X9^>)>h z``w+T$^16^?(MGzvyXXAwCqKQsJidB;On_CAB=?d(`Mv}iEqSFGr51a@SeMPjn9Cg z_UZv;PG20K*cUyOO7#|ec2%V*k?38>M_#5G?&P3V*1oe@8*x9EJdsKLgRp0t-p>5S zS#?Z@`}pDFi$3FKxkul8Ex_DJA|wc(6A=?9?&t3OFo}LsOTT7-F-4y$FmVQ0cxG5? zE#89b_2d|)kUq;>p)wA`x^mDr}Tm-5RZ@|Gv2jVhu{ph!SC2 z{DMqFnOO$N$qTLLuzTIaNP#h@62x&#^F5bpC8QOL@EAR@vaaJ5@4|A4$Rr-%y;|C& zO=4Og0jt_*K=#%aa&Ify$hA42j!9lW8uV6m$;@E z=+n2eG1KPg$Q*LoH_;inQ^JBQ=)-wI->05wuoNUkvf*aWCV3{^GL_LF?&Kb#Ozex0 zZScUIrw75SwkXO4hei4G9W&8w0fhKw*#mHJdPkBTrSMd z--fn>e-RB~b-<+-PZK1aTPm=^)%=%FNxi^JjPJ^vTXV^=c~BtfQp7A!)mnvpLmpqw zGS-p!=`-a;v+xHw@d!0D&+gTw=x6msJ)*hyR^eh(vp1PN1RFL>`W%@ZT4vN>VnRtD zLh8B%z=iSs^bZ+vEN&7#Mmk3n4}UFkxcP9>(};Iq!h{*JG&6Gqk-*G$kO0Em;%R5Bdmf2%j;irUSs%Tms%|#B) zh{FUx2iKb!QtqVG z&>oV+zNWm%t=oamfanNXfojhOKM3-cdQ{rQ(o+ach^~y zFHXSjHe3z%YrE(O^t!pJTz~*f0G+*aDHnyv5!zAhDQ$}8^ zL$CEXbpJBhOVMkm=&6c^ypBgNVofvq09rS2hv+X6(_=#HZ}3G6i3EFU*-Gm#3h%un zxnu&oivN81HfU5_l2nBUGDQ={^iTjZ5p~Eu{zIMf$?Vyuh4`JK=u(~KMy(OwkPyq@ zd${Jz2%lxYyXKB$-$n3&C&k*(M$A|T8a)n`pTSSu4V_q%^>(Kez#rX7_Ikc!nV7pr zW*5(X?rD@W+qQA9vr&@}fxYoXTI?vAuk=*kR0U#uD851`v>`^3{>F7}T(LKM7T<2} z#kC0@8NbNK7*L4Pk%2opsHpHYNiWk^zHKLI*g#J7**Ur&{iGK?qPvUM-s4!|0Fx4bye+pdPbdAJ zAc#clj^hJeyXQouyy8ZYZTXwqacxgtnY=KF^&!`;>@(=2*DT!UT)kI?uW(}k0QhOe z=yM-x_28QNS~eMJZwaFV?{=>}WYaX#Sw|}U)BNUYwh=Rx8uGC?Yx4=bJV&m&CKnJ} zu(0teD<}`2m#TVwDytBhf&6(Pm-La27=F{0$dw|GS&Ilmyb;Z=3GBevZzK|?qcDF9 zB;q4zxmlB_8Pirs(hq8!(iwQ;bG8@Nunfsxb@P?aFXrw8t!qU}XzU?!o-47NC}XgZ!91^S>xTQH4!E9=yR*Tgoi zxqw^P7CMigi%@-C51E6!dtSomjmKk4dOp|KaB98WUK0KB5)qm>V{vDCK`@v)M&`jV^#*;qDVMuwS+ z#8mSFY|i_ANb=`W`x&)lFatmnSjTtpP0N{ zxjXvb0!^y97*+hO!PrJSd;kSWt|L;G2&QY2elM$DO0MC&v~P!)e2Cb4D&9)wd&}j0 z5=ePD(VsZ&CmW?gfNtl7s9un12Ft_2;nTJp6Inz0zmd>A(D%8dsCmI~cHyA*JgYJ$ z^du7?&{l6lEl&%`_}6WQ_{LxrDF2#&l>XJn)`5#%B?4gFKkKPOA$QKYKQ{L`IJzX$F^K z*W!#T|ET$Gk>-6Rh{Ixj5sdpLdq#jGZ-qVhRrrXmd>uriKq5=6aU$0BE^^f) z3@BpqihF_dKL{xk>oFws0U2Ich_^(P73t%(ABzcIRCs zVaP_3e8#AYZ{hPowZ}}Q=`6#YN;lMh+*r9B%>f)!iB7Qs7HGF>SswN3wD3=f!;1d6 zDY%B^C?e7fHQ~DFavmW8KO*gsb6!}z_@HK8GhNN9K1%#b=>SZFL&rn^1IgF?eZ2@c zGf8fgf0X@daIY>`1Y0uT`j=4T%|K-|PD$jQhe~G*PZ}owp& z^YshX zKB0_?3_`mRi%lQ_U>L$2m!2)LQ=O_wdL~=GmtK+oY-T648PlP+7q6HW+@90)()l-8O8I}bx}e`WNS=2?swlQS#mVeRE!X@9wL(GQ-1X|ql!!)v8dagM6FZ_4F6Z{A!V56MPLcqb za&~7S|A*Zkf_gw=vI?bW63v~hC*uyM<=qL*e&km3WznBB1L-4wkA#(-Jm}wU=Juw1 z@R`^zBm(a#muCftR z$L86NzOFxx)lW2agMR$1J9c{ z*^;W}K9Zmw(NHGY>!1{T*(Yrmfh_DsJFN=?Z@`2|s$JOqF`r+N$<`Z25ll{m!<&x|RQj1{b0wtRO5E^(gmVg;Rbg!UL#P5&sD$$A+Xh}F2(1j}6JDgV}v?Q1OI8lC` z5n_W`7@Q$uq?SA{T8?E=AZjJ{ zu{J~UJ~~)2QwOBm0a}&zqbU6v?L?eB+`0O6dY%_iZZ{-lTX&;c6zdX8rbLXIA8KP$ zZV0inTN>->=@9_>a5~`xk)B49p0f=iy5ltP4bL<`p10G9(2^asbl%jL543=Tz=K6z zs!}v9XqE665&lh}7D3yYXiWM(lJD)vP*IAw0wW@{;p=00DOp)r8j@jI9KHf_m_t!q zd~BmIBz*ZYY52k;{UgDnNV?}#6GNIXz{q(FUV^5KaP()u)?cm={bu850mzS3xf?E}`%eP23W=TfH zDmfANpZOAD;~SI>G8t9Kjp`u_k@H`ZwY From 1ed9a3a0443811c0a8ae753615f8b1d4e98cfd58 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 11 Aug 2026 18:56:04 +1000 Subject: [PATCH 2/2] [minor] Replace LibGit2Sharp with the git command line libgit2 implements neither the clean and smudge filters nor the hooks, and Git LFS is built entirely out of those. For this application the half that bites is the smudge filter: cloning through LibGit2Sharp writes each LFS pointer to disk as its literal text, so a repository that tracks binaries comes down looking like a set of three-line files rather than its content. All git work now goes through GitCli, which starts the git command through ktsu.RunCommand. Arguments are passed as a list rather than as one command string, so paths containing spaces need no quoting, and RunIn uses git -C so the process working directory is never mutated -- which is what keeps the background fetches safe to run concurrently. Notable call site changes: - Clone, fetch and status move across directly. Pull becomes --ff-only. The old code performed a real merge, committed it unattended, and swallowed CheckoutConflictException, which left the working tree mid-conflict with nothing said about it. - Repository discovery takes the parent of each .git directory rather than asking libgit2 for the working directory, and reads the remote with git remote get-url. - The index enumeration behind the repository diff becomes git ls-files -z. NUL separation turns off the quoting git otherwise applies to unusual paths, so names arrive exactly as recorded. - RepositoryNotFoundException was driving control flow in four places. Those become an explicit GitCli.IsRepository check. - The log panel carried libgit2's debug trace. It now carries what git reported for each clone, fetch and pull, which is what someone watching that panel actually wants to see. There are no credentials in this code any more: git uses the platform credential helper, which also makes SSH remotes work. Add ProjectDirector.Test, the repository's first test project. GitCliTests pins both halves of the LFS guarantee -- a tracked binary is committed as a pointer and comes back out of a clone as its content -- plus the repository detection, tracked-file listing and remote lookup that replaced the exception handling. Also correct the solution configuration, which mapped Release to Debug, so building Release produced a Debug binary. --- CLAUDE.md | 20 +- Directory.Packages.props | 4 +- ProjectDirector.Test/GitCliTests.cs | 258 ++++++++++++++++++ .../ProjectDirector.Test.csproj | 14 + ProjectDirector.sln | 10 +- ProjectDirector/AssemblyInfo.cs | 3 + ProjectDirector/GitCli.cs | 179 ++++++++++++ ProjectDirector/GitRepository.cs | 7 +- ProjectDirector/ProjectDirector.cs | 216 ++++++--------- ProjectDirector/ProjectDirector.csproj | 2 +- 10 files changed, 570 insertions(+), 143 deletions(-) create mode 100644 ProjectDirector.Test/GitCliTests.cs create mode 100644 ProjectDirector.Test/ProjectDirector.Test.csproj create mode 100644 ProjectDirector/AssemblyInfo.cs create mode 100644 ProjectDirector/GitCli.cs diff --git a/CLAUDE.md b/CLAUDE.md index 0710140..05e47a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,11 @@ dotnet run dotnet publish --configuration Release --output ./staging ``` -This project has no test suite. +Tests live in `ProjectDirector.Test` (MSTest, via `MSTest.Sdk` + `ktsu.Sdk`). The app exposes its internals to the test project through `InternalsVisibleTo` in `ProjectDirector/AssemblyInfo.cs`. `GitCliTests` drives `GitCli` against throwaway repositories under the temp directory; the ImGui layer is not unit-tested. + +```powershell +dotnet test --configuration Release +``` ## Architecture @@ -43,9 +47,21 @@ This project has no test suite. - Concrete implementations: `GitHubRepository`, `AzureDevOpsRepository` - Tracks: remote/local paths, fetch timing, diff results against other repos +**[GitCli.cs](ProjectDirector/GitCli.cs)** - Git access +- `GitResult` (exit code plus both streams) and the runner that produces it, built on `ktsu.RunCommand` +- Arguments are passed as a list rather than as a command string, so paths containing spaces need no quoting +- `RunIn` uses `git -C `, which never touches the process working directory and so stays safe while repositories are fetched concurrently +- Queries answer from git's exit code rather than by searching its output for "fatal" + +### Why the git command line rather than a library + +Git LFS is a pair of filters plus a set of hooks, and all of them belong to the git command. A library that reads and writes the object database directly bypasses them: a commit stores raw bytes where a pointer belongs, and a clone or checkout lands the pointer text on disk where the file belongs. This application clones, fetches and pulls, so it is the checkout side that matters here. `ProjectDirector.Test` pins both halves down. + +Authentication follows from the same decision. There are no credentials in this code, because git uses the platform credential helper, which is also what makes SSH remotes work. + ### Key Dependencies -- **LibGit2Sharp** - Git operations (clone, fetch, pull, status) +- **ktsu.RunCommand** - Starts the git command line, which is how all git work is done (see below) - **Octokit** - GitHub API (list repos, user info) - **DiffPlex** - Line-by-line file diffing - **Hexa.NET.ImGui** - Immediate mode GUI framework diff --git a/Directory.Packages.props b/Directory.Packages.props index 92ae7bc..a35de5b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,10 +11,10 @@ - + + - diff --git a/ProjectDirector.Test/GitCliTests.cs b/ProjectDirector.Test/GitCliTests.cs new file mode 100644 index 0000000..f504be2 --- /dev/null +++ b/ProjectDirector.Test/GitCliTests.cs @@ -0,0 +1,258 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.Test; + +using System; +using System.Collections.ObjectModel; +using System.IO; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Guards the reason this application runs the git command line instead of binding libgit2. +/// +/// +/// Git LFS is a pair of filters plus a set of hooks, and all of them belong to the git command. A +/// library reading and writing the object database directly bypasses them, so a clone lands pointer +/// files where the real content should be and a commit stores raw bytes where a pointer should be. +/// ProjectDirector clones, fetches and pulls, which is exactly the half of that the smudge filter +/// covers, so these tests pin the behaviour down rather than trusting it. +/// +[TestClass] +public sealed class GitCliTests +{ + private const string LfsPointerPrefix = "version https://git-lfs.github.com/spec/v1"; + + private static bool IsLfsAvailable() => GitCli.Run("lfs", "version").Succeeded; + + private static string CreateRepository(bool trackBinariesWithLfs) + { + string root = Path.Combine(Path.GetTempPath(), $"ktsu_pd_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(root); + + Assert.IsTrue(GitCli.Run("init", root).Succeeded, "git init failed."); + + // Scope identity to this throwaway repository so the test neither depends on nor disturbs + // whatever global configuration the machine happens to carry. + Assert.IsTrue(GitCli.RunIn(root, "config", "user.name", "ProjectDirector").Succeeded); + Assert.IsTrue(GitCli.RunIn(root, "config", "user.email", "ProjectDirector@ktsu.dev").Succeeded); + + if (trackBinariesWithLfs) + { + Assert.IsTrue(GitCli.RunIn(root, "lfs", "install", "--local").Succeeded, "git lfs install failed."); + File.WriteAllText(Path.Combine(root, ".gitattributes"), "*.bin filter=lfs diff=lfs merge=lfs -text\n"); + } + + return root; + } + + private static void CommitAll(string root, string message) + { + Assert.IsTrue(GitCli.RunIn(root, "add", "--all").Succeeded, "git add failed."); + + GitResult committed = GitCli.RunIn(root, "commit", "-m", message); + Assert.IsTrue(committed.Succeeded, $"git commit failed: {committed.FailureText}"); + } + + [TestMethod] + public void CloningAnLfsRepositoryRestoresTheFileContentRatherThanThePointer() + { + if (!IsLfsAvailable()) + { + Assert.Inconclusive("git-lfs is not installed, so the filters cannot run."); + return; + } + + string origin = CreateRepository(trackBinariesWithLfs: true); + string clone = Path.Combine(Path.GetTempPath(), $"ktsu_pd_clone_{Guid.NewGuid():N}"); + + try + { + // Bytes that are unmistakably not text, so a pointer left in their place is obvious. + byte[] payload = new byte[2048]; + for (int i = 0; i < payload.Length; i++) + { + payload[i] = (byte)(i % 256); + } + + File.WriteAllBytes(Path.Combine(origin, "asset.bin"), payload); + CommitAll(origin, "Add asset.bin"); + + // The committed object must be a pointer, which is the clean filter having run. + GitResult blob = GitCli.RunIn(origin, "cat-file", "-p", "HEAD:asset.bin"); + Assert.IsTrue(blob.Succeeded, $"git cat-file failed: {blob.FailureText}"); + Assert.StartsWith(LfsPointerPrefix, blob.OutputText, "The committed blob should be an LFS pointer, not the file's bytes."); + + GitResult cloned = GitCli.Run("clone", origin, clone); + Assert.IsTrue(cloned.Succeeded, $"git clone failed: {cloned.FailureText}"); + + // And the checked-out file must be the content again, which is the smudge filter + // having run. This is the half libgit2 could not do: a clone through it lands the + // pointer text on disk in place of the file. + byte[] checkedOut = File.ReadAllBytes(Path.Combine(clone, "asset.bin")); + CollectionAssert.AreEqual(payload, checkedOut, "The clone should contain the file, not its LFS pointer."); + } + finally + { + TryDeleteDirectory(origin); + TryDeleteDirectory(clone); + } + } + + [TestMethod] + public void AFileOutsideAnyLfsPatternIsStoredVerbatim() + { + if (!IsLfsAvailable()) + { + Assert.Inconclusive("git-lfs is not installed, so the filters cannot run."); + return; + } + + string root = CreateRepository(trackBinariesWithLfs: true); + + try + { + // The pattern covers *.bin only. Without this half of the pair, a runner that turned + // everything into a pointer would still pass the test above. + File.WriteAllText(Path.Combine(root, "notes.txt"), "plain content\n"); + CommitAll(root, "Add notes.txt"); + + GitResult blob = GitCli.RunIn(root, "cat-file", "-p", "HEAD:notes.txt"); + + Assert.IsTrue(blob.Succeeded, $"git cat-file failed: {blob.FailureText}"); + Assert.AreEqual("plain content", blob.OutputText); + } + finally + { + TryDeleteDirectory(root); + } + } + + [TestMethod] + public void RepositoryDetectionDistinguishesAWorkingTreeFromAPlainDirectory() + { + string root = CreateRepository(trackBinariesWithLfs: false); + string outside = Path.Combine(Path.GetTempPath(), $"ktsu_pd_norepo_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(outside); + + try + { + Assert.IsTrue(GitCli.IsRepository(root)); + Assert.IsFalse(GitCli.IsRepository(outside)); + Assert.IsFalse(GitCli.IsRepository(Path.Combine(outside, "does-not-exist"))); + Assert.IsFalse(GitCli.IsRepository(string.Empty)); + } + finally + { + TryDeleteDirectory(root); + TryDeleteDirectory(outside); + } + } + + [TestMethod] + public void TrackedFilesAreListedWithForwardSlashesAndSurviveSpacesInPaths() + { + string root = CreateRepository(trackBinariesWithLfs: false); + + try + { + string nested = Path.Combine(root, "a directory with spaces"); + _ = Directory.CreateDirectory(nested); + File.WriteAllText(Path.Combine(nested, "a file with spaces.txt"), "content\n"); + File.WriteAllText(Path.Combine(root, "root.txt"), "content\n"); + CommitAll(root, "Add files"); + + Collection tracked = GitCli.ListTrackedFiles(root); + + // Paths arrive exactly as git records them, which is what the diff view then joins onto + // each repository root. Passing arguments as a list is what keeps the spaces intact. + Assert.Contains("root.txt", tracked); + Assert.Contains("a directory with spaces/a file with spaces.txt", tracked); + } + finally + { + TryDeleteDirectory(root); + } + } + + [TestMethod] + public void TrackedFilesAreEmptyOutsideARepository() + { + string outside = Path.Combine(Path.GetTempPath(), $"ktsu_pd_norepo_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(outside); + + try + { + Assert.IsEmpty(GitCli.ListTrackedFiles(outside)); + } + finally + { + TryDeleteDirectory(outside); + } + } + + [TestMethod] + public void UncommittedChangesAreDetected() + { + string root = CreateRepository(trackBinariesWithLfs: false); + + try + { + File.WriteAllText(Path.Combine(root, "notes.txt"), "content\n"); + CommitAll(root, "Add notes.txt"); + + Assert.IsFalse(GitCli.HasUncommittedChanges(root), "A freshly committed tree should be clean."); + + File.WriteAllText(Path.Combine(root, "notes.txt"), "changed\n"); + + Assert.IsTrue(GitCli.HasUncommittedChanges(root)); + } + finally + { + TryDeleteDirectory(root); + } + } + + [TestMethod] + public void RemoteUrlIsReadBackAndAbsentRemotesReportEmpty() + { + string root = CreateRepository(trackBinariesWithLfs: false); + + try + { + Assert.IsEmpty(GitCli.GetRemoteUrl(root, "origin")); + + Assert.IsTrue(GitCli.RunIn(root, "remote", "add", "origin", "https://github.com/ktsu-dev/ProjectDirector.git").Succeeded); + + Assert.AreEqual("https://github.com/ktsu-dev/ProjectDirector.git", GitCli.GetRemoteUrl(root, "origin")); + Assert.IsEmpty(GitCli.GetRemoteUrl(root, "upstream")); + } + finally + { + TryDeleteDirectory(root); + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + // Git marks objects read-only, which blocks a plain recursive delete on Windows. + foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + // Covers a missing directory too. A best-effort cleanup of a temp directory is not + // worth failing a test over. + } + catch (UnauthorizedAccessException) + { + // As above. + } + } +} diff --git a/ProjectDirector.Test/ProjectDirector.Test.csproj b/ProjectDirector.Test/ProjectDirector.Test.csproj new file mode 100644 index 0000000..2536def --- /dev/null +++ b/ProjectDirector.Test/ProjectDirector.Test.csproj @@ -0,0 +1,14 @@ + + + + + + true + net10.0 + + + + + + + diff --git a/ProjectDirector.sln b/ProjectDirector.sln index ec49775..308d12d 100644 --- a/ProjectDirector.sln +++ b/ProjectDirector.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.8.34316.72 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector", "ProjectDirector\ProjectDirector.csproj", "{E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.Test", "ProjectDirector.Test\ProjectDirector.Test.csproj", "{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -13,8 +15,12 @@ Global GlobalSection(ProjectConfigurationPlatforms) = postSolution {E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.ActiveCfg = Debug|Any CPU - {E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.Build.0 = Debug|Any CPU + {E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E13D3804-2B02-43BC-A2B8-AE331AE0FB6E}.Release|Any CPU.Build.0 = Release|Any CPU + {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectDirector/AssemblyInfo.cs b/ProjectDirector/AssemblyInfo.cs new file mode 100644 index 0000000..4d64088 --- /dev/null +++ b/ProjectDirector/AssemblyInfo.cs @@ -0,0 +1,3 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.Test")] diff --git a/ProjectDirector/GitCli.cs b/ProjectDirector/GitCli.cs new file mode 100644 index 0000000..f8d4409 --- /dev/null +++ b/ProjectDirector/GitCli.cs @@ -0,0 +1,179 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector; + +using System.Collections.ObjectModel; +using System.Text; + +using ktsu.RunCommand; + +/// +/// The result of running a git command: its exit code plus whatever it wrote to each stream. +/// +/// The process exit code, where zero means success. +/// The raw standard output. +/// The raw standard error. +internal sealed record GitResult(int ExitCode, string Output, string Error) +{ + /// + /// Gets a value indicating whether git reported success. + /// + internal bool Succeeded => ExitCode == 0; + + /// + /// Gets the standard output trimmed, which is what single-value queries want. + /// + internal string OutputText => Output.Trim(); + + /// + /// Gets whichever stream explains a failure, preferring standard error. + /// + internal string FailureText => Error.Trim().Length > 0 ? Error.Trim() : Output.Trim(); + + /// + /// Gets both streams as trimmed, non-empty lines, which is what the log panel displays. + /// Transfer commands report their whole progress narrative on standard error. + /// + internal Collection AllLines + { + get + { + Collection lines = []; + foreach (string stream in (string[])[Output, Error]) + { + if (string.IsNullOrEmpty(stream)) + { + continue; + } + + foreach (string line in stream.Split('\n')) + { + string trimmed = line.Trim(); + if (trimmed.Length > 0) + { + lines.Add(trimmed); + } + } + } + + return lines; + } + } +} + +/// +/// Runs the git command line. +/// +/// +/// Shelling out to git rather than binding libgit2 is what makes Git LFS work. The clean filter that +/// turns a tracked binary into a pointer, the smudge filter that turns it back on checkout, and the +/// hooks that transfer the objects those pointers refer to are all features of the git command. A +/// library reading and writing the object database directly silently bypasses them, so a clone lands +/// pointer files where the real content should be. +/// +internal static class GitCli +{ + /// + /// Runs git with the given arguments, each passed separately so paths need no quoting. + /// + /// The arguments to pass to git. + /// The exit code and captured output. + internal static GitResult Run(params string[] arguments) + { + Ensure.NotNull(arguments); + + StringBuilder output = new(); + StringBuilder error = new(); + + // The raw handler is deliberate: the line-splitting handler drops a trailing fragment that + // was never newline terminated, and git does not always terminate its final line. + OutputHandler handler = new( + onStandardOutput: data => output.Append(data), + onStandardError: data => error.Append(data)); + + int exitCode = RunCommand.Execute("git", arguments, handler); + + return new GitResult(exitCode, output.ToString(), error.ToString()); + } + + /// + /// Runs git against a specific repository using -C, which leaves the process working + /// directory untouched and so stays safe when repositories are fetched concurrently. + /// + /// The working tree to operate on. + /// The arguments to pass to git. + /// The exit code and captured output. + internal static GitResult RunIn(string repositoryPath, params string[] arguments) + { + Ensure.NotNull(repositoryPath); + Ensure.NotNull(arguments); + + return Run(["-C", repositoryPath, .. arguments]); + } + + /// + /// Determines whether the given directory is inside a git working tree. + /// + /// The directory to test. + /// if the path is inside a working tree. + internal static bool IsRepository(string path) => + !string.IsNullOrEmpty(path) + && Directory.Exists(path) + && RunIn(path, "rev-parse", "--is-inside-work-tree").Succeeded; + + /// + /// Gets the URL configured for a remote, or an empty string when the remote does not exist. + /// + /// The working tree to query. + /// The remote to look up. + /// The remote URL, or an empty string. + internal static string GetRemoteUrl(string repositoryPath, string remoteName) + { + GitResult result = RunIn(repositoryPath, "remote", "get-url", remoteName); + + return result.Succeeded ? result.OutputText : string.Empty; + } + + /// + /// Lists the repository-relative paths of every tracked file, using forward slashes as git + /// reports them. + /// + /// The working tree to query. + /// The tracked paths, or an empty collection when the path is not a repository. + internal static Collection ListTrackedFiles(string repositoryPath) + { + // -z separates entries with NUL and turns off the quoting git otherwise applies to paths + // holding unusual characters, so the names arrive exactly as recorded. + GitResult result = RunIn(repositoryPath, "ls-files", "-z"); + + Collection files = []; + if (!result.Succeeded) + { + return files; + } + + foreach (string entry in result.Output.Split('\0')) + { + if (entry.Length > 0) + { + files.Add(entry); + } + } + + return files; + } + + /// + /// Determines whether the working tree has any uncommitted change, tracked or otherwise. + /// + /// The working tree to query. + /// if anything differs from HEAD. + internal static bool HasUncommittedChanges(string repositoryPath) + { + GitResult result = RunIn(repositoryPath, "status", "--porcelain"); + + // Standard output alone. git reports line-ending conversion as a warning on standard + // error, and treating one of those as a change would mark every clean repository dirty. + return result.Succeeded && result.Output.Trim().Length > 0; + } +} diff --git a/ProjectDirector/GitRepository.cs b/ProjectDirector/GitRepository.cs index 6339ae5..6bf6a13 100644 --- a/ProjectDirector/GitRepository.cs +++ b/ProjectDirector/GitRepository.cs @@ -4,7 +4,6 @@ namespace ktsu.ProjectDirector; using System.Text.Json.Serialization; using DiffPlex.Model; -using LibGit2Sharp; using Semantics.Paths; #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -41,12 +40,8 @@ public abstract class GitRepository internal void UpdateStatus() { - IsDirty = false; + IsDirty = GitCli.HasUncommittedChanges(LocalPath); IsOutOfDate = false; - - using Repository repo = new(LocalPath); - RepositoryStatus status = repo.RetrieveStatus(); - IsDirty = status.IsDirty; // work out if the repository is behind the remote } } diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 02574b6..1991353 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -85,12 +85,6 @@ public ProjectDirector() RestoreDividerStates(); - LibGit2Sharp.GlobalSettings.LogConfiguration = new(LibGit2Sharp.LogLevel.Debug, new((level, message) => - { - string logMessage = $"[{level} {DateTimeOffset.Now}] {message}"; - QueueLog(logMessage); - })); - GitHubClient = new(new ProductHeaderValue("ktsu.ProjectDirector")); if (!string.IsNullOrEmpty(Options.GitHubLogin) && !string.IsNullOrEmpty(Options.GitHubToken)) @@ -110,6 +104,24 @@ private void QueueLog(string logMessage) } } + /// + /// Reports what git did in the log panel. This is what the panel carries now that libgit2's + /// debug trace is gone, and it is considerably more useful: the panel shows the transfer + /// progress and any failure git reported, rather than library internals. + /// + /// + /// Safe to call from the background tasks that run fetch and pull, because the queue behind it + /// is concurrent. + /// + private void QueueGitLog(string description, GitResult result) + { + QueueLog($"[{DateTimeOffset.Now}] {description}{(result.Succeeded ? string.Empty : " failed")}"); + foreach (string line in result.AllLines) + { + QueueLog($" {line}"); + } + } + private void WindowResized() { Options.WindowState = ImGuiApp.WindowState; @@ -199,42 +211,21 @@ private void FetchRepo(GitRepository repo) repo.LastFetchTime = DateTime.UtcNow; QueueSaveOptions(); - Task task = new(() => - { - LibGit2Sharp.Repository localRepo = new(repoPath); - LibGit2Sharp.FetchOptions fetchOptions = new(); - LibGit2Sharp.Remote origin = localRepo.Network.Remotes["origin"]; - IEnumerable refSpecs = origin.FetchRefSpecs.Select(x => x.Specification); - LibGit2Sharp.Commands.Fetch(localRepo, "origin", refSpecs, fetchOptions, $"Fetching {repo.RemotePath}"); - }); + // Authentication is the platform credential helper's job now, which is also what makes SSH + // remotes work without any configuration here. + Task task = new(() => QueueGitLog($"Fetching {repo.RemotePath}", GitCli.RunIn(repoPath, "fetch", "origin"))); task.Start(); } - private static void PullRepo(GitRepository repo) + private void PullRepo(GitRepository repo) { FullyQualifiedLocalRepoPath repoPath = repo.LocalPath; - Task task = new(() => - { - LibGit2Sharp.Repository localRepo = new(repoPath); - LibGit2Sharp.FetchOptions fetchOptions = new(); - LibGit2Sharp.Remote origin = localRepo.Network.Remotes["origin"]; - IEnumerable refSpecs = origin.FetchRefSpecs.Select(x => x.Specification); - try - { - _ = LibGit2Sharp.Commands.Pull(localRepo, new("ProjectDirector", "ProjectDirector@ktsu.dev", DateTimeOffset.Now), new() - { - FetchOptions = new(), - MergeOptions = new() - { - CommitOnSuccess = true, - }, - }); - } - catch (LibGit2Sharp.CheckoutConflictException) - { - } - }); + // --ff-only rather than a real merge. The previous code committed a merge unattended and + // swallowed the conflict exception, which left the working tree mid-conflict with nothing + // said about it. Refusing to advance a divergent branch, and reporting why in the log + // panel, is the safer default for an unattended background pull. + Task task = new(() => QueueGitLog($"Pulling {repo.RemotePath}", GitCli.RunIn(repoPath, "pull", "--ff-only"))); task.Start(); } @@ -293,7 +284,7 @@ private void ShowTopPanel(float dt) { if (ImGui.Button("Clone", new Vector2(FieldWidth, 0))) { - Task.Run(() => _ = LibGit2Sharp.Repository.Clone(repo.RemotePath, repo.LocalPath)) + Task.Run(() => QueueGitLog($"Cloning {repo.RemotePath}", GitCli.Run("clone", repo.RemotePath.ToString(), repo.LocalPath.ToString()))) .ContinueWith((t) => RefreshPage(), new CancellationToken(), TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously, @@ -339,17 +330,8 @@ private void ShowTopPanel(float dt) if (ImGui.Button("Pull", new Vector2(FieldWidth, 0))) { - // TODO: check if there are any uncommitted changes and warn the user - //var task = new Task(() => - //{ - // var localRepo = new LibGit2Sharp.Repository(repoPath); - // var fetchOptions = new LibGit2Sharp.FetchOptions(); - // var origin = localRepo.Network.Remotes["origin"]; - // var refSpecs = origin.FetchRefSpecs.Select(x => x.Specification); - // LibGit2Sharp.Commands.Pull(localRepo - //}); - - //task.Start(); + // TODO: check if there are any uncommitted changes and warn the user before + // calling PullRepo(repo), which is otherwise ready to be wired up here. } ImGui.SameLine(); @@ -625,15 +607,7 @@ private bool UpdateClonedStatus(GitRepository repo) { FullyQualifiedLocalRepoPath repoPath = repo.LocalPath; bool wasCloned = Options.ClonedRepos.ContainsKey(repoPath); - bool isCloned = true; - try - { - using LibGit2Sharp.Repository _ = new(repoPath); - } - catch (LibGit2Sharp.RepositoryNotFoundException) - { - isCloned = false; - } + bool isCloned = GitCli.IsRepository(repoPath); if (isCloned) { @@ -664,9 +638,24 @@ private void ScanDevDirectoryForOwnersAndRepos() IEnumerable gitDirs = Directory.EnumerateDirectories(Options.DevDirectory, ".git", SearchOption.AllDirectories); foreach (string gitDir in gitDirs) { - using LibGit2Sharp.Repository localRepo = new(gitDir); - FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(AbsoluteDirectoryPath.Create(localRepo.Info.WorkingDirectory)); - GitRemotePath remoteUrl = GitRemotePath.Create(localRepo.Network.Remotes["origin"].Url); + // The working tree is the parent of the .git directory, so there is nothing to ask git + // for here. Enumerating directories already skips worktrees and submodules, where .git + // is a file rather than a directory. + string workingDirectory = Directory.GetParent(gitDir)?.FullName ?? string.Empty; + if (!GitCli.IsRepository(workingDirectory)) + { + continue; + } + + string originUrl = GitCli.GetRemoteUrl(workingDirectory, "origin"); + if (string.IsNullOrEmpty(originUrl)) + { + // A repository with no origin has no remote to track against. + continue; + } + + FullyQualifiedLocalRepoPath localPath = MakeFullyQualifyLocalRepoPath(AbsoluteDirectoryPath.Create(workingDirectory)); + GitRemotePath remoteUrl = GitRemotePath.Create(originUrl); try { @@ -741,91 +730,58 @@ private void UpdateSimilarRepos(GitRepository repo) private static Dictionary DiffRepos(GitRepository repoA, GitRepository repoB) { Dictionary diffs = []; - try - { - using LibGit2Sharp.Repository gitRepo = new(repoA.LocalPath); - IEnumerable fileList = gitRepo.Index.Select(x => x.Path); - if (repoA != repoB) - { - try - { - using LibGit2Sharp.Repository otherGitRepo = new(repoB.LocalPath); - IEnumerable otherFileList = otherGitRepo.Index.Select(x => x.Path); - Collection matches = fileList.Intersect(otherFileList).ToCollection(); - Dictionary fileContents = matches.ToDictionary(x => x, x => - { - try - { - return File.ReadAllText(Path.Combine(repoA.LocalPath, x)); - } - catch (FileNotFoundException) - { - return string.Empty; - } - }); - Dictionary otherFileContents = matches.ToDictionary(x => x, x => - { - try - { - return File.ReadAllText(Path.Combine(repoB.LocalPath, x)); - } - catch (FileNotFoundException) - { - return string.Empty; - } - catch (DirectoryNotFoundException) - { - return string.Empty; - } - }); - foreach (string? match in matches) - { - diffs[RelativeFilePath.Create(match)] = Differ.Instance.CreateLineDiffs(fileContents[match], otherFileContents[match], ignoreWhitespace: false, ignoreCase: false); - } - } - catch (LibGit2Sharp.RepositoryNotFoundException) - { - // skip this repo - } - } + if (repoA == repoB || !GitCli.IsRepository(repoA.LocalPath) || !GitCli.IsRepository(repoB.LocalPath)) + { + return diffs; } - catch (LibGit2Sharp.RepositoryNotFoundException) + + Collection matches = GitCli.ListTrackedFiles(repoA.LocalPath) + .Intersect(GitCli.ListTrackedFiles(repoB.LocalPath)) + .ToCollection(); + + Dictionary fileContents = matches.ToDictionary(x => x, x => ReadFileOrEmpty(repoA.LocalPath, x)); + Dictionary otherFileContents = matches.ToDictionary(x => x, x => ReadFileOrEmpty(repoB.LocalPath, x)); + + foreach (string match in matches) { - // skip this repo + diffs[RelativeFilePath.Create(match)] = Differ.Instance.CreateLineDiffs(fileContents[match], otherFileContents[match], ignoreWhitespace: false, ignoreCase: false); } return diffs; } - private static DiffResult DiffSingleFile(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath) + /// + /// Reads a tracked file from a working tree, treating anything missing on disk as empty. A file + /// can be tracked and still be absent, and a diff against nothing is the useful answer. + /// + private static string ReadFileOrEmpty(string repoPath, string relativePath) { try { - using LibGit2Sharp.Repository gitRepo = new(repoA.LocalPath); - - if (repoA != repoB) - { - try - { - using LibGit2Sharp.Repository otherGitRepo = new(repoB.LocalPath); - - string fileContents = File.ReadAllText(Path.Combine(repoA.LocalPath, filePath)); - string otherFileContents = File.ReadAllText(Path.Combine(repoB.LocalPath, filePath)); - return Differ.Instance.CreateLineDiffs(fileContents, otherFileContents, ignoreWhitespace: false, ignoreCase: false); - } - catch (LibGit2Sharp.RepositoryNotFoundException) - { - // skip this repo - } - } + return File.ReadAllText(Path.Combine(repoPath, relativePath)); } - catch (LibGit2Sharp.RepositoryNotFoundException) + catch (FileNotFoundException) { - // skip this repo + return string.Empty; } + catch (DirectoryNotFoundException) + { + return string.Empty; + } + } + + private static DiffResult DiffSingleFile(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath) + { + if (repoA == repoB || !GitCli.IsRepository(repoA.LocalPath) || !GitCli.IsRepository(repoB.LocalPath)) + { + return new([], [], []); + } + + string fileContents = ReadFileOrEmpty(repoA.LocalPath, filePath); + string otherFileContents = ReadFileOrEmpty(repoB.LocalPath, filePath); - return new([], [], []); + return Differ.Instance.CreateLineDiffs(fileContents, otherFileContents, ignoreWhitespace: false, ignoreCase: false); } private static void RefreshFileDiff(GitRepository repoA, GitRepository repoB, RelativeFilePath filePath) diff --git a/ProjectDirector/ProjectDirector.csproj b/ProjectDirector/ProjectDirector.csproj index 4fa2275..b1b42c3 100644 --- a/ProjectDirector/ProjectDirector.csproj +++ b/ProjectDirector/ProjectDirector.csproj @@ -19,9 +19,9 @@ + -