From 874df3230e568c2446e8ae63a67eb0012ae9cb97 Mon Sep 17 00:00:00 2001 From: Lenrui Date: Thu, 5 Nov 2026 22:56:01 -0800 Subject: [PATCH] upload commit --- README.md | 90 ++ build.bat | 48 + build.sh | 36 + gitdesk-preview.png | Bin 0 -> 142959 bytes gitdesk.bat | 15 + gitdesk.py | 3536 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 7 files changed, 3726 insertions(+) create mode 100644 README.md create mode 100644 build.bat create mode 100644 build.sh create mode 100644 gitdesk-preview.png create mode 100644 gitdesk.bat create mode 100644 gitdesk.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf9123f --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# gitdesk + +A GitHub Desktop-style git client that runs in your terminal. Use the mouse or the keyboard. + +## Setup + +1. Install Python 3.9+ and git (2.25 or newer). +2. Install the one dependency: `python -m pip install -r requirements.txt` + (or just run gitdesk once and say yes when it offers to install it). + +## Build a standalone executable (optional) + +This makes a single file that runs without Python or Textual installed. + +- **Windows:** double-click `build.bat`. You get `dist\gitdesk.exe`. +- **Linux / macOS:** run `./build.sh`. You get `dist/gitdesk`. + +Build on the system you want to run it on: a Windows exe has to be built on Windows. +The scripts use their own `.build-venv` folder and don't touch your Python install. +The exe takes a second or two to start because it unpacks itself first. + +## Run + +| | | +|---|---| +| **Windows** | `gitdesk.bat` inside a repo folder, or `gitdesk.bat C:\path\to\repo`. Windows Terminal gives the best mouse support and symbols. | +| **Linux / macOS** | `python3 gitdesk.py` inside a repo, or `python3 gitdesk.py ~/path/to/repo` | + +Tip: put this folder on your PATH (Windows), or symlink it on Linux/macOS +(`ln -s "$PWD/gitdesk.py" ~/.local/bin/gitdesk`). Then you can type `gitdesk` in any repo. + +## What's on screen + +- **Top bar:** + - **Current repository:** click it to open the picker. It lists your recent repos + and has **Add local repository…**, **New repository…** and **Clone repository…**. + - **Current branch:** click to switch branches or create one. + - **Fetch / Pull / Push / Publish:** one button that changes with the state of your + branch, like GitHub Desktop's. If the repo has no remote yet, it shows **No remote**: + click it to add the URL of an empty repo you created on GitHub or Gitea, then publish. + - **More:** lists every action. +- **Startup:** gitdesk reopens the repository you had open last. If you start it inside + a repo folder, or pass a path (`gitdesk C:\path\to\repo`), it opens that one instead. +- **Add local repository** (`o`): type a path, or click **Choose…** to pick the folder. + If the folder isn't a git repository yet, it offers **Create a repository here**: + the files already in it go into the first commit (pick the Unity .gitignore for a Unity + project so Library/ and Temp/ stay out). +- **Choose… buttons** (Add local, New repository, Clone) open the normal folder picker: + the Explorer "Select Folder" window on Windows, Finder on macOS, and zenity/kdialog on + Linux desktops. With no desktop (e.g. over SSH) you get a folder browser inside the terminal. + On Windows the Explorer picker needs Python's tkinter, which the python.org installer + includes by default. Without it you get the older Windows folder dialog instead. + The same goes for the exe: build it with a Python that has tkinter. +- **New repository:** choose a name, description and folder, a README, a .gitignore + template (Unity, Python, Node, Visual Studio), an MIT license, and an optional remote URL. + It makes the first commit for you. +- **Clone repository:** paste an HTTPS or SSH URL. The folder name fills in automatically + and progress shows in the top bar. If the server needs a password, use **Retry in terminal**. +- **Changes tab:** changed files with checkboxes. Ticked files go into the commit. + Click a file to see its diff, then **Discard** it if you don't want those changes. + Discard keeps a backup in Stashed changes, so it can be undone. More → Discard + ticked changes does several files at once. Below the list are Stash all / Stashed, and + the commit summary + description. +- **History tab:** recent commits (↑ = not pushed yet). Click one to see what changed, + or **View on GitHub** to open that commit's page. +- **View on GitHub** (`g`, More menu, or the card on the "No local changes" screen) opens + the repo's web page, worked out from the remote URL. It works for GitHub, Gitea, + GitLab and Bitbucket, over SSH or HTTPS, and any token in the URL is left out. + If the address can't be worked out (e.g. SSH goes through a separate hostname), set it once: + `git config gitdesk.webUrl https://git.example.com/owner/repo` +- **Log** (`l`): shows every git command gitdesk ran and what git printed. + +## Behaviour + +- It refreshes every 3 seconds and fetches in the background every 5 minutes. +- **Pull blocked by your local changes:** it offers *Stash, pull & re-apply*. + - If re-applying clashes, the file shows the `<<<<<<< ======= >>>>>>>` markers. + - Fix the file, then click **Mark as resolved**. The stash is kept until you drop it. +- **Pull hits a merge conflict:** choose *Resolve conflicts* (fix files, Mark as resolved, + Commit merge) or *Abort merge*. +- **Push when the remote has newer commits:** it offers *Pull, then push*. +- **Switching branch blocked by your changes:** it offers *Stash & switch*. +- **A network command needs a password, token, SSH passphrase or host-key confirmation:** + click **Retry in terminal**. gitdesk steps aside so git can ask you, then comes back. +- **Another program has the repository busy** (GitHub Desktop, VS Code, Unity, OneDrive, + antivirus): gitdesk waits and retries for a few seconds. If a `.git/index.lock` is left + behind by a crashed git, it offers **Remove lock file & retry**. + +Keys: `f` fetch · `p` pull · `u` push · `b` branch · `s` stash · `t` stashes · `d` discard · +`o` add local repo · `n` new repo · `c` clone · `g` view on web · `l` log · `r` refresh · `space` tick/untick file · `q` quit diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..5824582 --- /dev/null +++ b/build.bat @@ -0,0 +1,48 @@ +@echo off +rem ========================================================================== +rem build.bat - builds gitdesk into a single Windows executable: dist\gitdesk.exe +rem +rem Needs Python 3.9+ installed. Double-click it or run it from this folder. +rem It makes its own throwaway environment in .build-venv, so it doesn't touch +rem your normal Python packages. +rem ========================================================================== +setlocal +cd /d "%~dp0" + +set "PY=" +where py >nul 2>&1 && set "PY=py -3" +if not defined PY where python >nul 2>&1 && set "PY=python" +if not defined PY ( + echo Python 3 was not found. Install it from https://www.python.org/downloads/ + goto :fail +) + +echo [1/3] Creating the build environment - .build-venv ... +if not exist ".build-venv\Scripts\python.exe" %PY% -m venv .build-venv +if not exist ".build-venv\Scripts\python.exe" goto :fail +set "VPY=.build-venv\Scripts\python.exe" + +echo [2/3] Installing PyInstaller and Textual... +"%VPY%" -m pip install --quiet --upgrade pip +"%VPY%" -m pip install --quiet pyinstaller -r requirements.txt +if errorlevel 1 goto :fail + +echo [3/3] Building gitdesk.exe... +rem Textual and Rich load some of their modules on demand, so bundle all of them. +"%VPY%" -m PyInstaller --noconfirm --clean --onefile --name gitdesk ^ + --collect-submodules textual --collect-data textual --collect-submodules rich ^ + --specpath build --workpath build --distpath dist ^ + gitdesk.py +if errorlevel 1 goto :fail + +echo. +echo Done: %CD%\dist\gitdesk.exe +echo Copy it anywhere - a folder on your PATH is handy - and run "gitdesk" inside a repo. +pause +exit /b 0 + +:fail +echo. +echo Build failed - see the messages above. +pause +exit /b 1 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..e555e47 --- /dev/null +++ b/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# build.sh - builds gitdesk into a single executable: dist/gitdesk +# +# Run it on the system you want the executable for (Linux builds a Linux +# binary, macOS builds a macOS one). For Windows use build.bat on Windows. +# It makes its own throwaway environment in .build-venv, so it doesn't touch +# your system Python packages. +set -euo pipefail +cd "$(dirname "$0")" + +PY=$(command -v python3 || command -v python || true) +[[ -n $PY ]] || { echo "Python 3 wasn't found. Install it first."; exit 1; } + +echo "[1/3] Creating the build environment (.build-venv)..." +if [[ ! -x .build-venv/bin/python ]]; then + "$PY" -m venv .build-venv || { + echo "Couldn't create a virtualenv. On Debian/Ubuntu: sudo apt install python3-venv"; exit 1; } +fi +VPY=.build-venv/bin/python + +echo "[2/3] Installing PyInstaller and Textual..." +"$VPY" -m pip install --quiet --upgrade pip +"$VPY" -m pip install --quiet pyinstaller -r requirements.txt + +echo "[3/3] Building..." +# Textual and Rich load some of their modules on demand, so tell PyInstaller to +# bundle all of them. +"$VPY" -m PyInstaller --noconfirm --clean --onefile --name gitdesk \ + --collect-submodules textual --collect-data textual --collect-submodules rich \ + --specpath build --workpath build --distpath dist \ + gitdesk.py + +echo +echo "Done: $PWD/dist/gitdesk" +echo "Copy it somewhere on your PATH (e.g. ~/.local/bin) and run 'gitdesk' inside a repo." diff --git a/gitdesk-preview.png b/gitdesk-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..e65c64a3e1b148f8ede20b1bbfe43c48e627320a GIT binary patch literal 142959 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�FhYYlY6N(ib$?g4&$#Kk9N@aXJbPh50Q^ z>JExW%EC^q(R0JTK#_6G4o_XkeBmdS@FPK>isj$E1!ZcM?CxbXVSO_?$ZA?8+dkH# zDsAF4+tgrMx)K3=;rENnKHbei!eU}Mc8@sPnM};>aSaFBqjSp-^Lxa?!J=YtLTjwb zoS~F!{@I_cbUYDh+W6^7`Re1cd77;>cejqQlgVD%_Y0R+L?I?>1~u2i=k?N>dKrz6p)xw(Ee)qvu;fJ zRI##_?o68FPxI~VWrOs3$w{tQ$Gu>g(m#hA$5!9Bx>bBuE01P}bYlp@!3i@`HMeGc zlud1?f}O;ztCjiJaf9TtER7s;V-#1iMRYc7)E%BJBe6DORCBIyUZ)djDJcq?mV*5968#<}HO!i|{*juFn5ny) ztpzkxYWcly^8_A&)0upH@sA3$n(p;DX5{2Nw%o0S=2k(Rxax|z)kB0!*a{$c9NVRV zq@qh>6$2|{c50BlU=NR*J26Ku!Q#yAO>6#vGDTB1|LtLcWBN!_RGCFz4J8H^7A5-1 zaNF8lWO)WyDDsZuJ~6GhuqYJ5i1%x8j%OB$x+$)=|B;lzkJXyeRv!h~TXU&!jdc_E%3--!*C9dv!R^b`MwrZr{;&J=s!= zkfD=ZY4Mhyl(}VM6e>1TZSrb%Rr~T*Nz1d;%1Hv+!2q+6Wl~z?Wn3QmU006kXw`j^ntCW|eb|T(59RHM8_uOkM z87IIsJBYT4w&_aFS4ZQ?EJ_0|*I~V~3F}nU#?Qs=ber^KD3ej;Ht0*~o}9kj))rIs z@$85cMSAO=8R>!|BXsoEv+C74H`6lBX5LUWg6On6Gi0Jkv)sPy&*lPhs*cso*P>4x z@aGKn4_k6)44&S3Vz<7)nlf&&AUjYRaZ)tDD*UWwef-Ab>fLopj=fEbhrGkhecZq;NA{_4!>etCKD-TGoIRe@DZ2EFYn|5dz1^^%j=kwNgwQvkfMd7#B%BbNf?(X^+`ltHt7@yjv#sWRR%x&7qsDw z<3fnX4$|*LNW+UpcvEPAvNL+}Cvk$|kF>zV$@Ar7x5vT7ZwjSS{sR)7=25pSB(J&f z-ON(RGp}>GH?%j{2O>Rc_20X8YV7@~IR7A@&LCeFQOU*?PC}~736NQm%Oq5Cc)hN| z6>kQXVd6~PASIJlQW1dRBDlPlxX9v`tC(tm2#LjytS_H0N4)x`1jszSma2`76A^g! zN$^U9?96#df0+R2>h3HL_2sKvIx#fl&b`gF%}Y7Mi%zmD@d?2=Pz(4VtYW$ zna72k1y}X=4LmTu#Fufk2X5fb{~*DrdP)G56@|9xA>+s0eD`mnGy%r7=~cg;0RKNs zBz#l|Ld-`x$VXa|XXCLLv8cGQzr?@qyLt)>^v?I=Wilu)cMrbLjsb$f^!47EBgNI9 z^){`)k`X<`y@qM+6>sZs>w%_#+C*yvlfklKVx7kADpqBke=zs^B&g0a@9#Df5f$t8 zOSeclEO&W?59Fs1Kmb~*8B5DfOAfc= zV|g`wij49+xDf+jqU7YmH4ZIEruuWm+2bC?rdz{85qb=x`SCH`Mynr9hQ5+A@$c`1 z6bjqS=-3&O?b%tIgeI2AbVM=46%ODAczL_r|%m`UG$dOt-kMUP3Etdend_u^oRtX>~gc1}Nze1W>5 zRncr#)-ao4sDW43m_%kFtyf>45}Tn&$#?>)meFmQKRKqu0p6Ou^U8J;MM9CDk}Gu) z^N)kxoWBhp4&k(~Jh{$uaUz?LujYRzoxlSP>mNo% zgp%_b7)OwR1w0PHiYBFtPYAMgkt}}r^0>&Gery7p(>Z)`p2X`(;_yP9LZ|d5mf1*>DuP~a+gOMLZ z-15%he;;^eg?`0?9P-I%L@H+D74RAM4zL^iKz@IxnWy!p;wd%i+`^nS_C01?+#dLv z^s#JDFCC)OpSnOw4vY{$#l3Km&KMwPx8Pr(>Bz=l^x~^HV6={liKl`XMtaukI{<6% z;e4KP`P9E0FHZbJFbzJPJkCcryYT}x=aqaW7p$X*GUcZuqxTQT3-!xg@XoHhjx02I z#7Y#uOAP*W6zBtCWBWmO@P9a7pwD>VsTQ1s>&Lph=lwVv9oTT7enU7$)Lyc#$dL=6 zYU&a!#%OYi2^=%&8Dhf{thP=UOC6K5Q^}T!6eUfx6Sh_i-C>3lG&24Y>4aifIR!mSdoi1p}GhDnmrJ743tO3p&czX2RrZ zSGbH}$o!oLp2Qk#-S^K%?X+oeIC~Z84IKX(0>zAT@oo^>+ga!EJ}^~C(i5b zbDT=z#IEUVfpsV)gU}<>rnDWFH2SV*!6{M=%2G*#2OdeC%G~_bT!wN|^J!CM77amF z!TmRqy_Yx=x8D}U7YY~I^?x$QfPAFG#{E9e5u_>8F|t_7u16z>D^wvG+>b(@j$;h?g=kp!x+w^BiXpj~)yo}>iC zn=4t@TPob2FMTxp-Mv%BE9y&l$T^L{MFIo)odgL+Xk*m*0*Mp6j{TQZQcDVEGdr#1 zsR8?!K>#cO$n}$`*!b82zSVyj1o)`W!JY7lNl*WmK>+MHAfJ*1*pv_$CjJkD0GU3W zvjAkIEI*$Q-&J$aMpzpMBb*dEkX>fM&Kiz<4_?Qea}btXtDx5Wc69}vlJI~9(5nQi zuO7Gi+Lf7HhZ$!BTjDNXV9Y@Ok>-T+arjHZ17B7YFws-=qaI#xvR>WtyJVyAz&xY0 zF~|d$XY?-#53pTf`%t^ub9BH?qKEIlAU&S7P7lyJyK3W+UEZN-~Er=*mrCn%}Bd5|n&plicO&%PYeT2UrXeoBamDMMW&mh9!V` zd9a?vXb5+ZM0sHYVWALm80Qb`X@>eq=75CxB^(Uv$UdFSoZuzvNIrBJ#nig(puG3a1CA2?*bnRlXXga6@P`XXn#ffu zA{_$0zP_x_>uOJ8M-zC3%x~=gg<}CB5rKCdzc05X*gl4;aR+qYnG}{POG-|<{lvO= zcd9`M$FcXbiLXXKn?HQ}w!J(|;W5ym{@LAv zt%trLtN>!ETw(pOePv5jbj$Mor^!2!CDSu=H{vyORZTjYiWD*+I$M8~KA!IjsK^ZP zvln0W_$G*HQUE}CaLaR+Mx{{OaN8%kLM~VJ1Zrww6pxYdCsZj@HB>A*i&44V=aEuy zM5WQ#zPH^*1U=bP;n9%5o^xl~pPYuc^54erL4u$}%4=844!NI{R-_Ww( zRf(sV#;So7g;j8|$(Z@{pLmkQsZ7op`$4~_Hy*M7N;|3%0Q6R8%db5!P#Mo^8h^pITaYx(3Tbnd zvZ+c3Vvy0A!Rtx1BeJ}4cX@bas9->ibQ=Q~mr_+*M@P$Q)w*^5ejsVo`_Zs25J@eI z3TZcy`mmaOLzzlaQZn#}$bI~xyQcw<&3tlqYGx3eC^7_{jEnfqqHAL{^YQXm0PlJhe@J~zj9p4<8R@F9MwU?>h!S9f5Zif zVcUHxr9C>>?zi0sAhJH)!;xbBnRGuuc;t-Jzm9$9a^Hsoj1)ZkI=og%oK71>Ufve3 z&FKej_9vN{4xT@LJTR&A84MfTER^}Vm#^MVJTIZDJ(kZpMg9S1r~rNZNW58i(vMHAo16Ff?~qf|sFz#rfy14_fh9?A z^ab_jd>(1xGs{bdwm(8go4o1i(dW7KSX1*Y8rtN}GEJItk49$;!okkMSdKB2dT8WW zF+VG*^oJ3k%eKQLQWOX>1o7NHX&D)6`Sw4-EV~Dt zkl%Orrp-*Iu6!Z%Ie&fK3d$rPBxrK~2^I}=QGkY!DgPtePg#(@2&)$P9J!!~((oU} z5FM+D0w&Y7{24U0h0f}+TKFTCHn-a>uh^a%e|Htll*<(s7j9`3u)!=7)7k`(TuLgT z{39{cFCy@AS4w^05F|+eLiNx+Nk-Y&$L4dC<+sM)+!dB9N%boBLs*e*#c%r-??f^R zYNNa{>S!7(sd-LLEBQ>Q`Q7;!tj-)`T>Y6CaC9dz0^y6% zGP{oQEnJYJp-fgTF)kS~ie|M6un?3_ zSj8{8O7$DxtrImBDLE2`gU!~AwEWh{8i@}QBNpZ-htSn^kDK!N>0sK+MH-KPs3D>w z{2lC#%Os5cMB2(~YrN84a9I7Fw2~Z2;831sstD(1(#CFS>B?C77vH2utTx$-C5}J} z9xaqM5>GN|dintx1Nt;D70Bzcx3C1!eFY(6LTN(`zL<@-o#9k3N`B%ifQOM$`s5)BPGh2+2)fmbadSwHMmjFXe;?CjdtF*9|J810(!G0CZwESI~7LYW7xtfR$9{jt)s zVuYPJo$%ulXWvskdKov7$M3s1nx5D-|K+K`J&)?`NL7kv%HCP2#Ta1l9^j&n4DWeU zqxCm+wB)9}oHjHYrL=o#<7$BsJsIB^F1u)N=(Vx=i-7%Ej(p2y)Y zY>u1z48hT7tF#%Dw1iIshvX$BR8+e2G~IyZ2%4T=ic)FXWRq3EyN_3}v*Y?6uOuPg zTzI?K)M>Yg?TB4BhQ*|)EyHok3nH0lk*?8s@SO~vMjskO5gnfvtJ7*88xXZRTBe+# zNcDg@!}L}Fy5RWu-KTp+)k}&LftNnbsjD-KuAznaf$&#H=31P{G-oQGW?QOv?`>X5 z5v0U@h?+S?Ce=#rB6=Vialu+TZ@?4{F(%$_hH8~sY{=|xmiH=~uziwMOY7sY3Rxwp zGFF?taS4M57%$m%gO+A0ypumP$jB%J`NzyI-yxpSo}ms1R7oZ$Ctrz1bkB>6li<@2 zm)u~AnI2`sWaY@3j&QHj6EJtoYO3;q#6*S3NXR6`SX>D?&spj`p0G9J!!r4&y2>s> z6K`UUu+IPc{!Xy`Dri9lZoZS#8;~pKgOrcQtq#UJ955R99`ZdFj#AGbDG^^SEggJ7 z9pLlw@OfhVEVl^zb|p1wgHDN!-h|1>Hr@bJ+3sL*`EYqn8d;Ugn>Yq2BM~vl-N*FL ziOW8VNdh}KyJr-QH+PjRi|WTw*Qlh{nC(<@mN{Q8d7-n(1^X!q?++a z@$$om3nbFls>h1Irs*B4I|+kByd1*h|cCe4l6ZRe;Jm~fs{e|!1+ z$JEZs5M_8yQrr7c+Qj5wqK{o+eTk7#0*gyttEG@X*7@%j@~dq3-0tB$KmL>#D9-Yx z^UXCCb>qZ&OV0;1f?qr%U;XA;_^PeLcU*mBb>Oc`Mw9EeH$Y8zQK0e-5ctmRWi1yh zzhpF6YCB6)bHZ-7Qn}~UcDS_@>$GR=Hj?TB4)3n~>pCHVI>+fi$xR1e**V^ zleoJc?ru<7X$Pe9$%Z^rr!_r(-PHRm_$JuVf@cfnj>#LG9?&RP8tr*0)(BQp3L4E1 z3^|oP>t_=b6l)(T$<4~DG?A=g9d`%7ftC!t zWtbY6&^=j8l|;fEDiy5S zX7-5qGuIIp+aV#l7z%y=e;NC z=q4k@qEc@si;bBVf4V*<7Rq=0KAWS;n)zf|pVOj36@lrktlBIAHYfp%j;+Z}O6t{g z<9ZvE2MC`#SQxXbBkDVp9>l3}U&&~kVxt7wl+rC4?lNYA<6U8JJZ!ms16;%_z)t6L z7t}k=^hWpFlvvpqw&wl6-?)cE_U6mzqdNzkfd@ZIbWD{iEz9aq`&jd7oF-y#9j{~O znw3m~xWJO-_Emd@zjQr;1rgP6EHf*AX(*D(>371A#Yy3ik>d`z{}(XV`ugN*>^k)n zWM!)yzo&fr_STyvUP4AeMP>4^dqyJcw#AjgpYsRf|AE0NUbEPV%*Apb$p1rJdkMN1}iFg(WBp{$|E z0eM+Pgh5i(f8j32Tf68%d2)Br`Ajab09nOM3PWP$cjJ@4cfQ#h3ILQHSrR_8&wxIB z3*LMClF!I9&|3IU_w^9shw?aJkgQJbLE^?qAZxtf_blMwGGAY^t{RUUIIws!V?2CL zbF5dsezmL+`3qb${;xz%eDy!OFNylDJ{Wv61Y%CWUj;SFpa9cmHH`U6Tr5gm`~Gwz zhlj<*oFe7tfB`IYOoDSb>nFU#QOzTF(mkT;9RzCrw!E?a#Z1kAWC=i9B@bxxfK# zw6$H)PAtVLuauO!zQauS=YgcY;tZvVUc*!=3EAYnVx|z#gN8^aZ zVVu1wE4eK$MFyn)Q5Og{NT9fS;dpFuDl+6^#W!O^2AC>Fqx~WTo!?yjj&XvB2v36a z1N33lt~?%a(91CPbPguSf>e}O7oH}4L%$GGqTp>zURv7|ciPA*tcbU*$DsyA^x=iV z-Xw|jS%u9>;j>YqTV6|6YC3HunyqTM&0go%pagbJAMt0l5*dpsaYR@!Uia%VMx)iq zo~aKj5-Mt5jLeZl|31(7y*za|nWT+5vhs!N(pH<4qMC7f5Nil0@kN;zvBab9dsjX% zK=QERoj%a5)1?nfNs`LS$%Y71P?F!wl~Gf|3qo;==T|AzK-o68Nh;&+~^QF z6?=#kmNbCNV$~L~E2B}Kh;^|NV0a5>Ey1r74)DlhUq0b~et`Y@N8LH(J`C|mC=UGj zZ?xco9!Cn+Tn+%4e(iSyt%biogThEyt`2_@fJ{7NAqB9)?bCO`!AIY{cJ@CoinGYZ zD*TJ{Ut9#RG#oCT;d}#NbdMz(&(=16)b+5x&j(ar0HyglybS?YbEe&VPV@Kapuy4r zb&q{V58$R*R1J}IHu%(fl|MP6Xg zmk$vJBjowv{{;R%t zEKQqM6w-HYZnX;8wW|p)Ggc6-O0N$~LP1NWi;0goQ`nHMz?!0W@H$LSAa68ns%Wcftbb5^4FZhz5x!w|7R>;%ga5~-B!3x z(@P&wW{}o(c8mIpS3JbwMZ{ZNvlF?B^b0A99MxLwtKSA}V2F$Nn&}&|u&F391yL$v!1X0(m=_g7GD;KK38` zvywU`8!YC0$n7Yz?+M`!Iq@z3G~m_`HQ32QnIYRm(tgh9+T}#nkQP(YXXenUmc6BnmeGA`n7Jwm{Yl{Lyo%8Kcf?@x34Fddksink9E1!w{d$;b2X1#$BJMfddN_!C3(7VvU_I<~ocxQ$#{wkwmms zC^P?=*|6#^a235jSpOs+bU=Rl(&W5C@q_(@k6Hzunqaf4c;Wv1t?q(zL4~DQUq{xR zmG+{3p@NtD>p{c;i_ItOMt3?qf9`9fTxr**aPY?GBhOPno**8e(0|_x8_W8ez(4GY zY(3Kgi}mjZMCxgQiVAEGo5 zW>EEKG%GdfWN+J{(cdP9pZ&lofIOHbXJHi8g@;=^beUL&+ZL3+(l!9KPr&@&!^c@- z+;smw8T17x-6+KKgHP}F72CT$B>W*s;DX;Mr^**!-@uq_UFZ&fcc9b1^iU*RIZKi0 zxg~UVI|%@7)@(EFH~$^>1gD8{T3Uil$qz|MKO~>JCZ`ZFS)O?DaZij_1q45WS;ey zYj=0PtF<9Lj@{Ohf5hlF+1ykccn0R*CW|vgXFW63W^J}>bSxox-p?O;*gvY9L$Rma_PLUL2^Ehs4_0I zD9xoIAYdvo`JMtW>-K1ta=NLvQ+&5s4FJeZDvs^cns$Fd->uR&MSL|zlh5~+?c{Ec zc(~={T0@aR_<{i$sYk*lv`4i=A8^y+KlQQ^Elg*@kn!x!oFr>SLeFx%!b9Na5aPHTbI1+Pp}>KwKt|-I<#^ zohG>0E2I;sKPbuH{8pQnZe;qwjS0iRpdhmB;S%Gg$N;Y=Ltp91)Xknf4dKA2e~YMa znSuu_fi}FL`zuv;JI%B+wKtrITDu=ZRI+pUv*-64-)>u9$6<&KH^`g)zD)H}E= z3Sx3uN>!G7H>0JVer4it09)tkz3QT~*3*zAFa) z!|%S=x0##GwMTcmCoPwinM;ZejaU$+CYAEANLq~gf?SZesHkd%<5Yk)CDF;e*YGqa`-H4mCfTja8ai>XO7`Q>@d?5Bl{!$M?!U794eNhRC2=#EF-E;%`6z^+Tge4IQuIH-gk5<}cm#Uv~n z3{4m_k;jlfYz`iaBK5l5Y%guzF$%qt1j*Bpfv@j7Q3mU!t{abSC%79e^T;v@|?%rQUS({ z%KC!tl&Q^=?>RiXw9IFA&85^Na|WQ&cMtPOc_pQ!16!I6#_6Yp@AdwT8&7voy&yR6 z`+Dp!Blsh%4Gokf7Ht5KM_F5kN;&Tvrn^Fwj2$_2(Ony{PuP|2jQNc5xjtV5S&U zLreA6@|%TPBtiAlEEp%L4Uo8@0o7>G9%b`s9svD%1zHGV&R{yVM#hFjP=p8UZ?A0a zOcEDWrzvFkU+hn?K$72?@pOlZrv`gn_^Q+4UW;k<$$o`?O?ym%(NCcPJ1rH73hUKy zMu*vs7f#V2FCqTIac^sfxngHYR(K$1;!j@*hy6)UM@>gKsNdoc+~u3ZG4O{_<)-aA zv{&l)=r)=GIQEE9HwRIumykJQLQXS%L-jc(W-wiHd3sWzj8b}fTIu_JFLfeULiE7) z%*=a^tEQr#6B5dtp6!|pTQ2Axf8#ZnEna3e&y(8+h^aw39sn5+UplyoYylXy(IdZP zMj$0(dzHq|)o%OoVWxQZyQc1sTx3N!m(CqPxjRZP5%&YuF#eAb&qHEhhQ6ue^L(WHeXr%_=`a%tI457)0-*!nzvTo#Vt$B5%qU6NLka#48?F_ij8h6fcn%6S0W6L2tpA(>RiU|$x% zT)N3X`ov3KK!U`J$v6E!XdDngN+duPfCUM?N&mnDoG>L682=s?v43Lju-LIF&r1cZ1XXk4e3dJR$2u*gTC3E`k!6CjgT?k+cc@A3X}dkK`1{_5-A9 z;~Rnhv5;Tz;N?Cz7X#L#@ssm{nT!7XLDc}L3aT{-JieKo18?>b-nxg1yJ6;X+^El0?Ov-+2Qvn|uAg4YEN{cqR&X;7yDc!WateC%qIVIkfx zPdKXtaWLC9mzmFnJFYkwE~qd%CqiT`#uY>Q#Ry*RbQ%B^kT@?=Odv*iamzQrZasSg zDhcchY9)qUi06Dtfh)L-KM#R$<5Vu*^4Xnw5O}`7o6#4T%G&+Ha4Hz1>|ee-m~PL( z=S7qbtp@B?8+e0%C#ob+LnCQsjGD4~BsN{?)g$W8#??>Sb&Vcc4MC#|Ht*Pza|Pc zoxsSrz(9=mJ@s1x|7w83z5+lo6@i{77yeb_1G^8k2_cIbmNNJ>TmAeWHXbLw%Jndy z+`9XJD6qgpKw$_BP+$Rfu8z}MT(9h%#0%8 zDr<`CvY?P5lfkZ^%*q;Cq?>!4j=)>YQ0T~XjErnpA{(=4KLE~7wP*L7*6{RDE8gZ?!#8FxUu*KluKaK8Ibi~ z)9$NO`$VBRuR5`0Zu9!okm!AZvpQeq_@l6t3JMea>;?Dq4p!XX!UmuK13)3{Hd;Ol zqin6`5n6N}31O8dz&jtmB}%JvB3z^tAu^oI7bvMIr=;C2V$q%g;oD}RDbOjQ)yci1 z&3AsyrzXAi<3w4VfMSyDCm?6?+E)^=o_o{W-}JuiHX^@sqG-UJf4j;Fp1oawp2hUkBiGKlid;}?_tKS`OtdTziLTuI7s>RzCv3i!XC)m~t@u(4|+ zhWR=yzbIgZ%bi{b$77nO)p4NIM&~5rMiOT9`S*aC9&r6L*p`t~A4TuYsS}W{eapg& z)`*dky&fN3Oqia*TYT3C%_!|p@SA^pduL91Q zhk;1ik9$b7?uVFo%62s|T--Ta5v-j6p|E>DU&B}m)q0dsQL`${jxbYM2&Avi( zEtrPswtI+wJM+Pke-g>JQE zK>HWFDXMixK5c@AxYwr*=oX1K8okL^0360=40VGQs^9Lb1}<9X_&=S(xg#XVyS4ww zr%ZOlXeD?uLu>U!p6L%?|2 zISn=ed&1Rm3U=~#y@H`o6Y68fwQ+U%<(8)0Kk3@|6xmJGs?o|yGAjgRQ~Wz}6%LBU zS%j;h!3T~inaefREc^{ z#%-PyY@IG6;LarEAa5`<`AawGt*B2-GFkuhV4c{*JeVeoP85Bw7~OK4U2`_0Vu;w? zvIENGYPGJP_1M!qtY!7G6U4oVMVh>HIbCsSckV%hh_L$TOQ8f4 zs5IB_himSq2;#R^nHyBDrLFjMOW!DYwbvN(y+F!~PJag{rd<_1q&<1`Y0y&W(r^-2 zc>c$H-~33ag7Mz(Ws%odiA7(%+L#|t&o7(0q2sk28WNx(HPWoma8Dp~D8^i?1^u4z z6Mw755(ItDCPbOq9O|j0?}GeV%~@#ngO99Kd@c+zNI=6RqEAax*8QBYU(=rd|jcI2Rf}QwmUA@dN+Vv#f zitMv5qffO#P;>>v-ew)<_K*c{MS4The+kj1eB?sc&zfy9C-y^bIMz$K^>c6Cqu3YW)iVQ-1 zfNEe>4-{jlf!3j01gT1bG&7}zRApCo>+#&B8T088|_?5o@F_lpw^ zc=)DE254}NSv6;xp^yd1$cU@QFge&dt{j8&QTMF`-t`3~JZ#x$i6ZbVgWk;FVX4+n z=8clfj*~1;ZCu| zMbSdef^8e>jbQpEpnN)TwFC$%mj>^fTpYSlvX)k&O4Yu0K*66Wz~NXh4Yu6PPv?8IS%%na*K3SLO*(6CDD^hCbSW8xq4ZaV_B zWX=6-+PHU*jzi2~wN-G}Lev)jEvf(XeE@c`(@?g$YPH|bbotkkWnUudG zV0{rRrW;m*I75`$ma8%-%z`FaLvAHI0&Kd&%ZF0a4nKW%bbn$SmRp6q19tqB>igHU z!*s7x99yJ>+mz{gSBca6@W=P)Dn|GCu*oShv-ViVe_UJ5^=~Z~d`)2WLX{b&rQyv? ziyM5OOUpW3cDR4bJJfYQ;upwV23xM9;M5}!O%v`yJtrk;&gma;flA;XyN_hVx; zpC^9oNy@iSh6DoZlXk-*PzA%2bq1QF4zP7InsyQ`s$P(3c0%hnMcJ~XrBc00dl3T@ zEO;i9N|3Cm2q^TWdZhHXsRPRVB2=8=uSU7}2_Tf|t*O!uW~|rhr#YhAmqyv; zt6y})a*y;5{!nNj?bZ6maadrp9r2<0n&w`QYER)CjQ?z;DVS*+lcoA@_dL5WqD|!E zr^GR=n!f}mwU?>8@dR=SS@O>Kq;bu8y-ciI>#JGZsg#o$qKG6SlU_bt5{XKOKhW!@ zd#L*VM*Ip+3e6rHMG3L7`W= zhH872Ti2DNCRze9R=+?>A)V}N$g+ysxnEzt)y+3VvkrU|!k5)f`=!VF>vU?% z@2yPI$JTUhZj;_^d}+oBIk0Sqol-1Gl%Y0mH4`YA?y8a|X68Umg!VD2wG)?B6DhFJ zSh{{9lA$jTP1H$k-Jf{X&B8!JUALBXKf56yYvbMlu!|mS05f6TFx2 z{;2z{)~xx8P&H>6g~bIfe7g7(he@M*b3G(?O|EiFAm!!_aY`DJ|#+*)skQiO{(VRZ&Rtx)@q4a zr}LD}j?yq`zvE4ds|5*|Y(ol<;mU0+OL~xZW1{aLp5$ivpt{mlPdCW+&2=aiI1L9>P0+MwhJ>!Se_kaoJS-#v zUe{ht?)c94Nj5{9AQtw4q#jwBp>qxdGXO}m)d{VSqeiqC?xhy$=TvHI7 z>|2vGtBNg^+H9a6q58_=XSM0c5G6yhMN_ZX$OdJS&X`QEU&oUDPRHW~0@hp-y#$}I zsuC4CXo@ZLR`1P4Gw8U7>4`PHyy1f<*fyqSeH|^^$CXs3?ZY3247X3dYON-;@frjk zCHGCxj32Zmuzffj$#5_~?g&Y^c4!a`@xhURS_d`fG>r?+aZopn$o|?jho{8w7?7t&SI*%P#)}Q?g3F8 zfPrtqq}p#*TpZYPxz7_SIu{ZAk_6#C|ATWr-}!GY?=Urh5ISJ*0z6kc^+F-wbj0Gx zYR_t;55ch+Q%sMp*v4}q1^^j=LuEeD`4>i>iul4F`d5hIbo4Gx3;@5NUJ+sd?d^i$ zxx>~`Td+XqA0Y;CV*v~(*rwJ1!Sa8E7{GJVfH-$b4hr-Cg}@=-f(_m<3C`u$y!iT@ z)3*?H!8ukRri5}Y{_Q*fM&2dm%uIvfxeUr}um9NsPT@Ca&J7rz`|lwE;FJS}QyGc> zJtP1esZezGwg2akfKQ%qK>PxRwcx+HejABf#EbdW+~e(nZM>lLd?j$a2Mk5R4?v`# zY+=e#l$4W06ZCvb-9=gZo&Lfcq2jv?Vp~A?ki;`TkV>e^E>lCMnaL98m&S2URTn*C679@Y!jx@$ANf*F_D=i_*~Z=nd?^X z-?W)}^WD{Iq5CJB+Xfld*OK|9@q={!3IfJvE`zKbh->Nr~^fAC#1*8bW_pSNRc zC~xyd027d%l7swlxnZxL*W<5O1G{Q9Ce)a6=&an zKik1^vRbv1A{Nd9>DAkL25P@USci^}4k~mR*I&(J9MSYWvh~bq^K)(AD3lg=SdF&P z5sN*Z(T!~>+FyFnk#!fL&vA%v^EEZb z+*~0yS&<}nlbwHMUqw*RXe>^D)MnM;4n)LMVop@0%($Z1oLZ_!n5O!D!*YrDV~4^7 zvO5ucMn+3O6>a;tVztS8zuIoqSVEbD;>OW&n=dnSB>lQ;dR~skE0LF}t*80Y+Q8a4 z++e{c3qA)Ws;fOvIdN4)s$AI?x%GtiopD^c(Y88(jC3?#VmeX1uJn|F_^0NG*;Ku% z{o2pRo(N$>K$-rnvO}nu$GFDGUa0Yca=BIPK3&1?U@5x;L^->UQa;-<`3^4QZns?a zi#LaY-(By>c6?t@(9Ky}TYD|3rbuv?xr`O*G2^eCbm=FMKXojaY+A35mI?+`Y?cQm z?3d$3QhT0l4H6B8dn+1!oqO=-xbLT&5{0&|&C)2tbmP+}4%7abx#bkUu6?$1L;Co8 zfixC}b-VQXEv#2ttuJ0U#`s4B*zeZ5tU!fCg#1{CfVOjmuIs*S?8er$-0mXwe6Eek zl?cOWf;T<0Z8XOR$-eA+rKP1>yI+16LB539)hI!4{`#ru!@QqP8sjJ5MpL!9T4Xpd zZoWZ1po*^adl&8*qk`(pF}d$Tv6cGXK+U0CoZ&#uanYS3Ioerl+pyvVBXiwq-HsIk zZr4})f4ED=Ach}jZP$~U*b4GZ%9e{08@$4w>q~tc<`bEzpLo8rG`=x37dtmS+s}Z~ z;^to7f`58k$}Ja@z^#ut(n^n3zFhNiOc+W%>>E&T@th=$vF~0Dw_NQPRWmHq>-wg7 zkJ}B80Z3Cm_Ox%@EE1afK(`joFx6VEyt8wwJoqHDZ2r5Ga0r#{OrN~!!gpWx^>|4w zxjnlsLbLaa3#sjFCx_ZCA>MdE6BFusq(tYXl3U|L!{#<4$~QZDP|an5cwp?ghbF}iC z|8e;Cl(@R%RXW(~I;~Lzzq3C(9WmIhH=$UuPd$8K zs9!8Ndr08Bu+7kQP^u#AIJ1svu6sNv(VONyQk{t!WI(KW#f@8pd44oPSHX*vMVr$pcs_iC!sAkfjkX&dc$KNx{&A8iQQ2 zPMg>`sFQx+ZM@$fcX5Lj-h0t4Jk_55PT^}l>gnUz@G4QwXgZJk;4aOplS7*c`;858 zj;iBf!;d%a$+0etr26hZnB3efLr9oU$W_W$0^FU6a}JzTu}??g7)oouAuF zL(+0KVI!f(uePQGLsiV(Gv~Oj-4OCwiZ+6p)*PxUA=)cvo-u;0 zZ$7mEK?2h9MbkE}@|Em`P9dWUw?97gE{NUf_8IZCL2VNs@V1Skxz$zns50r;b*wVv zxx?EoWMpC2UAg{^DZiRn`r2%%k1;rs&l&!j@%X1jHb1`;9Fue5s6?JITB zuP!$Fj629@HtSd?qU_z=iVKiYy*Oy~uX5-}rkHmxx>!8u!zI-U(|u-Vi@l3U@Szz2 zoaJ77vR}}~%Ot#zC1vcjx|1{%O87%Qrueh|-Z8VGknMkF;XQWFlPpmiK?ZwEz@6QmWhXA0zY5r)os_Ol@IQkdYk#*j1#`)+~QvjuAvif8|SN=MzMoE zw*n{L7rDqP^t~*0v3Ajv`nfnHp@4!}Ik3yOT#|{{?p3o^5>>Gs%qV+xvc=VRKbPW;%5kzXvRt@DV7@m*LNGB@=-aOg|iRQyFR)A5dv?2d+4S zw>Y8@5l$m0ozSOLX&gKFBoA*EpQXxMFA{3!Pi=@{fZZNgARq|dz%9#tW~*)yqgoz` zVw>GseoGh z1aW-_Fa}@kOvgZElmL%^ZEbl68kF46nFnGh(Cd$nx`!lJjy(KnpLc%_XZI+m%C*@6 z1*g$D6|CngtEG1o0upVz+GUc?>6yPBygk+|U2eSX2e;s{VyV-OS&y2?{#ivi+5zFa zV9dZH{WPFRf`qS>0K`2Ic5ArJSYW_w7*PfGgT{9fi)RFc`k|D_+H>K|HC>Nx3H`-E zeL0AwFeth7DDe$m@01DJS{zOY)*(EYH;(7Wu4b2(AjWh5&`Nd%%$jQ;?aDPD=1NzL zF^ttpHMQCygKhh}mmljTSFzCE1(}fYgTq;cR9{1%Z;_|J($K_Q z@OL^XRL`qN(u{?BrEBA@bZZBKqG(^b5r#W7Z>I+`hF;4MP7r@b_(0HcacT*p%Uj}$ zjehUvcq9^HT|XFFC)+mOn7?y*HTiFaJz)FUphwuSM@)Tb$usu6Y!;lzo7PzC6BPA5 z;KyDIN}E|9hQa8IKN=s{!dVhB-dUyuVKLiH(+iH>-g&|A(rF+? zwg=74lo4HaTL9qyvY^r88P8u9TZJcfjvNK=&m$Wou z+&u!porw^x$=337XbwCi%_wO|!C`kixa9c&jheLB^goSS`X52(;AZC5Sqg8`6s)FN z!YE4vxiLZiO4Q;x`A>%jAb5+8Xovo64cFKLIG^Kk6!QK-NBxkYPQX->kP-W#$Tm&v z1BgUy?_lC;n}!r7l#hsnv<;602c(}_wS;;()XYjFwq^@v;53W?>tZQ;M8^87*Az4? zL-Y(o?`HJ_3bomo*FORjexng21cGJAI_KPC|M(@qzr{PPX^p$pYw2@yC@9|6h;^yJ zz&-HjkhE{fHwP*K>EUPca~F0M(E<~iLLl!7cCY5=ald|heBkK4!91;{*J?myNFR$7 z3lszqxEh{2wXXSIUqCdj&I{1u#N_6gyEL{eCEBVtzMX3DYYm*yL)Quzl$zo7gl3{! zdu)CKwB(_k;zaY_ymNZ@+FUa`NO%ELqZ+ugSxe77#x8*f}@|g)IXq1>1nBes8)yDVkD^d}obV z+fd-M=acMaj9xg>kK0j8JG$T6ox~8aUeBrXYE0{{iTEa^V5QzS6beW-DWpO}LP{+#=9w}&7|1;ybXBSJ^+4ijmr|?zh283AQfjnP3{cOL-~ygU z_n)|)o@57K#=(-zF*u+`;^A9qJ9K?|k0rmn6uGmL8&qtH=@IUUO@=Xd$e5H$PTHbw(Y5NBYxJD_RmuMaU zS&!ZNeYAY;Op#Zm0{}jx%bsbINxCriM9BD@l1fI@HrRV<2nmWX;xef|oMvE|sB(|! zk^JD|tTG(=`evop!i~ShxuCt1^%6rqEY*mU$|n!Bv_tUKk=+^d7jO{o5KL$)#A#>! zd9aCc{@}ERrqXC^q4LQ#IjXla(D@|CEv>Ej`(W>Wf$C;0?411621E2-gZ-N2y#g(i zAD^CBd{)q0_H1kHtH}3KeXNjiNS6g?rcqf1+;%lU|MvHXGjNuKmgfrtZi*)K^9z8Y zKXxbZcNfC6hNF%5qm( zCDLA8|J=>(*C;OpdfB{Xr=oJ9gsTIIg-yz3or9nmG&rr%r5dYwtXnS@q^+Rfz02Mh zZo6gP9dv<$nh8637En?sD&DX%EqCp1m*BR5L|ShtO_xj?O@F)cu0~%O6ufwfzwD9J zXUd|@3>g)LYnB2Rmu77gyyZ-WumWcm!p6hKFJL*1*8nNd!j;&3N$(hwMYc}pjCGUp!5Jf=p#nqZZjVP25l-SsM{m6)oRJFI zQWL~(e6bT}kfEAyfEzxFMKcWRwdXXpb-#Ubc{W&Bs%KYJRqcwvqsgYjzH)q=OPogvn1FuH>49}jXOm0C-0->s=C00ax+ zF+M#Igo^FvzQ_hD_?;{B-7a+K8=1e|QX$-J8Y4)Q)L5!DkTlgJtEmIvh7gauX53&ak4lEcRStVwk-@h zl_cY-=xv0k?iU()hcl1hXl*N%B4dFT2mm{7P;OYpf|%Lf$Qg|H5SpB~$?-;7acATF zP!+stH95IMNY}olQ^x{9pH7uK+iYq{Ghgu%3+B`%O{Dp_J_U5t&}-d!U=2=(psn*R z*r29qRK_L?aE)R~Z0}-Q=`e0}vwU8tty}a!cfheYunk6~$uCQF_{yb6AqCJSC72(j z9908jl4$R#PG!s6X5B^G-3H~xWJh?MM|42d$N_%?G|kg?nq+?@#H4(NCfzTRMaB*{tbU|F|sveg3;^eV@zU_U@ZVoVl6e3!`$ zQjkD=debR1UPeFbD|^kSoIB9%dwzMe001enb5d7ZbTZR|yOZKyzBIKowNb|Z=s&gA zUPpc(0e{g_3w3|FDf>1I!&@VfKQwkpvqO{cjyf>{>=T_MtQgQ)=;srzjiH1x*XHmo z1b`0W)JH=THmA$ghlQ|qUE=A-7R*y4&1QU6C|5E`sv;s1(f5^=RIDtkIlq?pU#*>X zM}~r(ZY~?$!RaPno)a`QH2l%bQp4qW9vvPTUbt#&W@ZM2k4P#4)Sa@j^Xu(}ioxKl zV%Brnmjy{iORp(`AMSj4b<(bsu<~ki2by0VA(14sf1Bw>x2*Y0ef3*%_f@&KOMQ&B z46SDQFVJ=0a!fxD9QYdQH5Pc|?=WKaVK#Rd$4fCUkm~v5s*8M!dkJ)R<=<5rtLV;| z&Cj=&bmV<)nm2R2iD5@`l)XuOp0OoTbtslFaxKekwJ(}U1>G=ZDn-wm2OUS9$UXc5Fwj zY3aa|xm|6s#gCic4kmB`K%j*G@zK7;WitoYMhYQJeXFc!A&kCH$nYE`2Dp_Vu`1m6{4&f2*Q)#!tb%ER{$AEs4(}vjtCyN@hR>X_ zzV~OG(N7PU9e<(Vr!DsH#;hz*#auAfp?_yf-p33fx+L7}M!Db4C-IZZufa1nvQQC>t5ziBuxeo!ZQW*(AP&F!?f~OO+5~#0JoDb0T z<~@df!mi*_HEq8E!k>*v-;$vNwtd9#GNZ=IJa(!9@qEdwu{l4bsCce06)@=o5{xAE z@gc|}hY(4Op~Z)@f2B~K{_43mwmp==JT{P}(9+7hP_X*}A@rsN3~ayU^slBC2JG|2 zO}CQdO5_f2Xd!N28n8*iUIo@8ti%;n#Z6KKF-6o0!0>y337@?6IKTr9r;a)zBWP$^ zyfctD_mIEO>H`0%jf{!eLjSET9uOe2Pmr!3m{s_>xVT{})CWkM9JzI-Oug~ug#)121UdQUbwC6;M7Z2R=o4%t?Rr>MBTneo|4t2t<+pYI6Bz7h0Y$Zy_DMa zc^iYrysIH!n+*5Muy^P(D7E0*ul)L+?HCw{`s`%|0GX5iw3*6r!w8Z}O}gi4EnLfw z;hF!$dDvUIQ=QL+vUV*knPELDhgOF;w-8I3a$_5=mw=koGZbRm+C=rV>q?Y_k0c0M zRx2vwK(Qn^PrHfG1@0f|?urj|PIu|pCV^{jrIPl=OC&C(n8 z1jdbyU|31seyx>mY@x;OO|jS-T90dbEvV0lxoTEYwp5eWb1i7){ZBj+r(GZ((yvxm zZR-t;ze@ANr-n<+XGmHOwk|C|^2&Uoq19aVoRT~c{D!%yLwwNrG_6ZP_QKxlKy8?w z{JIRw%xvngV#fG+{wzlc^&0N(ElvtGjERaH6cFKbARaE0tLih|6*umW(m#qlq*ppg37!!qq7E6~u z*!pGyw7wvyWLiA1edQY?__rLv!9Xk7^CZgOzp5c-7ELZzEHf2%mfXn;8Ol6#Rkxk} zDc&;y7Oq1mP{1T*-%B^i$m#l?J0hB zn7$38aB*>QnPvx8G5;_wTf;jeE2|Ia8;3^^kddHq-AdQ zzwP4oDm_>>SD0xTie4j3Ycxb5m(2unU%pg2)?}Z*Gq-G0bUi^rDd|G05-tnq{2Wmg z4Nikb6bm24@-dK%W0hM zN(toN;BLEsAJLOsgwKDVvXT$Lk$&Gk`Ju3@Knahb6XEV0z;3|qoy7nShbGfnV}UtQ z5O$aGLgwH}&N^|wqfmmLGruX7;QWn3%~as34!(jXKiFyRrQd8bKkG&%vbS{K% zBjxz)d;WwYwzB?~J}|-`ndC=ZQkqzvcM|qUwaktpr{B2}2LJ_IgGzSp))%#+UTb(W zbpVWJt=40!a~HE`&#^yEuU@hz3P=KQ^eo5MZ#&Oe0ugRi^b-D!1*o*gT!VzfwRhWt z+6~Dp5*@=OzN&jd3ZsHGlF1p*%qW-4{+{19i}`~Hf!sy;{DIE#Mf|ByU|0=3n&DdO zU)j$D?k-4Md;ASQ`KmrDj6(rXwU;&0{AN7OYrn@92|kwR)||sUm!dmVW4J_GaEd^B@26)Y=O4L zp=Gy4g;I;;fzgrgZ+~g$AH5TKUlVB}n(MGR?9-9fvKP{RlKA((bwwo{NLywul4lSk z9^*zZkiJ%tU9xSMk>Lxp+Oa#?-cyZ1AZz1ds+xxcG9fuyYanO4PXQSXo!d|_V61oA zPStu0z;$C@x|A8Rq4rPIvG}cG7ZQ-V-NR%(()gONU)nh#)3zOr1iG9MoN%})8r+4!grCaGOSg< z8bk8#Chf!KU}X(@=~X3)*a*)mx>(w}nCXf_(_FUQ5|Obx<(gTBe*rr3QWrVOl5E$A&!L=G|#o4B`yuwm4;H0I9!8Y03*vgbW*ex6iVgSOhqo zBYMG@u~a}YK=7Z5$|Iqx1I+~Wm4!4qhHm7etPIUuj&@!LYl775|3&SM8#j6G?MV*(F~>)x+jf|!5h^4yW%&OfK%KC zFiT9a=gqX*)7)Td2!gj@Z9i9^7a&-v=5h}}T2gtb%7TCh!kGw&H&BpeI6~YhdNTQu zt*p)5%R3Sgb1!S!b}I79%Ez?qVxOo)d?M!~I`Si|QU>&>{Te{IVA%MMdEBmgUJ#7) zYZ4r9iit?v?!z?+nXESPmOLC8`Y`9mgyq2Y%|w8Dv6b4VI!$IiQy<1PQKwbg)-RXK zj3@Y^T2XJle~&xjbq#vlU=eS(IHgF*kM6|*8`K+3n>6io7n6C#=DXuDF4exLdVB|f zD1j}eq?9<7?8Xgz0+8u;j27pgq|%UHzq<2x0n~-e5)7H;&~MI zyD5?U1kgf*(OS#a9_IX)mMiT)Uq5?a7JaAJBb#u3F;kAU%g`?d*jO#AK~rrUAZ9m9 zVNYpZ_zu36p2~%Z_9YS|>kfpCJU=e(Hn8}4y7Q^X-$o;=NA|0EllOAT3R4RQ=DAnG zl>G`cchvK`G&HFIB1W!ssBWP>7hlN8GVtt?Y0OdTjzW5w<^!co@5rb5u7tEEdbcJq4EETd=YkRin!>iQcO61%-}(848lY`T0r<_fNF4_YR((h>$k(_^$fEYS>* zOT!HsHk@{W9VjAAK4JeVkC1l&q2h*ZTM~~Zl!~;8zq55mP1jpYwqL5%HPTu!x{o0< z6xYWg@e3y#-43lfWS&oW8obytTWya@r~^BS)C3B3^nAORISUXNlvCX^H(g8vm(=gT zdi>@NkLNt6i+UBPyJpw-w>ebSc1wC|k4N7Q`SkQ9G(K58vRdyeXC1VCZ_v&m0O z5@CDg(0Z^AC)hd*Y;>fjsG!;VK4Z@r>%$9LiAnCG74LZGDjmE?pxzT1T~}4y_C33Q zvrEg^>VNenr_J^c8nOsN0Q8(N4n|NCbWrG>3KkJzJk8PSx2fAzbiNl0=3DBJ7~ii5 zUBg8=-#c;iJRtM`;fxW*F??G+2uXgWwK{Kk1gG#Q59vXo$lywQ%l=XXY9c?robQTW zA4GgwrtJubQ2~sn^PTrUCFHFFZwA91AQPiT!gNTH)Tbx9V>dRq32Vtq9VcGo2G`Ex zhCUtQ{j0F*JSoZH%*0crAA;TAWNs@dPvz!PSx{mBYm7Iim12jYjr9(x_$J9V%Kl!DR<+MHbGAT7Y8nu#cXC7rA)&>wjC@ z0-v^Ve@i$uYB9snOz}EH@{$-QXyW}HI+jaYDMb=d?yc$RA{xfC@*n^4F8+(D`!`+J z^MLHcA7?}lx7}bbzK}^wzWHZy&YM5;0IM*dxt0N-TOu$ehqo^Y{kLHN@NrK6%Gq0W z{=r^h|CHqT{a|%*MiO=_6pU{m+N~ zFCUK_@WbE!gBkswJ|26OF9!X`r33WsfBSf>yFbkNua|x#rU1O}UTrsb)qi^${qu%^ zkH@3$hrDv&&;I-`YsA0K03;SZJ@|tbeqQYO|GUP&9_6?`-1zOq4@aVu_wiMKA$I?q z^0CrI5^(s%n}D8u|FaArS@54%eqDEb82;CpzX9gj@-E>yVgHug{#z%o=h3|rY{Z^D odjx{jM%8xU!NDJW$-X_7p=Q!YqaH;9&+IY1W_1;H`S#=g0f78LJ^%m! literal 0 HcmV?d00001 diff --git a/gitdesk.bat b/gitdesk.bat new file mode 100644 index 0000000..8445ddb --- /dev/null +++ b/gitdesk.bat @@ -0,0 +1,15 @@ +@echo off +rem gitdesk launcher for Windows. +rem Run it from inside a repo folder (or pass a path): gitdesk / gitdesk C:\path\to\repo +rem Needs Python 3.9+ and git. On first run it offers to install the "textual" package. +setlocal +set "PY=" +where py >nul 2>&1 && set "PY=py -3" +if not defined PY where python >nul 2>&1 && set "PY=python" +if not defined PY ( + echo Python 3 was not found. Install it from https://www.python.org/downloads/ + pause + exit /b 1 +) +%PY% "%~dp0gitdesk.py" %* +if errorlevel 1 pause diff --git a/gitdesk.py b/gitdesk.py new file mode 100644 index 0000000..205eb99 --- /dev/null +++ b/gitdesk.py @@ -0,0 +1,3536 @@ +#!/usr/bin/env python3 +""" +gitdesk - a GitHub Desktop-style git client that runs in your terminal. + +Works with the mouse and the keyboard: click the repository / branch / +Fetch-Pull-Push buttons along the top, tick the files you want in a commit, +click a file to see its diff, and use the History tab to browse commits. + + python gitdesk.py open the repository in the current folder, or the one + you had open last time + python gitdesk.py PATH open the repository at PATH + +New / Add local / Clone repository are in the repository picker (top-left). + +Needs Python 3.9+, git 2.25+ and the "textual" package (pip install textual). +Runs in Windows Terminal / PowerShell / cmd and in Linux / macOS terminals. +""" +from __future__ import annotations + +import asyncio +import json +import os +import queue +import re +import shutil +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Set, Tuple + +IS_WIN = os.name == "nt" + + +def _pick_folder_helper(args: List[str]) -> int: + """`gitdesk --pick-folder TITLE START`: show the system folder picker, print the choice. + + Runs as its own short-lived process so the GUI dialog can't interfere with the + terminal UI. Exit codes: 0 = chosen (path on stdout), 1 = cancelled, 3 = unavailable. + """ + title = args[0] if args else "Choose a folder" + start = args[1] if len(args) > 1 else "" + try: + import tkinter as tk + from tkinter import filedialog + except ImportError: + return 3 + try: + if IS_WIN: + try: # crisp (not blurry) dialog on high-DPI screens + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(2) + except Exception: + pass + root = tk.Tk() + root.withdraw() + try: + root.attributes("-topmost", True) # don't open behind the terminal window + except Exception: + pass + root.update() + path = filedialog.askdirectory(parent=root, title=title, initialdir=start or None, mustexist=True) + root.destroy() + except Exception: + return 3 + if not path: + return 1 + sys.stdout.buffer.write(os.path.normpath(path).encode("utf-8")) + sys.stdout.flush() + return 0 + + +if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--pick-folder": + sys.exit(_pick_folder_helper(sys.argv[2:])) + + +def _ensure_textual() -> None: + """Offer to install Textual if it's missing, then restart.""" + try: + import textual # noqa: F401 + return + except ImportError: + if getattr(sys, "frozen", False): # a built executable can't pip-install anything + raise + print("gitdesk needs the Python package 'textual'.") + try: + answer = input("Install it now with pip? [Y/n] ").strip().lower() + except EOFError: + answer = "n" + if answer not in ("", "y", "yes"): + print("Install it with: python -m pip install textual") + sys.exit(1) + cmd = [sys.executable, "-m", "pip", "install", "textual"] + if sys.prefix == sys.base_prefix: # not inside a virtualenv + cmd.insert(4, "--user") + if subprocess.call(cmd) != 0: + print("\npip couldn't install it. Try: python -m pip install textual") + print("(Some Linux distros need a virtualenv or pipx for this.)") + sys.exit(1) + sys.exit(subprocess.call([sys.executable, *sys.argv])) # restart with textual available + + +_ensure_textual() + +from rich.console import Group # noqa: E402 +from rich.table import Table # noqa: E402 +from rich.text import Text # noqa: E402 +from textual import events, on # noqa: E402 +from textual.app import App, ComposeResult, SuspendNotSupported # noqa: E402 +from textual.binding import Binding # noqa: E402 +from textual.css.query import NoMatches # noqa: E402 +from textual.containers import Horizontal, Vertical, VerticalScroll # noqa: E402 +from textual.message import Message # noqa: E402 +from textual.reactive import reactive # noqa: E402 +from textual.screen import ModalScreen # noqa: E402 +from textual.theme import Theme # noqa: E402 +from textual.widgets import ( # noqa: E402 + Button, Checkbox, ContentSwitcher, DirectoryTree, Footer, Input, Label, ListItem, ListView, + RichLog, Select, Static, TabbedContent, TabPane, TextArea, Tree, +) + +# -------------------------------------------------------------------------- +# Colours (GitHub Desktop dark-ish) +# -------------------------------------------------------------------------- +C_MUTED = "#959da5" +C_DIM = "#6a737d" +C_TEXT = "#e1e4e8" +C_BLUE = "#79b8ff" +C_GREEN = "#34d058" +C_RED = "#ea4a5a" +C_YELLOW = "#dbab09" +C_ORANGE = "#f66a0a" + +KIND_ICON = { + "M": ("●", C_YELLOW, "modified"), + "A": ("+", C_GREEN, "added"), + "?": ("+", C_GREEN, "new file"), + "D": ("−", C_RED, "deleted"), + "R": ("→", C_BLUE, "renamed"), + "U": ("!", C_ORANGE, "conflict"), +} + +MAX_FILES_SHOWN = 1000 +MAX_DIFF_ROWS = 4000 +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + +# Background git commands must never sit waiting for a password prompt; +# if one is needed the user gets a "Retry in terminal" button instead. +GIT_ENV = dict(os.environ) +GIT_ENV.update({ + "GIT_TERMINAL_PROMPT": "0", + "GIT_MERGE_AUTOEDIT": "no", + "GIT_OPTIONAL_LOCKS": "0", # status refreshes don't grab the index lock +}) + + +# -------------------------------------------------------------------------- +# Git layer +# -------------------------------------------------------------------------- +@dataclass +class Result: + code: int + out: str = "" + err: str = "" + lockish: bool = False # failed because another program had the repository locked + handled: bool = False # the user has already been shown this failure + + @property + def ok(self) -> bool: + return self.code == 0 + + @property + def message(self) -> str: + return self.err.strip() or self.out.strip() or f"git exited with code {self.code}" + + +# Failures that mean "the repository was busy" rather than "this can't be done". +LOCKISH_RE = re.compile( + r"index\.lock|could not write index|unable to write (new )?index|unable to create '[^']*\.lock'" + r"|cannot lock ref|resource busy|permission denied|file exists", + re.I) +# git's normal "no, because..." answers - never retried as if the repo were busy. +NOT_LOCK = ("CONFLICT", "would be overwritten", "already exists", "needs merge", "did not match", + "not a valid", "unknown revision", "nothing to commit", "no changes added", + "Please tell me who you are", "hook", "Filename too long") +STASH_KEPT_RE = re.compile(r"The stash entry is kept in case you need it again\.?") + + +def looks_lockish(r: Result) -> bool: + text = f"{r.err}\n{r.out}" + if any(s in text for s in NOT_LOCK): + return False + residue = STASH_KEPT_RE.sub("", text).strip() + return not residue or bool(LOCKISH_RE.search(residue)) + + +def hint_for(message: str) -> str: + """Plain-English next step for common git errors.""" + if "Filename too long" in message: + return ("Windows limits paths to 260 characters unless git is told otherwise. Run this once, " + "then try again:\n git config --global core.longpaths true") + if "Please tell me who you are" in message: + return ("git needs your name and email for commits. Run these once:\n" + ' git config --global user.name "Your Name"\n' + " git config --global user.email you@example.com") + if "Permission denied" in message or "Invalid argument" in message: + return ("A file is probably open in another program (Unity, an editor, a sync tool like OneDrive). " + "Close it and try again.") + return "" + + +def git_version() -> Tuple[int, ...]: + r = run_git(os.getcwd(), ["--version"]) + m = re.search(r"(\d+)\.(\d+)", r.out) + return (int(m.group(1)), int(m.group(2))) if m else (0, 0) + + +@dataclass +class FileChange: + path: str + kind: str # M A D R U ? (see KIND_ICON) + orig: Optional[str] = None # old path for renames + + @property + def is_conflict(self) -> bool: + return self.kind == "U" + + +@dataclass +class Commit: + sha: str + short: str + subject: str + author: str + when: str + + +@dataclass +class RepoState: + branch: Optional[str] = None # None = detached HEAD + oid: Optional[str] = None # None = no commits yet + upstream: Optional[str] = None # e.g. "origin/main" + upstream_gone: bool = False + ahead: int = 0 + behind: int = 0 + files: List[FileChange] = field(default_factory=list) + merging: bool = False + rebasing: bool = False + stashes: int = 0 + remotes: List[str] = field(default_factory=list) + remote_urls: dict = field(default_factory=dict) # name -> fetch URL + + @property + def conflicts(self) -> List[FileChange]: + return [f for f in self.files if f.is_conflict] + + @property + def has_upstream(self) -> bool: + return bool(self.upstream) and not self.upstream_gone + + +class GitError(Exception): + pass + + +def run_git(cwd: str, args, input: Optional[str] = None, env=None) -> Result: + cmd = ["git", "-c", "core.quotepath=false", "-c", "color.ui=false", *args] + kw = dict(cwd=cwd, capture_output=True, text=True, encoding="utf-8", + errors="replace", env=env or GIT_ENV) + if input is None: + kw["stdin"] = subprocess.DEVNULL + else: + kw["input"] = input + # Detach from the terminal so ssh/git can't grab the keyboard behind the UI. + if IS_WIN: + kw["creationflags"] = subprocess.CREATE_NO_WINDOW + else: + kw["start_new_session"] = True + try: + p = subprocess.run(cmd, **kw) + except FileNotFoundError: + return Result(127, "", "git was not found - is it installed and in your PATH?") + except OSError as e: + return Result(1, "", str(e)) + return Result(p.returncode, p.stdout or "", p.stderr or "") + + +def _kind(xy: str, entry: str) -> str: + if entry == "2": + return "R" + if "D" in xy: + return "D" + if "A" in xy: + return "A" + return "M" + + +def parse_status(out: str, st: RepoState) -> None: + """Parse `git status --porcelain=v2 --branch -z` output into st.""" + fields = out.split("\0") + i = 0 + while i < len(fields): + f = fields[i] + i += 1 + if not f: + continue + t = f[0] + if t == "#": + key, _, val = f[2:].partition(" ") + if key == "branch.oid": + st.oid = None if val == "(initial)" else val + elif key == "branch.head": + st.branch = None if val == "(detached)" else val + elif key == "branch.upstream": + st.upstream = val + st.upstream_gone = True # until branch.ab shows up + elif key == "branch.ab": + st.upstream_gone = False + a, b = val.split() + st.ahead, st.behind = int(a.lstrip("+")), int(b.lstrip("-")) + elif t == "1": + p = f.split(" ", 8) + st.files.append(FileChange(p[8], _kind(p[1], "1"))) + elif t == "2": + p = f.split(" ", 9) + orig = fields[i] if i < len(fields) else None + i += 1 + st.files.append(FileChange(p[9], "R", orig)) + elif t == "u": + p = f.split(" ", 10) + st.files.append(FileChange(p[10], "U")) + elif t == "?": + st.files.append(FileChange(f[2:], "?")) + st.files.sort(key=lambda fc: fc.path.lower()) + + +class Git: + def __init__(self, path: str, git_dir: str, common_dir: str, log: Callable): + self.path = path + self.git_dir = git_dir + self.common_dir = common_dir + self._log = log + # Only one git process at a time from gitdesk, so the background refresh can + # never be holding the repository while an action (stash, commit...) runs. + self.lock = threading.RLock() + + @classmethod + def open(cls, path: str, log: Callable) -> Optional["Git"]: + path = os.path.abspath(os.path.expanduser(path)) + if not os.path.isdir(path): + return None + r = run_git(path, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"]) + lines = r.out.splitlines() + if not r.ok or len(lines) < 3: + return None + top, git_dir, common = lines[0], lines[1], lines[2] + if not os.path.isabs(common): + common = os.path.normpath(os.path.join(path, common)) + return cls(os.path.normpath(top), git_dir, common, log) + + def run(self, *args, input: Optional[str] = None, log: bool = True, serialize: bool = True) -> Result: + if serialize: + with self.lock: + r = run_git(self.path, list(args), input=input) + else: + r = run_git(self.path, list(args), input=input) + if log: + self._log(list(args), r) + return r + + def snapshot(self) -> tuple: + """Everything a failed command could have changed - used to decide if a retry is safe.""" + r = self.run("status", "--porcelain=v2", "--branch", "-z", "-uall", log=False) + return (r.out, self.stash_count(), os.path.exists(os.path.join(self.git_dir, "MERGE_HEAD"))) + + def index_lock_path(self) -> str: + return os.path.join(self.git_dir, "index.lock") + + # ---- state ------------------------------------------------------------- + def state(self) -> RepoState: + r = self.run("status", "--porcelain=v2", "--branch", "-z", "-uall", log=False) + if not r.ok: + raise GitError(r.message) + st = RepoState() + parse_status(r.out, st) + st.merging = os.path.exists(os.path.join(self.git_dir, "MERGE_HEAD")) + st.rebasing = any(os.path.isdir(os.path.join(self.git_dir, d)) + for d in ("rebase-merge", "rebase-apply")) + st.stashes = self.stash_count() + for line in self.run("remote", "-v", log=False).out.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[0] not in st.remote_urls: + st.remotes.append(parts[0]) + st.remote_urls[parts[0]] = parts[1] + return st + + def stash_count(self) -> int: + try: + with open(os.path.join(self.common_dir, "logs", "refs", "stash"), "rb") as fh: + return sum(1 for line in fh if line.strip()) + except OSError: + return 0 + + def last_fetch_time(self) -> Optional[float]: + for d in (self.git_dir, self.common_dir): + try: + return os.path.getmtime(os.path.join(d, "FETCH_HEAD")) + except OSError: + pass + return None + + def merge_message(self) -> str: + try: + with open(os.path.join(self.git_dir, "MERGE_MSG"), encoding="utf-8", errors="replace") as fh: + return fh.readline().strip() + except OSError: + return "" + + # ---- diffs ------------------------------------------------------------- + def file_diff(self, fc: FileChange, has_head: bool) -> str: + if fc.kind == "?": + return self._untracked_diff(fc.path) + args = ["diff", "HEAD" if has_head else EMPTY_TREE, "--no-ext-diff", "-M", "--"] + if fc.orig: + args.append(fc.orig) + args.append(fc.path) + r = self.run(*args, log=False) + return r.out if r.ok else r.message + + def _untracked_diff(self, path: str) -> str: + try: + with open(os.path.join(self.path, path), "rb") as fh: + data = fh.read(512_000) + except OSError as e: + return f"Can't read this file: {e}" + if b"\0" in data[:8000]: + return f"diff --git a/{path} b/{path}\nBinary files /dev/null and b/{path} differ" + text = data.decode("utf-8", errors="replace") + lines = text.splitlines() + out = [f"diff --git a/{path} b/{path}", f"@@ -0,0 +1,{len(lines)} @@"] + out += ["+" + line for line in lines] + if text and not text.endswith(("\n", "\r")): + out.append("\\ No newline at end of file") + return "\n".join(out) + + def changed_between(self, a: str, b: str) -> Set[str]: + r = self.run("diff", "--name-only", "-z", f"{a}...{b}", log=False) + return {p for p in r.out.split("\0") if p} + + def commit_header(self, sha: str) -> str: + return self.run("show", "-s", "--date=format:%Y-%m-%d %H:%M", + "--format=%H%n%an <%ae>%n%ad%n%B", sha, log=False).out + + def commit_diff(self, sha: str) -> str: + return self.run("show", "--format=", "-p", "-M", "--first-parent", + "--no-ext-diff", sha, log=False).out + + # ---- history / branches / stashes ------------------------------------- + def history(self, has_upstream: bool, limit: int = 300) -> Tuple[List[Commit], Set[str]]: + r = self.run("log", f"-n{limit}", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%ar", log=False) + commits = [] + for line in r.out.splitlines(): + parts = line.split("\x1f") + if len(parts) == 5: + commits.append(Commit(*parts)) + unpushed: Set[str] = set() + if has_upstream: + unpushed = set(self.run("rev-list", "@{u}..HEAD", log=False).out.split()) + return commits, unpushed + + def local_branches(self) -> List[Tuple[str, str]]: + r = self.run("for-each-ref", "--sort=-committerdate", + "--format=%(refname:short)%1f%(committerdate:relative)", "refs/heads", log=False) + return [tuple(line.split("\x1f", 1)) for line in r.out.splitlines() if "\x1f" in line] + + def remote_branches(self, remote: str) -> List[Tuple[str, str]]: + r = self.run("for-each-ref", "--sort=-committerdate", + "--format=%(refname:short)%1f%(committerdate:relative)", + f"refs/remotes/{remote}", log=False) + out = [] + for line in r.out.splitlines(): + if "\x1f" not in line: + continue + name, when = line.split("\x1f", 1) + if name in (remote, f"{remote}/HEAD"): + continue + out.append((name, when)) + return out + + def stash_list(self) -> List[Tuple[str, str, str]]: + r = self.run("stash", "list", "--format=%gd%x1f%gs%x1f%cr", log=False) + return [tuple(line.split("\x1f", 2)) for line in r.out.splitlines() if line.count("\x1f") >= 2] + + def stash_preview(self, ref: str) -> str: + r = self.run("stash", "show", "--stat", "--include-untracked", ref, log=False) + if not r.ok: + r = self.run("stash", "show", "--stat", ref, log=False) + return r.out.rstrip() or r.message + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- +def config_dir() -> str: + if IS_WIN: + base = os.environ.get("APPDATA") or os.path.expanduser("~") + else: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return os.path.join(base, "gitdesk") + + +def _settings_file() -> str: + return os.path.join(config_dir(), "recent.json") + + +def load_settings() -> dict: + try: + with open(_settings_file(), encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def save_settings(data: dict) -> None: + try: + os.makedirs(config_dir(), exist_ok=True) + tmp = _settings_file() + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=1) + os.replace(tmp, _settings_file()) + except OSError: + pass + + +def load_recent() -> List[str]: + return [p for p in load_settings().get("recent", []) if isinstance(p, str)] + + +def save_recent(path: str) -> None: + """Remember a repository; the first one in the list is reopened next launch.""" + norm = os.path.normcase(path) + data = load_settings() + data["recent"] = ([path] + [p for p in load_recent() if os.path.normcase(p) != norm])[:15] + save_settings(data) + + +def default_parent_folder() -> str: + """Where new / cloned repositories go by default (like GitHub Desktop's Documents\\GitHub).""" + saved = load_settings().get("parent") + if isinstance(saved, str) and os.path.isdir(saved): + return saved + home = os.path.expanduser("~") + for cand in (os.path.join(home, "Documents", "GitHub"), os.path.join(home, "Documents")): + if os.path.isdir(cand): + return cand + return home + + +def remember_parent_folder(folder: str) -> None: + data = load_settings() + data["parent"] = folder + save_settings(data) + + +def repo_name_from_url(url: str) -> str: + """'git@host:owner/My-Repo.git' -> 'My-Repo'.""" + tail = re.split(r"[/:\\]", url.strip().rstrip("/\\"))[-1] if url.strip() else "" + return re.sub(r"\.git$", "", tail) + + +BAD_NAME_RE = re.compile(r'[\\/:*?"<>|]') + + +GITIGNORE_OS = """ +# OS / editor clutter +.DS_Store +Thumbs.db +desktop.ini +""" + +GITIGNORE_TEMPLATES = { + "Unity": """# Unity generated folders +/[Ll]ibrary/ +/[Tt]emp/ +/[Oo]bj/ +/[Bb]uild/ +/[Bb]uilds/ +/[Ll]ogs/ +/[Uu]ser[Ss]ettings/ +/[Mm]emoryCaptures/ +/[Rr]ecordings/ + +# Asset meta data should only be ignored when the corresponding asset is also ignored +!/[Aa]ssets/**/*.meta + +# Uncomment this line if you wish to ignore the asset store tools plugin +# /[Aa]ssets/AssetStoreTools* + +# Autogenerated Jetbrains Rider plugin +/[Aa]ssets/Plugins/Editor/JetBrains* + +# Visual Studio / Rider cache directories +.vs/ +.idea/ +.gradle/ + +# Autogenerated VS/MD/Consulo solution and project files +ExportedObj/ +.consulo/ +*.csproj +*.unityproj +*.sln +*.suo +*.tmp +*.user +*.userprefs +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Unity3D generated meta files +*.pidb.meta +*.pdb.meta +*.mdb.meta + +# Unity3D generated file on crash reports +sysinfo.txt + +# Builds +*.apk +*.aab +*.unitypackage +*.unitypackage.meta +*.app + +# Crashlytics generated file +crashlytics-build.properties + +# Packed Addressables +/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin* + +# Temporary auto-generated Android Assets +/[Aa]ssets/[Ss]treamingAssets/aa.meta +/[Aa]ssets/[Ss]treamingAssets/aa/* +""", + "Python": """# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +env/ +.env +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +""", + "Node": """# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +dist/ +build/ +coverage/ +.env +.env.local +.cache/ +""", + "Visual Studio / .NET": """# Visual Studio / .NET +.vs/ +[Bb]in/ +[Oo]bj/ +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +*.user +*.suo +*.userosscache +*.sln.docstates +packages/ +TestResults/ +*.log +""", +} + +MIT_LICENSE = """MIT License + +Copyright (c) {year} {name} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +PROGRESS_RE = re.compile(r"^(?:remote: )?(Enumerating|Counting|Compressing|Receiving|Resolving|" + r"Updating files|Filtering content|Checking out|Total|Downloading)", re.I) + + +def run_git_streaming(cwd: str, args: List[str], on_progress: Callable[[str], None]) -> Result: + """Run a long git command (clone), passing each progress line to on_progress.""" + kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WIN else {"start_new_session": True} + try: + p = subprocess.Popen(["git", *args], cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=GIT_ENV, **kw) + except OSError as e: + return Result(1, "", str(e)) + out: List[bytes] = [] + reader = threading.Thread(target=lambda: out.append(p.stdout.read()), daemon=True) + reader.start() + messages: List[str] = [] + buf = b"" + while True: + chunk = p.stderr.read1(4096) + if not chunk: + break + buf += chunk + *lines, buf = re.split(rb"[\r\n]", buf) + for raw in lines: + line = raw.decode("utf-8", "replace").strip() + if not line: + continue + if PROGRESS_RE.match(line): + on_progress(line) + elif line not in messages: + messages.append(line) + if buf.strip(): + messages.append(buf.decode("utf-8", "replace").strip()) + p.wait() + reader.join(timeout=5) + return Result(p.returncode, (out[0] if out else b"").decode("utf-8", "replace"), "\n".join(messages)) + + +def ago(ts: Optional[float]) -> str: + if not ts: + return "never" + s = max(0, time.time() - ts) + if s < 60: + return "just now" + for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)): + if s >= size: + n = int(s // size) + return f"{n} {unit}{'s' if n != 1 else ''} ago" + return "just now" + + +def plural(n: int, word: str) -> str: + return f"{n} {word}{'' if n == 1 else 's'}" + + +MARKER_RE = re.compile(r"^(<{7}|>{7})( |$)", re.M) + + +def file_has_markers(path: str) -> bool: + try: + with open(path, encoding="utf-8", errors="replace") as fh: + return bool(MARKER_RE.search(fh.read())) + except OSError: + return False + + +def editor_name() -> str: + if shutil.which("code"): + return "VS Code" + ed = os.environ.get("VISUAL") or os.environ.get("EDITOR") + return os.path.basename(ed.split()[0]) if ed else "your editor" + + +_SSH_HOSTS: dict = {} + + +def resolve_ssh_host(host: str) -> str: + """Turn a Host alias from ~/.ssh/config into the real hostname (cached).""" + if host not in _SSH_HOSTS: + real = "" + if shutil.which("ssh"): + try: + kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WIN else {"start_new_session": True} + p = subprocess.run(["ssh", "-G", host], capture_output=True, text=True, timeout=3, + stdin=subprocess.DEVNULL, **kw) + for line in p.stdout.splitlines(): + if line.lower().startswith("hostname "): + real = line.split(None, 1)[1].strip() + break + except (OSError, subprocess.SubprocessError): + pass + _SSH_HOSTS[host] = real or host + return _SSH_HOSTS[host] + + +def web_url_for(remote_url: str) -> Optional[str]: + """The repository's web page for a remote URL (GitHub, Gitea, GitLab...), or None.""" + u = remote_url.strip() + m = re.match(r"^(https?)://(?:[^@/]+@)?([^/]+)/(.+?)(?:\.git)?/*$", u) + if m: # https://[user:token@]host[:port]/owner/repo.git + return f"{m.group(1)}://{m.group(2)}/{m.group(3)}" + m = re.match(r"^(?:ssh|git\+ssh|ssh\+git|git)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/*$", u) + if not m and "://" not in u: # git@host:owner/repo.git + m = re.match(r"^(?:[^@/:]+@)?([^/:]{2,}):/*(.+?)(?:\.git)?/*$", u) + if not m: + return None # a local path - no web page + return f"https://{resolve_ssh_host(m.group(1))}/{m.group(2).lstrip('/')}" + + +def web_site_name(web_url: str) -> str: + host = re.sub(r"^https?://", "", web_url).split("/")[0].split(":")[0].lower() + if host == "github.com" or host.endswith(".github.com"): + return "GitHub" + if "gitlab" in host: + return "GitLab" + if "bitbucket" in host: + return "Bitbucket" + return host + + +def commit_web_url(web_url: str, sha: str) -> str: + host = web_url.lower() + if "gitlab" in host: + return f"{web_url}/-/commit/{sha}" + if "bitbucket" in host: + return f"{web_url}/commits/{sha}" + return f"{web_url}/commit/{sha}" # GitHub, Gitea, Forgejo, Gogs + + +def _helper_command(title: str, start: str) -> List[str]: + if getattr(sys, "frozen", False): + return [sys.executable, "--pick-folder", title, start] + return [sys.executable, os.path.abspath(__file__), "--pick-folder", title, start] + + +def _powershell_pick(title: str, start: str) -> Tuple[str, Optional[str]]: + def q(text: str) -> str: + return "'" + text.replace("'", "''") + "'" + ps = ("Add-Type -AssemblyName System.Windows.Forms;" + "$f = New-Object System.Windows.Forms.FolderBrowserDialog;" + f"$f.Description = {q(title)}; $f.SelectedPath = {q(start)}; $f.ShowNewFolderButton = $true;" + "$o = New-Object System.Windows.Forms.Form -Property @{TopMost = $true};" + "if ($f.ShowDialog($o) -eq [System.Windows.Forms.DialogResult]::OK) {" + " try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}; $f.SelectedPath }") + p = subprocess.run(["powershell", "-NoProfile", "-STA", "-Command", ps], capture_output=True, + stdin=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW) + out = p.stdout.decode("utf-8", "replace").strip() + if p.returncode == 0: + return ("ok", out) if out else ("cancel", None) + return "unavailable", None + + +def pick_folder_native(title: str, start: str) -> Tuple[str, Optional[str]]: + """Show the operating system's folder picker (blocks until it closes). + + Returns ("ok", path), ("cancel", None), or ("unavailable", None) when there's no + desktop to show one on - then gitdesk shows its own in-terminal folder browser. + """ + try: + if sys.platform == "darwin": + def aq(text: str) -> str: + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + script = f"POSIX path of (choose folder with prompt {aq(title)} default location (POSIX file {aq(start)}))" + p = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) + if p.returncode == 0 and p.stdout.strip(): + return "ok", p.stdout.strip().rstrip("/") or "/" + return ("cancel", None) if "-128" in p.stderr else ("unavailable", None) + if not IS_WIN: + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + return "unavailable", None + for cmd in (["zenity", "--file-selection", "--directory", f"--title={title}", + f"--filename={start.rstrip('/')}/"], + ["kdialog", "--getexistingdirectory", start, "--title", title]): + if shutil.which(cmd[0]): + p = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL, + start_new_session=True) + if p.returncode == 0 and p.stdout.strip(): + return "ok", p.stdout.strip() + if p.returncode == 1: + return "cancel", None + # Windows (and Linux without zenity/kdialog): a helper copy of gitdesk shows the + # native picker through tkinter - on Windows that's the Explorer "Select Folder" window. + kw = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WIN else {"start_new_session": True} + p = subprocess.run(_helper_command(title, start), capture_output=True, stdin=subprocess.DEVNULL, **kw) + if p.returncode == 0 and p.stdout.strip(): + return "ok", p.stdout.decode("utf-8", "replace").strip() + if p.returncode == 1: + return "cancel", None + if IS_WIN: + return _powershell_pick(title, start) + except (OSError, subprocess.SubprocessError): + pass + return "unavailable", None + + +def existing_folder(path: str) -> str: + """The nearest folder that exists, walking up from path (falls back to home).""" + path = os.path.abspath(os.path.expanduser(path)) if path.strip() else "" + while path and not os.path.isdir(path): + parent = os.path.dirname(path) + if parent == path: + break + path = parent + return path if path and os.path.isdir(path) else os.path.expanduser("~") + + +def file_manager_name() -> str: + return "Explorer" if IS_WIN else ("Finder" if sys.platform == "darwin" else "file manager") + + +def set_label(button, label: str) -> None: + """Change a button's label and let it resize to fit.""" + if str(button.label) != label: + button.label = label + button.refresh(layout=True) + + +def path_text(fc: FileChange) -> Text: + t = Text(no_wrap=True, overflow="ellipsis") + d, _, name = fc.path.rpartition("/") + if fc.orig: + t.append(fc.orig + " → ", style=C_DIM) + if d: + t.append(d + "/", style=C_MUTED) + t.append(name) + return t + + +def render_diff(text: str, multi_file: bool = False): + """Turn unified diff text into a table with old/new line numbers.""" + t = Table.grid(expand=True, padding=(0, 1)) + t.add_column(justify="right", width=5, style=C_DIM, no_wrap=True) + t.add_column(justify="right", width=5, style=C_DIM, no_wrap=True) + t.add_column(width=1, no_wrap=True) + t.add_column(ratio=1, overflow="fold") + old = new = 0 + in_hunk = False + rows = 0 + for raw in text.split("\n"): + line = raw.rstrip("\r") + if rows >= MAX_DIFF_ROWS: + t.add_row("", "", "", Text(f"… diff truncated after {MAX_DIFF_ROWS} lines", style=f"italic {C_MUTED}")) + break + if line.startswith("diff --git "): + in_hunk = False + if multi_file: + name = line.split(" b/", 1)[-1] if " b/" in line else line[11:] + t.add_row("", "", "", Text(name, style=f"bold {C_TEXT}"), style="on #2f363d") + rows += 1 + continue + if line.startswith("@@"): + m = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line) + if m: + old, new = int(m.group(1)), int(m.group(2)) + in_hunk = True + t.add_row("", "", "", Text(line, style=C_BLUE), style="on #1c2938") + rows += 1 + continue + if not in_hunk: + if line.startswith("Binary files"): + t.add_row("", "", "", Text("Binary file - there's no text diff to show.", style=f"italic {C_MUTED}")) + rows += 1 + elif multi_file and line.startswith(("rename from", "rename to", "new file mode", "deleted file mode")): + t.add_row("", "", "", Text(line, style=C_DIM)) + rows += 1 + continue + if not line: + continue + body = Text(line[1:].expandtabs(4)) + if line[0] == "+": + if MARKER_RE.match(line[1:]) or line[1:].startswith("======="): + body.stylize(f"bold {C_ORANGE}") + t.add_row("", str(new), Text("+", style=C_GREEN), body, style="on #12261e") + new += 1 + elif line[0] == "-": + t.add_row(str(old), "", Text("-", style=C_RED), body, style="on #3a1d22") + old += 1 + elif line[0] == "\\": + t.add_row("", "", "", Text(line[2:], style=f"italic {C_MUTED}")) + else: + t.add_row(str(old), str(new), " ", body) + old += 1 + new += 1 + rows += 1 + if rows == 0: + return Text("No differences to show.", style=f"italic {C_MUTED}") + return t + + +def create_repository(o: dict) -> Tuple[bool, str]: + """Create a repo from the New repository dialog's choices. + + Returns (created, problem). created=False means nothing usable was made. + """ + path, name, desc = o["path"], o["name"], o.get("description", "") + try: + os.makedirs(path, exist_ok=True) + except OSError as e: + return False, f"Couldn't create the folder: {e}" + branch = run_git(path, ["config", "--get", "init.defaultBranch"]).out.strip() or "main" + r = run_git(path, ["init"]) + if not r.ok: + return False, r.message + run_git(path, ["symbolic-ref", "HEAD", f"refs/heads/{branch}"]) + wrote = [] + try: + def write(fname: str, text: str) -> None: + with open(os.path.join(path, fname), "w", encoding="utf-8", newline="\n") as fh: + fh.write(text) + wrote.append(fname) + if o.get("readme") and not os.path.exists(os.path.join(path, "README.md")): + write("README.md", f"# {name}\n" + (f"\n{desc}\n" if desc else "")) + if o.get("gitignore") in GITIGNORE_TEMPLATES: + ignore_path = os.path.join(path, ".gitignore") + template = GITIGNORE_TEMPLATES[o["gitignore"]] + GITIGNORE_OS + if os.path.exists(ignore_path): # existing folder: add to what's there + with open(ignore_path, "a", encoding="utf-8", newline="\n") as fh: + fh.write("\n" + template) + wrote.append(".gitignore") + else: + write(".gitignore", template) + if o.get("license") == "MIT" and not os.path.exists(os.path.join(path, "LICENSE")): + author = run_git(path, ["config", "--get", "user.name"]).out.strip() or "" + write("LICENSE", MIT_LICENSE.format(year=time.localtime().tm_year, name=author)) + except OSError as e: + return True, f"Couldn't write {e.filename}: {e.strerror}" + if o.get("remote"): + r = run_git(path, ["remote", "add", "origin", o["remote"]]) + if not r.ok: + return True, "Couldn't add the remote: " + r.message + if wrote: + r = run_git(path, ["add", "-A"]) + if r.ok: + r = run_git(path, ["commit", "-q", "-m", "Initial commit"]) + if not r.ok: + return True, r.message + return True, "" + + +# -------------------------------------------------------------------------- +# Small widgets +# -------------------------------------------------------------------------- +class ToolbarButton(Static, can_focus=True): + """Two-line clickable block in the top bar (like GitHub Desktop's).""" + + class Pressed(Message): + def __init__(self, button: "ToolbarButton") -> None: + super().__init__() + self.button = button + + def __init__(self, icon: str, top: str, bottom: str, *, id: str, + emphasis: str = "bottom", dropdown: bool = False) -> None: + super().__init__(id=id, classes="tb") + self.icon, self.top, self.bottom = icon, top, bottom + self.badge = "" + self.emphasis = emphasis + self.dropdown = dropdown + + def set(self, *, icon=None, top=None, bottom=None, badge=None) -> None: + if icon is not None: + self.icon = icon + if top is not None: + self.top = top + if bottom is not None: + self.bottom = bottom + if badge is not None: + self.badge = badge + self.refresh() + + def render(self) -> Text: + strong, weak = f"bold {C_TEXT}", C_MUTED + top_style, bottom_style = (weak, strong) if self.emphasis == "bottom" else (strong, weak) + t = Text(no_wrap=True, overflow="ellipsis") + t.append(f"{self.icon} ", style=f"bold {C_TEXT}") + t.append(self.top, style=top_style) + if self.badge: + t.append(f" {self.badge}", style=f"bold {C_BLUE}") + if self.dropdown: + t.append(" ▾", style=C_MUTED) + t.append("\n ") + t.append(self.bottom, style=bottom_style) + return t + + def on_click(self, event: events.Click) -> None: + event.stop() + self.post_message(self.Pressed(self)) + + def key_enter(self) -> None: + self.post_message(self.Pressed(self)) + + def key_space(self) -> None: + self.post_message(self.Pressed(self)) + + +class Tick(Static): + """A small clickable checkbox. Clicking it doesn't select the row.""" + + class Toggled(Message): + def __init__(self, tick: "Tick") -> None: + super().__init__() + self.tick = tick + + checked = reactive(True) + + def __init__(self, checked: bool = True, key: Optional[str] = None, **kw) -> None: + super().__init__(**kw) + self.set_reactive(Tick.checked, checked) + self.key = key + + def render(self) -> Text: + return Text("[x]" if self.checked else "[ ]", + style=f"bold {C_BLUE}" if self.checked else C_MUTED) + + def on_click(self, event: events.Click) -> None: + event.stop() + self.checked = not self.checked + self.post_message(self.Toggled(self)) + + +class FileRow(ListItem): + def __init__(self, fc: FileChange, checked: bool) -> None: + super().__init__(classes="filerow") + self.fc = fc + self._checked = checked + + def compose(self) -> ComposeResult: + icon, color, _ = KIND_ICON.get(self.fc.kind, ("●", C_YELLOW, "")) + with Horizontal(): + yield Tick(self._checked, key=self.fc.path, classes="tick") + yield Label(path_text(self.fc), classes="fpath") + yield Label(Text(icon, style=f"bold {color}"), classes="fkind") + + +class CommitRow(ListItem): + def __init__(self, c: Commit, unpushed: bool) -> None: + super().__init__(classes="commitrow") + self.commit = c + self.unpushed = unpushed + + def compose(self) -> ComposeResult: + t = Text(no_wrap=True, overflow="ellipsis") + if self.unpushed: + t.append("↑ ", style=f"bold {C_BLUE}") + t.append(self.commit.subject) + yield Label(t) + yield Label(Text(f"{self.commit.author} • {self.commit.when}", style=C_MUTED, + no_wrap=True, overflow="ellipsis")) + + +class Card(Horizontal): + def __init__(self, title: str, desc: str, button: str, action: str) -> None: + super().__init__(classes="card") + self._title, self._desc, self._button, self._action = title, desc, button, action + + def compose(self) -> ComposeResult: + with Vertical(classes="card-text"): + yield Label(Text(self._title, style=f"bold {C_TEXT}")) + yield Label(Text(self._desc, style=C_MUTED)) + yield Button(self._button, name=self._action, classes="card-btn", compact=True) + + +# -------------------------------------------------------------------------- +# Dialogs +# -------------------------------------------------------------------------- +class ChoiceDialog(ModalScreen): + """Message + a row of buttons. Returns the chosen button's value.""" + BINDINGS = [Binding("escape", "cancel", "Close")] + + def __init__(self, title: str, body, buttons: List[Tuple[str, str, str]], + cancel: Optional[str] = None) -> None: + super().__init__() + self._title = title + self._body = body if isinstance(body, Text) else Text(str(body)) + self._buttons = buttons + self._cancel = cancel if cancel is not None else buttons[-1][0] + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog"): + yield Label(Text(self._title, style="bold"), classes="dialog-title") + with VerticalScroll(classes="dialog-body"): + yield Static(self._body) + with Horizontal(classes="dialog-buttons"): + for value, label, variant in self._buttons: + yield Button(label, variant=variant, name=value, compact=True) + + def on_mount(self) -> None: + self.query(Button).first().focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(event.button.name) + + def action_cancel(self) -> None: + self.dismiss(self._cancel) + + +class InputDialog(ModalScreen): + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, title: str, prompt: str, ok: str, placeholder: str = "", value: str = "") -> None: + super().__init__() + self._title, self._prompt, self._ok = title, prompt, ok + self._placeholder, self._value = placeholder, value + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog"): + yield Label(Text(self._title, style="bold"), classes="dialog-title") + yield Static(Text(self._prompt)) + yield Input(value=self._value, placeholder=self._placeholder, id="dlg-input") + with Horizontal(classes="dialog-buttons"): + yield Button(self._ok, variant="primary", name="ok", compact=True) + yield Button("Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + self.query_one(Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self.dismiss(event.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(self.query_one(Input).value if event.button.name == "ok" else None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class FolderTree(DirectoryTree): + def filter_paths(self, paths): + return [p for p in paths if p.is_dir() and p.name != ".git"] + + +class FolderBrowser(ModalScreen): + """In-terminal folder picker, used when there's no desktop folder picker available.""" + BINDINGS = [Binding("escape", "cancel", "Cancel"), Binding("backspace", "up", "Up a folder")] + + def __init__(self, title: str, start: str) -> None: + super().__init__() + self._title = title + self.current = existing_folder(start) + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog wide"): + yield Label(Text(self._title, style="bold"), classes="dialog-title") + yield Static(id="fb-current") + yield FolderTree(self.current, id="fb-tree", classes="folder-tree") + with Horizontal(classes="dialog-buttons"): + yield Button("Up a folder", name="up", compact=True) + yield Button("Select this folder", variant="primary", name="select", compact=True) + yield Button("Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + self._show() + self.query_one("#fb-tree").focus() + + def _show(self) -> None: + self.query_one("#fb-current", Static).update(Text.assemble(("Selected: ", C_MUTED), (self.current, "bold"))) + + @on(Tree.NodeHighlighted, "#fb-tree") + def _highlighted(self, event: Tree.NodeHighlighted) -> None: + data = event.node.data + if data is not None and getattr(data, "path", None) is not None: + self.current = str(data.path) + self._show() + + def action_up(self) -> None: + parent = os.path.dirname(self.current.rstrip("\\/")) or self.current + if os.path.isdir(parent) and parent != self.current: + self.current = parent + self.query_one("#fb-tree", FolderTree).path = parent + self._show() + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.name == "up": + self.action_up() + elif event.button.name == "select": + self.dismiss(self.current) + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class ChooseFolderMixin: + """For dialogs with a path box + "Choose…" button.""" + + async def choose_into(self, input_id: str, title: str, button_id: str, + start: Optional[str] = None) -> Optional[str]: + inp = self.query_one(f"#{input_id}", Input) + btn = self.query_one(f"#{button_id}", Button) + btn.disabled = True + try: + chosen = await self.app.choose_folder(title, start if start is not None else inp.value) + finally: + btn.disabled = False + if chosen: + inp.value = chosen + inp.cursor_position = len(chosen) + return chosen + + +class AddLocalDialog(ChooseFolderMixin, ModalScreen): + """Returns ("open", path), ("create_here", path) or None.""" + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog"): + yield Label(Text("Add local repository", style="bold"), classes="dialog-title") + yield Label("Local path", classes="field-label") + with Horizontal(classes="path-row"): + yield Input(placeholder="repository path", id="al-path", compact=True) + yield Button("Choose…", name="choose", id="al-choose", compact=True) + yield Static(id="al-error", classes="error") + with Horizontal(classes="dialog-buttons"): + yield Button("Create a repository here", name="create_here", id="al-create", + variant="success", compact=True, classes="hidden") + yield Button("Add repository", variant="primary", name="add", compact=True) + yield Button("Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + self.query_one("#al-path", Input).focus() + + _checked_value: Optional[str] = None + + def on_input_changed(self, event: Input.Changed) -> None: + # Only clear the message when the path really changed since it was checked + # (filling the box from Choose… also sends a Changed event, a moment later). + if event.value != self._checked_value: + self.query_one("#al-error", Static).update("") + self.query_one("#al-create").add_class("hidden") + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._submit() + + def _path(self) -> str: + raw = self.query_one("#al-path", Input).value.strip() + return os.path.abspath(os.path.expanduser(raw)) if raw else "" + + def _submit(self) -> None: + self._checked_value = self.query_one("#al-path", Input).value + path = self._path() + err = self.query_one("#al-error", Static) + if not path: + err.update(Text("Type a folder path or click Choose….", style=C_RED)) + elif not os.path.isdir(path): + err.update(Text("That folder doesn't exist.", style=C_RED)) + elif run_git(path, ["rev-parse", "--show-toplevel"]).ok: + self.dismiss(("open", path)) + else: + err.update(Text("This folder doesn't appear to be a git repository. Would you like to " + "create a repository here instead?", style=C_ORANGE)) + self.query_one("#al-create").remove_class("hidden") + + async def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + name = event.button.name + if name == "choose": + self.run_worker(self._choose(), exit_on_error=False) + elif name == "add": + self._submit() + elif name == "create_here": + self.dismiss(("create_here", self._path())) + else: + self.dismiss(None) + + async def _choose(self) -> None: + if await self.choose_into("al-path", "Choose a repository folder", "al-choose"): + self._submit() + + def action_cancel(self) -> None: + self.dismiss(None) + + +class SimpleRow(ListItem): + def __init__(self, value, *lines: Text) -> None: + super().__init__() + self.value = value + self._lines = lines + + def compose(self) -> ComposeResult: + for line in self._lines: + yield Label(line) + + +class StashDialog(ModalScreen): + BINDINGS = [Binding("escape", "cancel", "Close")] + + def __init__(self, git: Git) -> None: + super().__init__() + self.git = git + self.stashes = git.stash_list() + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog wide"): + yield Label(Text("Stashed changes", style="bold"), classes="dialog-title") + if not self.stashes: + yield Static(Text("You don't have any stashed changes.", style=C_MUTED)) + else: + rows = [SimpleRow(ref, Text(msg, no_wrap=True, overflow="ellipsis"), + Text(f"{ref} • {when}", style=C_MUTED)) + for ref, msg, when in self.stashes] + yield ListView(*rows, id="stash-lv", classes="dialog-list") + yield Static(id="stash-preview", classes="preview") + with Horizontal(classes="dialog-buttons"): + if self.stashes: + yield Button("Pop (restore + remove)", variant="primary", name="pop", compact=True) + yield Button("Apply (keep stash)", name="apply", compact=True) + yield Button("Drop", variant="error", name="drop", compact=True) + yield Button("Close", name="close", compact=True) + + def on_mount(self) -> None: + if self.stashes: + lv = self.query_one("#stash-lv", ListView) + lv.index = 0 + lv.focus() + + @on(ListView.Highlighted, "#stash-lv") + def _preview(self, event: ListView.Highlighted) -> None: + if isinstance(event.item, SimpleRow): + self.query_one("#stash-preview", Static).update( + Text(self.git.stash_preview(event.item.value), style=C_MUTED)) + + def _current(self) -> Optional[str]: + lv = self.query_one("#stash-lv", ListView) + item = lv.highlighted_child + return item.value if isinstance(item, SimpleRow) else None + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.name == "close": + self.dismiss(None) + return + ref = self._current() + self.dismiss((event.button.name, ref) if ref else None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class BranchDialog(ModalScreen): + BINDINGS = [Binding("escape", "cancel", "Close")] + + def __init__(self, git: Git, current: Optional[str], remote: Optional[str]) -> None: + super().__init__() + self.current = current + self.local = git.local_branches() + local_names = {n for n, _ in self.local} + self.remote_only = [] + if remote: + for name, when in git.remote_branches(remote): + short = name[len(remote) + 1:] + if short not in local_names: + self.remote_only.append((short, name, when)) + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog wide"): + yield Label(Text("Branches", style="bold"), classes="dialog-title") + yield Input(placeholder="Filter, or type a new branch name", id="branch-filter") + yield ListView(id="branch-lv", classes="dialog-list") + with Horizontal(classes="dialog-buttons"): + yield Button("New branch", variant="success", name="create", id="branch-create", + compact=True, disabled=True) + yield Button("Close", name="close", compact=True) + + async def on_mount(self) -> None: + await self._fill("") + self.query_one("#branch-filter", Input).focus() + + async def _fill(self, text: str) -> None: + lv = self.query_one("#branch-lv", ListView) + await lv.clear() + needle = text.lower() + rows = [] + for name, when in self.local: + if needle and needle not in name.lower(): + continue + t = Text(no_wrap=True, overflow="ellipsis") + if name == self.current: + t.append("✓ " + name, style=f"bold {C_GREEN}") + else: + t.append(" " + name) + t.append(f" {when}", style=C_DIM) + rows.append(SimpleRow(("switch", name, None), t)) + for short, full, when in self.remote_only: + if needle and needle not in short.lower(): + continue + t = Text(no_wrap=True, overflow="ellipsis") + t.append(" " + short) + t.append(f" on {full.split('/')[0]} • {when}", style=C_DIM) + rows.append(SimpleRow(("switch", short, full), t)) + await lv.extend(rows) + btn = self.query_one("#branch-create", Button) + names = {n for n, _ in self.local} + btn.disabled = not text.strip() or text.strip() in names + set_label(btn, f'Create branch "{text.strip()}"' if text.strip() else "New branch") + + async def on_input_changed(self, event: Input.Changed) -> None: + await self._fill(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + lv = self.query_one("#branch-lv", ListView) + rows = list(lv.query(SimpleRow)) + if len(rows) == 1: + self._pick(rows[0].value) + elif not self.query_one("#branch-create", Button).disabled: + self.dismiss(("create", event.value.strip(), None)) + + def _pick(self, value) -> None: + if value[1] == self.current and value[2] is None: + self.dismiss(None) + else: + self.dismiss(value) + + @on(ListView.Selected, "#branch-lv") + def _selected(self, event: ListView.Selected) -> None: + if isinstance(event.item, SimpleRow): + self._pick(event.item.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.name == "create": + self.dismiss(("create", self.query_one("#branch-filter", Input).value.strip(), None)) + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class RepoDialog(ModalScreen): + """Repository picker: recent repos, plus Add local / New / Clone. + + Returns a path, "add_local", "new", "clone", or None. + """ + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, current: str, first: bool, error: str = "") -> None: + super().__init__() + self.current, self.first, self.error = current, first, error + self.recent = [p for p in load_recent() if os.path.isdir(p)] + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog wide"): + yield Label(Text("Repositories", style="bold"), classes="dialog-title") + if self.error: + yield Static(Text(self.error, style=C_RED)) + if self.recent: + yield Static(Text("Recent", style=f"bold {C_MUTED}")) + rows = [SimpleRow(p, Text(os.path.basename(p) or p, style="bold"), + Text(p, style=C_MUTED, no_wrap=True, overflow="ellipsis")) + for p in self.recent] + yield ListView(*rows, id="repo-lv", classes="dialog-list") + else: + yield Static(Text("No recent repositories yet. Add one you already have, create a new " + "one, or clone one from GitHub, Gitea, etc.", style=C_MUTED)) + with Horizontal(classes="dialog-buttons"): + yield Button("Add local repository…", name="add_local", variant="success", compact=True) + yield Button("New repository…", name="new", variant="success", compact=True) + yield Button("Clone repository…", name="clone", variant="success", compact=True) + yield Button("Quit" if self.first else "Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + if self.recent: + self.query_one("#repo-lv", ListView).focus() + else: + self.query(Button).first().focus() + + @on(ListView.Selected, "#repo-lv") + def _selected(self, event: ListView.Selected) -> None: + if isinstance(event.item, SimpleRow): + self.dismiss(event.item.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + name = event.button.name + if name in ("new", "clone", "add_local"): + self.dismiss(name) + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class NewRepoDialog(ChooseFolderMixin, ModalScreen): + """Create a new repository. Returns a dict of the choices, or None.""" + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, parent: str, name: str = "") -> None: + super().__init__() + self.parent_folder = parent + self.initial_name = name + + def compose(self) -> ComposeResult: + ignore_opts = [("None", "")] + [(k, k) for k in GITIGNORE_TEMPLATES] + with Vertical(classes="dialog wide"): + yield Label(Text("Create a new repository", style="bold"), classes="dialog-title") + yield Static(id="nr-error", classes="error pinned") + with VerticalScroll(classes="form"): + yield Label("Name", classes="field-label") + yield Input(value=self.initial_name, placeholder="my-project", id="nr-name", compact=True) + yield Label("Description (optional)", classes="field-label") + yield Input(id="nr-desc", compact=True) + yield Label("Local path - the new folder is created inside this one", classes="field-label") + with Horizontal(classes="path-row"): + yield Input(value=self.parent_folder, id="nr-parent", compact=True) + yield Button("Choose…", name="choose", id="nr-choose", compact=True) + yield Static(id="nr-where", classes="hint") + yield Checkbox("Initialize this repository with a README", value=True, id="nr-readme", + compact=True) + yield Label("Git ignore", classes="field-label") + yield Select(ignore_opts, value="", allow_blank=False, id="nr-ignore", compact=True) + yield Label("License", classes="field-label") + yield Select([("None", ""), ("MIT License", "MIT")], value="", allow_blank=False, id="nr-license", + compact=True) + yield Label("Remote URL (optional) - an empty repository you've already created on " + "GitHub, Gitea, etc.", classes="field-label") + yield Input(placeholder="e.g. git@github.com:you/my-project.git", id="nr-remote", compact=True) + with Horizontal(classes="dialog-buttons pinned"): + yield Button("Create repository", variant="primary", name="create", compact=True) + yield Button("Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + self.query_one("#nr-name", Input).focus() + self._update_where() + + def _target(self) -> str: + name = self.query_one("#nr-name", Input).value.strip() + parent = os.path.expanduser(self.query_one("#nr-parent", Input).value.strip()) + return os.path.join(parent, name) if name else parent + + def _update_where(self) -> None: + name = self.query_one("#nr-name", Input).value.strip() + target = self._target() + text = Text(f"Will be created at: {target}" if name else "", style=C_MUTED) + if name and os.path.isdir(os.path.join(target, ".git")): + text = Text(f"{target} is already a repository - you'll be offered to open it.", style=C_ORANGE) + elif name and os.path.isdir(target) and os.listdir(target): + text = Text(f"{target} already exists - it will become a repository, and the files already " + "in it will be included in the first commit.", style=C_ORANGE) + self.query_one("#nr-where", Static).update(text) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id in ("nr-name", "nr-parent"): + self._update_where() + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._submit() + + def _error(self, msg: str) -> None: + self.query_one("#nr-error", Static).update(Text(msg, style=C_RED)) + + def _submit(self) -> None: + name = self.query_one("#nr-name", Input).value.strip() + parent = os.path.expanduser(self.query_one("#nr-parent", Input).value.strip()) + if not name: + return self._error("Give the repository a name.") + if BAD_NAME_RE.search(name) or name in (".", ".."): + return self._error('The name can\'t contain \\ / : * ? " < > |') + if not parent: + return self._error("Choose the folder to create it in.") + target = os.path.join(parent, name) + if os.path.exists(target) and not os.path.isdir(target): + return self._error(f"{target} is a file, not a folder.") + self.dismiss({ + "name": name, + "parent": parent, + "path": target, + "description": self.query_one("#nr-desc", Input).value.strip(), + "readme": self.query_one("#nr-readme", Checkbox).value, + "gitignore": self.query_one("#nr-ignore", Select).value or "", + "license": self.query_one("#nr-license", Select).value or "", + "remote": self.query_one("#nr-remote", Input).value.strip(), + }) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.name == "choose": + self.run_worker(self.choose_into("nr-parent", "Choose where to create the repository", "nr-choose"), + exit_on_error=False) + elif event.button.name == "create": + self._submit() + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class CloneDialog(ChooseFolderMixin, ModalScreen): + """Clone a repository. Returns (url, path) or None.""" + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, parent: str) -> None: + super().__init__() + self.parent_folder = parent + self._auto_path = parent + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog wide"): + yield Label(Text("Clone a repository", style="bold"), classes="dialog-title") + yield Label("Repository URL - HTTPS or SSH", classes="field-label") + yield Input(placeholder="https://github.com/owner/repo.git or git@host:owner/repo.git", + id="cl-url", compact=True) + yield Label("Local path", classes="field-label") + with Horizontal(classes="path-row"): + yield Input(value=self.parent_folder, id="cl-path", compact=True) + yield Button("Choose…", name="choose", id="cl-choose", compact=True) + yield Static(id="cl-error", classes="error") + with Horizontal(classes="dialog-buttons"): + yield Button("Clone", variant="primary", name="clone", compact=True) + yield Button("Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + self.query_one("#cl-url", Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id != "cl-url": + return + path_input = self.query_one("#cl-path", Input) + if path_input.value == self._auto_path: # user hasn't typed their own path + name = repo_name_from_url(event.value) + self._auto_path = os.path.join(self.parent_folder, name) if name else self.parent_folder + path_input.value = self._auto_path + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._submit() + + def _submit(self) -> None: + url = self.query_one("#cl-url", Input).value.strip() + path = os.path.expanduser(self.query_one("#cl-path", Input).value.strip()) + err = self.query_one("#cl-error", Static) + if not url: + err.update(Text("Paste the repository's URL.", style=C_RED)) + elif not path: + err.update(Text("Choose where to put it.", style=C_RED)) + elif os.path.isdir(path) and os.listdir(path): + err.update(Text(f"{path} already exists and isn't empty - pick another folder.", style=C_RED)) + else: + self.dismiss((url, path)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.name == "choose": + self.run_worker(self._choose(), exit_on_error=False) + elif event.button.name == "clone": + self._submit() + else: + self.dismiss(None) + + async def _choose(self) -> None: + chosen = await self.choose_into("cl-path", "Choose where to put the cloned repository", "cl-choose", + start=self.parent_folder) + if chosen: + self.parent_folder = chosen + name = repo_name_from_url(self.query_one("#cl-url", Input).value) + self._auto_path = os.path.join(chosen, name) if name else chosen + self.query_one("#cl-path", Input).value = self._auto_path + + def action_cancel(self) -> None: + self.dismiss(None) + + +ACTIONS = [ + ("fetch", "Fetch"), + ("pull", "Pull"), + ("push", "Push"), + ("sync", "Sync (pull, then push)"), + ("stash", "Stash all changes…"), + ("stashes", "Stashed changes…"), + ("discard_ticked", "Discard ticked changes…"), + ("branches", "Switch / create branch…"), + ("new_repo", "New repository…"), + ("repositories", "Repositories…"), + ("open_repo", "Add local repository…"), + ("clone_repo", "Clone repository…"), + ("add_remote", "Add / change remote URL…"), + ("open_editor", "Open in editor"), + ("open_folder", "Show in file manager"), + ("view_remote", "View on GitHub / remote site"), + ("toggle_log", "Show / hide git output"), + ("refresh", "Refresh"), +] + + +class ActionsDialog(ModalScreen): + BINDINGS = [Binding("escape", "cancel", "Close")] + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog narrow"): + yield Label(Text("Actions", style="bold"), classes="dialog-title") + for action, label in ACTIONS: + yield Button(label, name=action, classes="action-btn", compact=True) + with Horizontal(classes="dialog-buttons"): + yield Button("Close", name="", compact=True) + + def on_mount(self) -> None: + self.query(Button).first().focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss(event.button.name or None) + + def action_cancel(self) -> None: + self.dismiss(None) + + +# -------------------------------------------------------------------------- +# The app +# -------------------------------------------------------------------------- +GITDESK_THEME = Theme( + name="gitdesk", + primary="#2188ff", + secondary="#79b8ff", + accent="#2188ff", + warning="#f66a0a", + error="#d73a49", + success="#28a745", + foreground=C_TEXT, + background="#24292e", + surface="#2f363d", + panel="#1f2428", + dark=True, +) + +CSS = """ +Screen { background: #24292e; } +.hidden { display: none; } + +#toolbar { height: 3; background: #1f2428; } +.tb { height: 3; width: 32; padding: 0 1; content-align: left middle; + border-right: vkey #444d56; background: #1f2428; } +.tb:hover { background: #2f363d; } +.tb:focus { background: #2f363d; text-style: none; } +.tb:disabled { color: #6a737d; } +#tb-remote { width: 34; } +#tb-more { width: 14; } + +#main { height: 1fr; } +#left { width: 42; min-width: 30; border-right: vkey #444d56; } +#right { width: 1fr; } + +#tabs { height: 1fr; } +#tabs > ContentSwitcher { height: 1fr; } +TabPane { padding: 0; height: 1fr; } +#tab-changes > Vertical, #tab-history > Vertical { height: 1fr; } + +#banner { height: auto; background: #3b2a12; padding: 0 1; border-bottom: solid #f66a0a; } +#banner-buttons { height: auto; margin-top: 1; } +#changes-header { height: 1; padding: 0 1; background: #2b3036; } +#changes-header .tick { width: 4; } +#changes-count { width: 1fr; } + +#files { height: 1fr; background: #24292e; } +.filerow { padding: 0 1; height: 1; } +.filerow > Horizontal { height: 1; } +.filerow .tick { width: 4; } +.filerow .fpath { width: 1fr; } +.filerow .fkind { width: 2; } + +#stash-bar { height: 1; padding: 0 1; margin-top: 1; } +#stash-bar Button { margin-right: 1; } + +#commit-box { height: auto; padding: 1 1 0 1; border-top: solid #444d56; } +#summary { margin-bottom: 1; } +#description { height: 5; } +#commit { width: 100%; margin: 1 0; } + +#history { height: 1fr; } +.commitrow { height: auto; padding: 0 1; border-bottom: solid #30363d; } + +#placeholder { padding: 1 4; } +#ph-title { text-style: bold; margin-bottom: 1; } +#ph-cards { height: auto; margin-top: 1; } +.card { height: auto; border: round #444d56; padding: 0 1; margin-bottom: 1; } +.card-text { width: 1fr; height: auto; margin-right: 2; } +.card-text > Label { width: 100%; } +.card-btn { margin-top: 1; } + +#diffview { height: 1fr; } +#diff-header { height: 1; background: #1f2428; padding: 0 1; } +#diff-title { width: 1fr; } +#diff-header Button { margin-left: 1; } +#diff-scroll { height: 1fr; } + +#gitlog { height: 12; border-top: solid #444d56; background: #1f2428; } + +ModalScreen { align: center middle; background: rgba(0, 0, 0, 0.55); } +.dialog { width: 76; max-width: 95%; height: auto; max-height: 90%; + background: #2f363d; border: round #586069; padding: 1 2; } +.dialog.wide { width: 90; } +.dialog.narrow { width: 44; } +.dialog-title { margin-bottom: 1; } +.dialog-body { height: auto; max-height: 22; } +.dialog-buttons { height: auto; align-horizontal: right; margin-top: 1; } +.dialog-buttons Button { margin-left: 2; } +.dialog-list { height: auto; max-height: 16; margin-top: 1; background: #24292e; } +.dialog-list > ListItem { padding: 0 1; } +.preview { height: auto; max-height: 8; margin-top: 1; } +.action-btn { width: 100%; margin-bottom: 0; } +#dlg-input { margin-top: 1; } +.form { height: auto; } +.pinned { dock: bottom; } +.dialog-buttons.pinned { margin-top: 0; padding-top: 1; } +.error.pinned { margin-top: 0; } +.form Input, .dialog Input.-textual-compact { background: #24292e; } +.form Select, .form Checkbox { width: 100%; } +.field-label { margin-top: 1; color: #959da5; } +.hint { height: auto; color: #959da5; } +.error { height: auto; margin-top: 1; } +#nr-readme { margin-top: 1; } +.path-row { height: auto; } +.path-row Input { width: 1fr; } +.path-row Button { margin-left: 1; min-width: 12; } +.folder-tree { height: 18; background: #24292e; margin-top: 1; } +""" + + +class GitDesk(App): + CSS = CSS + TITLE = "gitdesk" + BINDINGS = [ + Binding("f", "fetch", "Fetch"), + Binding("p", "pull", "Pull"), + Binding("u", "push", "Push"), + Binding("b", "branches", "Branch"), + Binding("s", "stash", "Stash"), + Binding("t", "stashes", "Stashes"), + Binding("d", "discard", "Discard"), + Binding("o", "open_repo", "Add local"), + Binding("n", "new_repo", "New"), + Binding("c", "clone_repo", "Clone"), + Binding("g", "view_remote", "Web"), + Binding("l", "toggle_log", "Log"), + Binding("r", "refresh", "Refresh"), + Binding("space", "toggle_file", "Include file", show=False), + Binding("q", "quit", "Quit"), + ] + + def __init__(self, start_path: Optional[str] = None) -> None: + super().__init__() + self.start_path = start_path + self.busy_detail = "" + self.git: Optional[Git] = None + self.state: Optional[RepoState] = None + self.excluded: Set[str] = set() + self.selected: Optional[str] = None + self.mode = "changes" # or "history" + self.busy = False + self.busy_label = "" + self.last_fetch: Optional[float] = None + self.log_q: "queue.Queue" = queue.Queue() + self._files_key = None + self._ph_key = None + self._diff_key = None + self._history_key = None + self._merge_prefilled = False + self._lock: Optional[asyncio.Lock] = None + self._net_lock: Optional[asyncio.Lock] = None + self.fetching = False + + # ---- layout ------------------------------------------------------------ + def compose(self) -> ComposeResult: + with Horizontal(id="toolbar"): + yield ToolbarButton("▤", "Current repository", "—", id="tb-repo", dropdown=True) + yield ToolbarButton("⎇", "Current branch", "—", id="tb-branch", dropdown=True) + yield ToolbarButton("↻", "Fetch origin", "", id="tb-remote", emphasis="top") + yield ToolbarButton("☰", "More", "Actions", id="tb-more") + with Horizontal(id="main"): + with Vertical(id="left"): + with TabbedContent(id="tabs", initial="tab-changes"): + with TabPane("Changes", id="tab-changes"): + with Vertical(): + with Vertical(id="banner", classes="hidden"): + yield Static(id="banner-text") + with Horizontal(id="banner-buttons"): + yield Button("Abort merge", id="abort-merge", variant="error", compact=True) + with Horizontal(id="changes-header"): + yield Tick(True, id="tick-all", classes="tick") + yield Label("0 changed files", id="changes-count") + yield ListView(id="files") + with Horizontal(id="stash-bar"): + yield Button("Stash all", id="stash-all", compact=True) + yield Button("Stashed (0)", id="stash-list", compact=True) + with Vertical(id="commit-box"): + yield Input(placeholder="Summary (required)", id="summary") + yield TextArea(id="description", placeholder="Description", + show_line_numbers=False, soft_wrap=True) + yield Button("Commit", id="commit", variant="primary", compact=True) + with TabPane("History", id="tab-history"): + with Vertical(): + yield ListView(id="history") + with ContentSwitcher(id="right", initial="placeholder"): + with VerticalScroll(id="placeholder"): + yield Static(id="ph-title") + yield Static(id="ph-sub") + yield Vertical(id="ph-cards") + with Vertical(id="diffview"): + with Horizontal(id="diff-header"): + yield Label("", id="diff-title") + yield Button("Open in editor", id="open-file", compact=True) + yield Button("Discard", id="discard-file", variant="error", compact=True) + yield Button("View on web", id="view-commit", compact=True) + yield Button("Mark as resolved", id="mark-resolved", variant="warning", compact=True) + with VerticalScroll(id="diff-scroll"): + yield Static(id="diff") + yield RichLog(id="gitlog", classes="hidden", markup=False, wrap=True, max_lines=3000) + yield Footer() + + async def on_mount(self) -> None: + self.register_theme(GITDESK_THEME) + self.theme = "gitdesk" + self._lock = asyncio.Lock() + self._net_lock = asyncio.Lock() + self.set_interval(3, self.poll) + self.set_interval(0.3, self.drain_log) + self.set_interval(30, self.update_remote_button) + self.set_interval(300, self.auto_fetch) + if await self.open_start_repo(): + self.run_worker(self.auto_fetch(), exit_on_error=False) + else: + self.run_worker(self.open_repo_flow(first=True), exit_on_error=False) + + async def open_start_repo(self) -> bool: + """A path on the command line wins; otherwise the folder you launched from if it's a + repository (but not gitdesk's own folder); otherwise the repository you had open last.""" + if self.start_path: + return await self.load_repo(self.start_path) + candidates = [] + here = await asyncio.to_thread(Git.open, os.getcwd(), self._log_cmd) + if here is not None: + app_dir = os.path.dirname(os.path.abspath( + sys.executable if getattr(sys, "frozen", False) else __file__)) + own = await asyncio.to_thread(Git.open, app_dir, self._log_cmd) + if own is None or os.path.normcase(own.path) != os.path.normcase(here.path): + candidates.append(here.path) + candidates += [p for p in load_recent() if os.path.isdir(p)] + for path in candidates: + if await self.load_repo(path): + return True + return False + + APP_KEYS = {"fetch", "pull", "push", "branches", "stash", "stashes", "open_repo", "discard", "view_remote", + "new_repo", "clone_repo", "add_remote", "repositories", + "toggle_log", "refresh", "toggle_file", "quit"} + + def check_action(self, action: str, parameters) -> Optional[bool]: + # Single-letter shortcuts are switched off while a popup is open. + if action in self.APP_KEYS and isinstance(self.screen, ModalScreen): + return False + return True + + # ---- small accessors --------------------------------------------------- + def remote_name(self) -> str: + st = self.state + if st and st.upstream and st.remotes: + for r in sorted(st.remotes, key=len, reverse=True): + if st.upstream.startswith(r + "/"): + return r + if st and st.remotes: + return "origin" if "origin" in st.remotes else st.remotes[0] + return "origin" + + def web_url(self) -> Optional[str]: + """Web page of the current remote. Override per repo: git config gitdesk.webUrl """ + st = self.state + if st is None or self.git is None: + return None + url = st.remote_urls.get(self.remote_name(), "") + key = (self.git.path, url) + if getattr(self, "_web_cache_key", None) != key: + override = self.git.run("config", "--get", "gitdesk.webUrl", log=False).out.strip() + self._web_cache_key = key + self._web_cache = override.rstrip("/") or (web_url_for(url) if url else None) + return self._web_cache + + def open_url(self, url: str) -> None: + """Open a link in the browser, without letting a text browser take over this terminal.""" + try: + if IS_WIN: + os.startfile(url) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", url]) + elif (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) and shutil.which("xdg-open"): + subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True) + elif shutil.which("wslview"): + subprocess.Popen(["wslview", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + else: + self.copy_to_clipboard(url) + self.notify(f"No browser found here - the link is copied to your clipboard:\n{url}", timeout=20) + return + self.notify(f"Opening {url}") + except OSError as e: + self.notify(f"Couldn't open the browser ({e}). The link is:\n{url}", severity="error", timeout=20) + + def action_view_remote(self) -> None: + if self.git is None or self.state is None: + return + url = self.web_url() + if not url: + remote = self.state.remote_urls.get(self.remote_name()) + if not remote: + self.notify("This repository has no remote, so there's no web page to open.", severity="warning") + else: + self.notify(f"Couldn't work out a web page from {remote}. Set one with:\n" + "git config gitdesk.webUrl https://host/owner/repo", severity="warning", timeout=12) + return + self.open_url(url) + + def included_files(self) -> List[FileChange]: + return [f for f in (self.state.files if self.state else []) if f.path not in self.excluded] + + async def bg(self, *args, input: Optional[str] = None, serialize: bool = True) -> Result: + return await asyncio.to_thread(self.git.run, *args, input=input, serialize=serialize) + + async def load_state(self) -> RepoState: + st = await asyncio.to_thread(self.git.state) + self.state = st + return st + + def _log_cmd(self, args, res: Result) -> None: + self.log_q.put((args, res)) # called from worker threads + + def drain_log(self) -> None: + try: + log = self.query_one("#gitlog", RichLog) + except NoMatches: # app is starting up or shutting down + return + while True: + try: + args, res = self.log_q.get_nowait() + except queue.Empty: + break + log.write(Text("$ git " + " ".join(args), style=f"bold {C_BLUE}")) + out = (res.out + ("\n" if res.out and res.err else "") + res.err).strip() + if out: + lines = out.splitlines() + if len(lines) > 40: + lines = lines[:40] + [f"… ({len(lines) - 40} more lines)"] + log.write(Text("\n".join(lines), style=C_TEXT if res.ok else "#f97583")) + + # ---- loading / refreshing ---------------------------------------------- + async def load_repo(self, path: str) -> bool: + g = await asyncio.to_thread(Git.open, path, self._log_cmd) + if g is None: + return False + self.git = g + self.state = None + self.excluded.clear() + self.selected = None + self._files_key = self._ph_key = self._diff_key = self._history_key = None + self.last_fetch = g.last_fetch_time() + save_recent(g.path) + self.sub_title = g.path + await self.query_one("#history", ListView).clear() + await self.refresh_state(force=True) + return True + + async def poll(self) -> None: + if not self.busy and self.git is not None: + await self.refresh_state() + + async def refresh_state(self, force: bool = False) -> None: + if self.git is None or self._lock is None: + return + if self._lock.locked() and not force: + return + async with self._lock: + g = self.git + try: + st = await asyncio.to_thread(g.state) + except GitError as e: + self.notify(str(e), title="git status failed", severity="error") + return + if g is not self.git: + return + self.state = st + try: + await self.apply_state(st) + except NoMatches: # app is shutting down + pass + + async def apply_state(self, st: RepoState) -> None: + g = self.git + self.query_one("#tb-repo", ToolbarButton).set(bottom=os.path.basename(g.path) or g.path) + branch = st.branch or (f"detached at {st.oid[:7]}" if st.oid else "detached") + self.query_one("#tb-branch", ToolbarButton).set(bottom=branch) + self.update_remote_button() + + key = tuple((f.path, f.kind, f.orig) for f in st.files) + if key != self._files_key: + self._files_key = key + await self.rebuild_files(st) + self.update_counts() + self.update_banner(st) + set_label(self.query_one("#stash-list", Button), f"Stashed ({st.stashes})") + self.query_one("#stash-list", Button).disabled = self.busy or st.stashes == 0 + self.query_one("#stash-all", Button).disabled = self.busy or not st.files + + if st.merging and not self._merge_prefilled: + self._merge_prefilled = True + summary = self.query_one("#summary", Input) + if not summary.value: + summary.value = g.merge_message() + elif not st.merging: + self._merge_prefilled = False + self.update_commit_button() + + if self.mode == "changes": + await self.show_changes_side(st) + else: + await self.load_history() + + async def rebuild_files(self, st: RepoState) -> None: + lv = self.query_one("#files", ListView) + paths = {f.path for f in st.files} + self.excluded &= paths + await lv.clear() + shown = st.files[:MAX_FILES_SHOWN] + rows: List[ListItem] = [FileRow(f, f.path not in self.excluded) for f in shown] + if len(st.files) > len(shown): + rows.append(ListItem(Label(Text(f"… and {len(st.files) - len(shown)} more files", + style=C_MUTED)), disabled=True)) + await lv.extend(rows) + if self.selected not in paths: + self.selected = shown[0].path if shown else None + for i, f in enumerate(shown): + if f.path == self.selected: + lv.index = i + break + + def update_counts(self) -> None: + st = self.state + n = len(st.files) if st else 0 + inc = len(self.included_files()) + self.query_one("#changes-count", Label).update( + Text(plural(n, "changed file"), style="bold" if n else C_MUTED)) + self.query_one("#tick-all", Tick).checked = n > 0 and inc == n + self.query_one("#tabs", TabbedContent).get_tab("tab-changes").label = ( + f"Changes ({n})" if n else "Changes") + + def update_banner(self, st: RepoState) -> None: + banner = self.query_one("#banner") + abort = self.query_one("#abort-merge", Button) + text = None + if st.rebasing: + text = Text.assemble(("Rebase in progress. ", "bold"), + "Finish it in a terminal with git rebase --continue (or --abort).") + abort.display = False + elif st.merging: + if st.conflicts: + text = Text.assemble(("Merge conflicts: ", "bold"), + f"{plural(len(st.conflicts), 'file')} need fixing (marked ", (" ! ", f"bold {C_ORANGE}"), + "). Open each one, keep the parts you want, remove the <<<<<<< ======= >>>>>>> " + "lines, click Mark as resolved, then commit the merge.") + else: + text = Text.assemble(("All conflicts resolved. ", "bold"), "Commit the merge to finish it.") + abort.display = True + elif st.conflicts: + text = Text.assemble(("Conflicts: ", "bold"), + f"{plural(len(st.conflicts), 'file')} couldn't be merged with your stashed changes. " + "Fix the <<<<<<< ======= >>>>>>> parts, then click Mark as resolved.") + abort.display = False + banner.set_class(text is None, "hidden") + if text is not None: + self.query_one("#banner-text", Static).update(text) + + def update_commit_button(self) -> None: + st = self.state + btn = self.query_one("#commit", Button) + if st is None: + btn.disabled = True + return + summary = self.query_one("#summary", Input).value.strip() + if st.merging: + btn.label = "Commit merge" + btn.disabled = self.busy or bool(st.conflicts) + else: + n = len(self.included_files()) + btn.label = f"Commit {plural(n, 'file')} to {st.branch or 'HEAD'}" + btn.disabled = self.busy or n == 0 or not summary or bool(st.conflicts) + + def remote_mode(self) -> str: + st = self.state + if st is None or not st.remotes: + return "none" + if st.branch is None: + return "fetch" + if not st.has_upstream: + return "publish" + if st.behind: + return "pull" + if st.ahead: + return "push" + return "fetch" + + def update_remote_button(self) -> None: + try: + b = self.query_one("#tb-remote", ToolbarButton) + except NoMatches: # app is shutting down + return + st = self.state + if self.busy: + b.set(icon="…", top=self.busy_label + "…", bottom=self.busy_detail or "Working", badge="") + b.disabled = True + return + b.disabled = False + remote = self.remote_name() + fetched = f"Last fetched {ago(self.last_fetch)}" + if self.fetching: + fetched = "Checking for changes…" + mode = self.remote_mode() + if mode == "none": + b.set(icon="+", top="No remote", bottom="Click to add one", badge="") + elif mode == "publish": + b.set(icon="↑", top="Publish branch", bottom=f"Push it to {remote}", badge="") + elif mode == "pull": + badge = f"↓{st.behind}" + (f" ↑{st.ahead}" if st.ahead else "") + b.set(icon="↓", top=f"Pull {remote}", bottom=fetched, badge=badge) + elif mode == "push": + b.set(icon="↑", top=f"Push {remote}", bottom=fetched, badge=f"↑{st.ahead}") + else: + b.set(icon="↻", top=f"Fetch {remote}", bottom=fetched, badge="") + + def set_busy(self, busy: bool, label: str = "") -> None: + self.busy, self.busy_label = busy, label + for sel in ("#tb-branch", "#tb-repo", "#stash-all", "#stash-list", "#abort-merge", "#mark-resolved", "#discard-file"): + self.query_one(sel).disabled = busy + self.update_remote_button() + self.update_commit_button() + + # ---- right-hand side --------------------------------------------------- + async def show_changes_side(self, st: RepoState) -> None: + switcher = self.query_one("#right", ContentSwitcher) + if not st.files: + self._diff_key = None + await self.rebuild_placeholder(st) + switcher.current = "placeholder" + return + fc = next((f for f in st.files if f.path == self.selected), None) + if fc is None: + fc = st.files[0] + self.selected = fc.path + switcher.current = "diffview" + self.run_worker(self.show_file_diff(fc), group="diff", exclusive=True, exit_on_error=False) + + async def show_file_diff(self, fc: FileChange) -> None: + g, st = self.git, self.state + text = await asyncio.to_thread(g.file_diff, fc, st.oid is not None) + if self.mode != "changes" or self.selected != fc.path: + return + key = ("file", fc.path, fc.kind, text) + if key == self._diff_key: + return + new_file = not self._diff_key or self._diff_key[1] != fc.path + self._diff_key = key + icon, color, label = KIND_ICON.get(fc.kind, ("●", C_YELLOW, "")) + title = Text(no_wrap=True, overflow="ellipsis") + title.append(f"{icon} ", style=f"bold {color}") + title.append(fc.path, style="bold") + title.append(f" {label}", style=C_MUTED) + self.query_one("#diff-title", Label).update(title) + self.query_one("#open-file").display = True + self.query_one("#discard-file").display = not fc.is_conflict + self.query_one("#view-commit").display = False + self.query_one("#mark-resolved").display = fc.is_conflict + self.query_one("#diff", Static).update(render_diff(text)) + self.query_one("#right", ContentSwitcher).current = "diffview" + if new_file: + self.query_one("#diff-scroll", VerticalScroll).scroll_home(animate=False) + + async def rebuild_placeholder(self, st: RepoState) -> None: + key = (st.behind, st.ahead, st.has_upstream, bool(st.remotes), st.branch, st.stashes, self.web_url()) + if key == self._ph_key: + return + self._ph_key = key + self.query_one("#ph-title", Static).update(Text("No local changes", style=f"bold {C_TEXT}")) + self.query_one("#ph-sub", Static).update(Text( + "There are no uncommitted changes in this repository. Here are some friendly " + "suggestions for what to do next.", style=C_MUTED)) + remote = self.remote_name() + cards = [] + if not st.remotes: + cards.append(Card("Publish your repository", + "It isn't on GitHub, Gitea or another server yet. Create an empty repository " + "there, then add its URL here.", "Add remote URL", "add_remote")) + if st.remotes and st.branch: + if not st.has_upstream and st.oid: + cards.append(Card("Publish your branch", + f"'{st.branch}' isn't on {remote} yet. Publish it so it's backed up and shareable.", + "Publish branch", "push")) + elif st.behind: + cards.append(Card(f"Pull {plural(st.behind, 'commit')} from {remote}", + "The remote has changes you don't have yet.", f"Pull {remote}", "pull")) + elif st.ahead: + cards.append(Card(f"Push {plural(st.ahead, 'commit')} to {remote}", + "You have local commits that aren't on the remote yet.", f"Push {remote}", "push")) + if st.stashes: + cards.append(Card(f"You have {plural(st.stashes, 'stash')}", + "Changes you put aside earlier. Bring them back any time.", + "View stashed changes", "stashes")) + cards.append(Card("Open the repository in your external editor", + f"Opens it in {editor_name()}.", "Open in editor", "open_editor")) + cards.append(Card("View the files of your repository", + f"Opens the folder in {file_manager_name()}.", + f"Show in {file_manager_name()}", "open_folder")) + web = self.web_url() + if web: + site = web_site_name(web) + cards.append(Card(f"Open the repository page on {site} in your browser", web, + f"View on {site}", "view_remote")) + box = self.query_one("#ph-cards", Vertical) + await box.remove_children() + await box.mount_all(cards) + + async def load_history(self) -> None: + st, g = self.state, self.git + if st is None: + return + key = (st.oid, st.upstream, st.ahead) + if key == self._history_key: + return + self._history_key = key + commits, unpushed = await asyncio.to_thread(g.history, st.has_upstream) if st.oid else ([], set()) + lv = self.query_one("#history", ListView) + await lv.clear() + await lv.extend([CommitRow(c, c.sha in unpushed) for c in commits]) + if not commits: + self._show_message("History", "No commits yet.") + + def _show_message(self, title: str, message: str) -> None: + self._diff_key = None + self.query_one("#diff-title", Label).update(Text(title, style="bold")) + self.query_one("#open-file").display = False + self.query_one("#discard-file").display = False + self.query_one("#mark-resolved").display = False + self.query_one("#view-commit").display = False + self.query_one("#diff", Static).update(Text(message, style=f"italic {C_MUTED}")) + self.query_one("#right", ContentSwitcher).current = "diffview" + + async def show_commit(self, c: Commit, unpushed: bool = False) -> None: + g = self.git + header = await asyncio.to_thread(g.commit_header, c.sha) + diff = await asyncio.to_thread(g.commit_diff, c.sha) + if self.mode != "history": + return + lines = header.splitlines() + head = Text() + if len(lines) >= 3: + body = "\n".join(lines[3:]).strip() + _, _, rest = body.partition("\n") # subject is already in the title bar + head.append(f" {lines[1]} • {lines[2]} • {lines[0][:10]}\n", style=C_MUTED) + if rest.strip(): + head.append("\n" + "\n".join(" " + ln for ln in rest.strip().splitlines()) + "\n", + style=C_TEXT) + head.append("\n") + self._diff_key = ("commit", c.sha) + title = Text(no_wrap=True, overflow="ellipsis") + title.append(c.short + " ", style=C_BLUE) + title.append(c.subject, style="bold") + self.query_one("#diff-title", Label).update(title) + self.query_one("#open-file").display = False + self.query_one("#discard-file").display = False + self.query_one("#mark-resolved").display = False + web = self.web_url() + view = self.query_one("#view-commit", Button) + view.display = bool(web) and not unpushed # unpushed commits aren't on the site yet + if web: + set_label(view, f"View on {web_site_name(web)}") + self._shown_commit = c.sha + self.query_one("#diff", Static).update(Group(head, render_diff(diff, multi_file=True))) + self.query_one("#right", ContentSwitcher).current = "diffview" + self.query_one("#diff-scroll", VerticalScroll).scroll_home(animate=False) + + # ---- UI events --------------------------------------------------------- + @on(TabbedContent.TabActivated, "#tabs") + async def _tab_changed(self, event: TabbedContent.TabActivated) -> None: + self.mode = "history" if event.pane.id == "tab-history" else "changes" + self._diff_key = None + if self.state is None: + return + if self.mode == "history": + await self.load_history() + lv = self.query_one("#history", ListView) + if isinstance(lv.highlighted_child, CommitRow): + self.run_worker(self.show_commit(lv.highlighted_child.commit, lv.highlighted_child.unpushed), + group="diff", + exclusive=True, exit_on_error=False) + elif lv.children: + self._show_message("History", "Click a commit to see what changed.") + else: + await self.show_changes_side(self.state) + + @on(ListView.Highlighted, "#files") + def _file_highlighted(self, event: ListView.Highlighted) -> None: + if isinstance(event.item, FileRow) and self.mode == "changes": + self.selected = event.item.fc.path + self.run_worker(self.show_file_diff(event.item.fc), group="diff", exclusive=True, + exit_on_error=False) + + @on(ListView.Highlighted, "#history") + def _commit_highlighted(self, event: ListView.Highlighted) -> None: + if isinstance(event.item, CommitRow) and self.mode == "history": + self.run_worker(self.show_commit(event.item.commit, event.item.unpushed), group="diff", exclusive=True, + exit_on_error=False) + + @on(Tick.Toggled) + def _tick_toggled(self, event: Tick.Toggled) -> None: + st = self.state + if st is None: + return + tick = event.tick + if tick.id == "tick-all": + self.excluded = set() if tick.checked else {f.path for f in st.files} + for t in self.query("#files Tick"): + t.checked = tick.checked + elif tick.key: + (self.excluded.discard if tick.checked else self.excluded.add)(tick.key) + self.update_counts() + self.update_commit_button() + + def action_toggle_file(self) -> None: + lv = self.query_one("#files", ListView) + if self.mode != "changes" or not lv.has_focus or not isinstance(lv.highlighted_child, FileRow): + return + tick = lv.highlighted_child.query_one(Tick) + tick.checked = not tick.checked + tick.post_message(Tick.Toggled(tick)) + + @on(Input.Changed, "#summary") + def _summary_changed(self) -> None: + self.update_commit_button() + + @on(Input.Submitted, "#summary") + def _summary_submitted(self) -> None: + if not self.query_one("#commit", Button).disabled: + self.start_op("Committing", self.commit_flow) + + @on(ToolbarButton.Pressed) + def _toolbar(self, event: ToolbarButton.Pressed) -> None: + bid = event.button.id + if bid == "tb-repo": + self.action_repositories() + elif bid == "tb-branch": + self.action_branches() + elif bid == "tb-more": + self.run_worker(self.actions_flow(), exit_on_error=False) + elif bid == "tb-remote": + mode = self.remote_mode() + if mode == "none": + self.action_add_remote() + elif mode == "pull": + self.action_pull() + elif mode in ("push", "publish"): + self.action_push() + else: + self.action_fetch() + + async def on_button_pressed(self, event: Button.Pressed) -> None: + b = event.button + if b.has_class("card-btn") and b.name: + await self.run_action(b.name) + elif b.id == "commit": + self.start_op("Committing", self.commit_flow) + elif b.id == "stash-all": + self.action_stash() + elif b.id == "stash-list": + self.action_stashes() + elif b.id == "abort-merge": + self.start_op("Aborting merge", self.abort_merge_flow) + elif b.id == "mark-resolved": + self.start_op("Marking resolved", self.mark_resolved_flow) + elif b.id == "view-commit" and self.web_url() and getattr(self, "_shown_commit", None): + self.open_url(commit_web_url(self.web_url(), self._shown_commit)) + elif b.id == "discard-file": + self.action_discard() + elif b.id == "open-file" and self.selected and self.git: + self.action_open_editor(os.path.join(self.git.path, self.selected)) + + # ---- actions (keyboard + buttons) -------------------------------------- + def action_fetch(self) -> None: + self.start_op("Fetching", self.fetch_flow) + + def action_pull(self) -> None: + self.start_op("Pulling", self.pull_flow) + + def action_push(self) -> None: + self.start_op("Pushing", self.push_flow) + + def action_sync(self) -> None: + self.start_op("Syncing", lambda: self.push_flow(auto_pull=True)) + + def action_stash(self) -> None: + self.run_worker(self.stash_all_flow(), exit_on_error=False) + + def action_stashes(self) -> None: + self.run_worker(self.stashes_flow(), exit_on_error=False) + + def action_discard(self) -> None: + st = self.state + fc = next((f for f in (st.files if st else []) if f.path == self.selected), None) + if self.mode != "changes" or fc is None: + self.notify("Select a changed file first.", severity="warning") + return + self.run_worker(self.discard_flow([fc]), exit_on_error=False) + + def action_discard_ticked(self) -> None: + self.run_worker(self.discard_flow(self.included_files()), exit_on_error=False) + + def action_branches(self) -> None: + self.run_worker(self.branches_flow(), exit_on_error=False) + + def action_open_repo(self) -> None: + self.run_worker(self.add_local_flow(), exit_on_error=False) + + def action_toggle_log(self) -> None: + self.query_one("#gitlog").toggle_class("hidden") + + async def action_refresh(self) -> None: + self._files_key = self._ph_key = self._diff_key = self._history_key = None + await self.refresh_state(force=True) + + def action_open_editor(self, path: Optional[str] = None) -> None: + if self.git is None: + return + target = path or self.git.path + code = shutil.which("code") + try: + if code: + if IS_WIN: + subprocess.Popen(f'"{code}" "{target}"', shell=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW) + else: + subprocess.Popen([code, target], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + self.notify(f"Opening in VS Code: {os.path.basename(target)}") + return + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") + if editor: + with self.suspend(): + subprocess.call(f'{editor} "{target}"', shell=True) + return + except (OSError, SuspendNotSupported) as e: + self.notify(f"Couldn't open the editor: {e}", severity="error") + return + self.notify("Couldn't find VS Code ('code' command) or an EDITOR environment variable.", + severity="warning") + + def action_open_folder(self) -> None: + if self.git is None: + return + try: + if IS_WIN: + os.startfile(self.git.path) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", self.git.path]) + elif shutil.which("xdg-open"): + subprocess.Popen(["xdg-open", self.git.path], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + else: + self.notify("No file manager found (xdg-open isn't installed).", severity="warning") + except OSError as e: + self.notify(f"Couldn't open the folder: {e}", severity="error") + + # ---- running operations ------------------------------------------------ + def idle_or_warn(self) -> bool: + if self.git is None or self.state is None: + self.notify("Open a repository first.", severity="warning") + return False + if self.busy: + self.notify(f"Still busy ({self.busy_label.lower()}) - try again in a moment.", severity="warning") + return False + return True + + def start_op(self, label: str, flow: Callable) -> None: + if self.git is None or self.state is None: + self.notify("Open a repository first.", severity="warning") + return + if self.busy: + self.notify("Still working on the last action…", severity="warning") + return + self.set_busy(True, label) + self.run_worker(self._run_op(flow), exit_on_error=False) + + async def _run_op(self, flow: Callable) -> None: + try: + await flow() + except Exception as e: # keep the app alive whatever happens + await self.error_dialog("Something went wrong", f"{type(e).__name__}: {e}") + finally: + self.set_busy(False) + await self.refresh_state(force=True) + self.update_remote_button() + + async def ask(self, title: str, body, buttons, cancel=None) -> str: + return await self.push_screen_wait(ChoiceDialog(title, body, buttons, cancel)) + + async def error_dialog(self, title: str, message: str) -> None: + await self.ask(title, Text(message.strip(), style="#f97583"), [("ok", "OK", "primary")]) + + async def fail(self, title: str, r: Result, hint: str = "") -> None: + """Show a git failure, unless the user has already seen it (e.g. the lock popup).""" + if r.handled: + return + hint = hint or hint_for(r.message) + await self.error_dialog(title, r.message + (f"\n\n{hint}" if hint else "")) + + async def gitop(self, *args, input: Optional[str] = None) -> Result: + """Run a git command that changes the repository. + + If it fails because another program (GitHub Desktop, VS Code, Unity, a + terminal...) has the repository locked, and the failed attempt didn't + change anything, wait a moment and try again. If the lock doesn't go + away, ask the user what to do. + """ + while True: + r = await self._gitop_retrying(args, input) + if r.ok or not r.lockish: + return r + choice = await self.lock_dialog(r) + if choice == "remove": + try: + os.remove(self.git.index_lock_path()) + self._log_cmd(["(gitdesk removed .git/index.lock)"], Result(0)) + except OSError as e: + await self.error_dialog("Couldn't remove the lock file", str(e)) + r.handled = True + return r + elif choice != "retry": + r.handled = True + return r + + async def _gitop_retrying(self, args, input) -> Result: + before = await asyncio.to_thread(self.git.snapshot) + for delay in (0.25, 0.5, 0.75, 1.0, 1.5, None): + r = await self.bg(*args, input=input) + if r.ok or not looks_lockish(r): + return r + after = await asyncio.to_thread(self.git.snapshot) + if after != before: + return r # it changed something - never repeat blindly + r.lockish = True + if delay is None: + return r + await asyncio.sleep(delay) + return r + + async def lock_dialog(self, r: Result) -> str: + lock = self.git.index_lock_path() + body = Text() + detail = STASH_KEPT_RE.sub("", f"{r.err}\n{r.out}").strip() + if detail: + body.append(detail + "\n\n", style="#f97583") + if os.path.exists(lock): + try: + age = ago(os.path.getmtime(lock)) + except OSError: + age = "a moment ago" + body.append("Another git program is using this repository right now, so git couldn't " + "update it. The lock file ") + body.append(".git/index.lock", style="bold") + body.append(f" was created {age}.\n\n") + body.append("If GitHub Desktop, VS Code, Unity or another terminal is busy with this repository, " + "wait for it to finish and try again. If nothing else is running, the lock was " + "left behind by a git program that crashed, and it's safe to remove it.", style=C_MUTED) + buttons = [("retry", "Try again", "primary"), ("remove", "Remove lock file & retry", "warning"), + ("close", "Close", "default")] + else: + body.append("Another program had the repository busy, so git couldn't update it. This is usually " + "GitHub Desktop, VS Code, Unity, an antivirus scan, or a sync tool (OneDrive, Dropbox) " + "briefly holding the repository's files. Wait a moment and try again.", style=C_MUTED) + buttons = [("retry", "Try again", "primary"), ("close", "Close", "default")] + return await self.ask("The repository is busy", body, buttons, cancel="close") + + async def net(self, args: List[str], what: str) -> bool: + """Run a network git command in the background; on failure offer to retry.""" + while True: + async with self._net_lock: + r = await self.bg(*args, serialize=False) + if r.ok: + return True + tail = "\n".join(r.message.splitlines()[-10:]) + body = Text.assemble((tail, "#f97583"), "\n\n", + ("If git needs a password or token, an SSH passphrase, or wants you to " + "confirm the server's key, 'Retry in terminal' lets it ask you.", C_MUTED)) + choice = await self.ask(f"Couldn't {what}", body, + [("retry", "Try again", "default"), + ("terminal", "Retry in terminal", "primary"), + ("close", "Close", "default")], cancel="close") + if choice == "terminal": + return self.run_in_terminal(args) + if choice != "retry": + return False + + def run_in_terminal(self, args: List[str], cwd: Optional[str] = None) -> bool: + rc = 1 + try: + with self.suspend(): + print(f"\n[gitdesk] git {' '.join(args)}\n", flush=True) + rc = subprocess.call(["git", *args], cwd=cwd or self.git.path) + print("\n[gitdesk] " + ("Done." if rc == 0 else f"git exited with code {rc}.")) + try: + input("Press Enter to go back to gitdesk...") + except EOFError: + pass + except SuspendNotSupported: + self.notify("This terminal can't hand over to git for a prompt.", severity="error") + return False + self._log_cmd(args, Result(rc, "(ran in the terminal)")) + return rc == 0 + + async def auto_fetch(self) -> None: + """Quiet background fetch. Never blocks other actions and never pops up dialogs.""" + if self.busy or self.git is None or self.state is None or not self.state.remotes: + return + if self._net_lock.locked(): + return + async with self._net_lock: + self.fetching = True + self.update_remote_button() + try: + r = await self.bg("fetch", "--prune", self.remote_name(), serialize=False) + if r.ok: + self.last_fetch = time.time() + finally: + self.fetching = False + await self.refresh_state(force=True) + self.update_remote_button() + + async def do_fetch(self) -> bool: + remote = self.remote_name() + ok = await self.net(["fetch", "--prune", remote], f"fetch from {remote}") + if ok: + self.last_fetch = time.time() + return ok + + def blocked_by_merge(self, st: RepoState) -> bool: + if st.merging or st.conflicts or st.rebasing: + self.notify("Finish resolving the conflicts (or abort the merge) first.", severity="warning") + return True + return False + + # ---- flows ------------------------------------------------------------- + async def fetch_flow(self) -> None: + if not self.state.remotes: + self.notify("This repository has no remote to fetch from.", severity="warning") + return + if await self.do_fetch(): + self.notify(f"Fetched {self.remote_name()}.") + + async def pull_flow(self) -> None: + st = self.state + if self.blocked_by_merge(st): + return + if st.branch is None: + self.notify("You're not on a branch, so there's nothing to pull.", severity="warning") + return + if not st.has_upstream: + self.notify(f"'{st.branch}' isn't on {self.remote_name()} yet. Publish it first.", severity="warning") + return + if await self.do_fetch(): + await self.merge_upstream() + + async def merge_upstream(self) -> bool: + """Merge the upstream branch in, offering to stash if local changes are in the way.""" + st = await self.load_state() + if st.behind == 0: + self.notify("Already up to date.") + return True + n = st.behind + remote = self.remote_name() + r = await self.gitop("merge", "--no-edit", st.upstream) + if r.ok: + self.notify(f"Pulled {plural(n, 'commit')} from {remote}.") + return True + st = await self.load_state() + if st.merging: + await self.conflict_dialog(auto_stashed=False) + return False + if not st.files: + await self.fail("Pull failed", r) + return False + + # git refused because uncommitted changes are in the way + incoming = await asyncio.to_thread(self.git.changed_between, "HEAD", st.upstream) + local = {f.path for f in st.files} + blocking = sorted(local & incoming) or sorted(local) + body = Text("Unable to pull when changes are present on your branch. " + "These files would be overwritten:\n\n") + for p in blocking[:12]: + body.append(f" {p}\n", style="bold") + if len(blocking) > 12: + body.append(f" … and {len(blocking) - 12} more\n", style=C_MUTED) + body.append("\nYou can stash your changes now, pull, and then they'll be put back automatically.") + choice = await self.ask("Your changes are in the way", body, + [("stash", "Stash, pull & re-apply", "primary"), ("close", "Close", "default")]) + if choice != "stash": + return False + if not await self.stash_push("gitdesk: auto-stash before pull"): + return False + r = await self.gitop("merge", "--no-edit", st.upstream) + if not r.ok: + st = await self.load_state() + if st.merging: + await self.conflict_dialog(auto_stashed=True) + return False + await self.fail("Pull failed", r) + await self.stash_pop("stash@{0}") + return False + self.notify(f"Pulled {plural(n, 'commit')} from {remote}.") + await self.stash_pop("stash@{0}") + return True + + async def conflict_dialog(self, auto_stashed: bool) -> None: + st = await self.load_state() + body = Text("Your commits and the commits you pulled changed the same lines in:\n\n") + for f in st.conflicts[:12]: + body.append(f" {f.path}\n", style="bold") + body.append("\nTo fix it, click each file on the left, keep the parts you want and delete the " + "<<<<<<< ======= >>>>>>> marker lines (Open in editor helps). Then click " + "'Mark as resolved' and commit the merge.\n\nOr abort to put everything back the " + "way it was before the pull.") + if auto_stashed: + body.append("\n\nYour own uncommitted changes are safe in Stashed changes.", style=C_MUTED) + choice = await self.ask("Merge conflicts", body, + [("resolve", "Resolve conflicts", "primary"), ("abort", "Abort merge", "error")], + cancel="resolve") + if choice == "abort": + r = await self.gitop("merge", "--abort") + if not r.ok: + await self.fail("Couldn't abort the merge", r) + return + self.notify("Merge aborted - nothing was changed.") + if auto_stashed: + await self.stash_pop("stash@{0}") + elif auto_stashed: + self.notify("After you commit the merge, bring your changes back from Stashed changes.", + timeout=10) + + async def stash_push(self, message: Optional[str], paths: Optional[List[str]] = None) -> bool: + """Stash changes (including new files). Returns True if a stash entry was created.""" + st = await self.load_state() + if st.oid is None: + await self.error_dialog("Can't stash yet", + "git can only stash once the repository has at least one commit. " + "Make a first commit, then stash.") + return False + if st.conflicts: + self.notify("Resolve the conflicts first - git can't stash files that are mid-merge.", + severity="warning") + return False + before = self.git.stash_count() + args = ["stash", "push", "-u"] + (["-m", message] if message else []) + if paths: + args = ["--literal-pathspecs", *args, "--", *paths] + r = await self.gitop(*args) + created = self.git.stash_count() > before + if created and not r.ok: + # The stash was saved but git hit a problem afterwards (often a file that + # another program - Unity, an editor - has open on Windows). + await self.error_dialog( + "Stashed, but git reported a problem", + r.message + "\n\nYour changes ARE saved in Stashed changes. Some files may not have been " + "removed from your folder - close any program using them and check the Changes list.") + return True + if not r.ok: + await self.fail("Couldn't stash your changes", r) + return False + if not created: + self.notify("There was nothing to stash.", severity="warning") + return False + return True + + async def stash_pop(self, ref: str, keep: bool = False) -> bool: + st = await self.load_state() + if st.conflicts or st.merging: + self.notify("Resolve the current conflicts (or abort the merge) first.", severity="warning") + return False + r = await self.gitop("stash", "apply" if keep else "pop", ref) + if r.ok: + self.notify("Your stashed changes are back." if not keep else "Applied - the stash is still saved too.") + return True + if r.handled: + return False + st = await self.load_state() + if st.conflicts: + body = Text("Your stashed changes clash with the current version of:\n\n") + for f in st.conflicts[:12]: + body.append(f" {f.path}\n", style="bold") + body.append("\nThe clashing parts are marked inside those files with <<<<<<< / ======= / >>>>>>>. " + "Edit each file to keep what you want, then click 'Mark as resolved'.\n\n" + "The stash itself was kept, so nothing is lost - drop it from Stashed changes " + "once you're happy.") + await self.ask("Stashed changes conflict", body, [("ok", "OK", "primary")]) + else: + msg = r.message + if "would be overwritten" in msg: + hint = ("Some of the stashed files also have changes in your folder right now. Commit or " + "stash your current changes first, then bring this stash back.") + elif "already exists" in msg: + hint = ("Some new files in the stash already exist in your folder. Move or delete those " + "files, then try again.") + else: + hint = "" + await self.error_dialog("Couldn't bring back your stashed changes", + msg + (f"\n\n{hint}" if hint else "") + "\n\nThe stash was kept, so nothing is lost.") + return False + + async def push_flow(self, auto_pull: bool = False) -> None: + st = self.state + if self.blocked_by_merge(st): + return + if st.branch is None: + self.notify("You're not on a branch, so there's nothing to push.", severity="warning") + return + if not st.remotes: + self.notify("This repository has no remote. Add one with: git remote add origin ", + severity="warning") + return + if st.oid is None: + self.notify("Make a commit first - there's nothing to push yet.", severity="warning") + return + remote = self.remote_name() + if not st.has_upstream: + if await self.net(["push", "-u", remote, st.branch], f"publish to {remote}"): + self.notify(f"Published '{st.branch}' to {remote}.") + return + if not await self.do_fetch(): + return + st = await self.load_state() + if st.behind: + if not auto_pull: + choice = await self.ask( + "Newer commits on the remote", + f"{remote} has {plural(st.behind, 'commit')} on '{st.branch}' that you don't have yet, " + "so a push would be rejected.\n\nPull them first, then push?", + [("pull", "Pull, then push", "primary"), ("close", "Cancel", "default")]) + if choice != "pull": + return + if not await self.merge_upstream(): + return + st = await self.load_state() + if st.ahead == 0: + self.notify("Nothing to push - you're up to date.") + return + target = st.upstream[len(remote) + 1:] + n = st.ahead + if await self.net(["push", remote, f"HEAD:refs/heads/{target}"], f"push to {remote}"): + self.notify(f"Pushed {plural(n, 'commit')} to {remote}.") + + async def commit_flow(self) -> None: + st = await self.load_state() + summary = self.query_one("#summary", Input).value.strip() + desc = self.query_one("#description", TextArea).text.strip() + if st.conflicts: + self.notify("Resolve the conflicts first (click each ! file and Mark as resolved).", + severity="warning") + return + message = summary + (f"\n\n{desc}" if desc else "") + if st.merging: + r = await self.gitop("add", "-A") + if r.ok: + if summary: + r = await self.gitop("commit", "--cleanup=whitespace", "-F", "-", input=message) + else: + r = await self.gitop("commit", "--no-edit") + else: + files = [f for f in st.files if f.path not in self.excluded] + if not files: + self.notify("Tick at least one file to commit.", severity="warning") + return + if not summary: + self.notify("Write a summary for the commit.", severity="warning") + return + if len(files) == len(st.files): + r = await self.gitop("add", "-A") + else: + # Commit only the ticked files: reset the index, then add just those. + if st.oid: + r = await self.gitop("reset", "-q") + else: + r = await self.gitop("rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".") + if r.ok: + now = await self.load_state() + present = {f.path for f in now.files} + specs = [] + for f in files: + specs += [p for p in (f.path, f.orig) if p and p in present] + if not specs: + self.notify("The ticked files have no changes left to commit.", severity="warning") + return + r = await self.gitop("--literal-pathspecs", "add", "-A", "--pathspec-from-file=-", + "--pathspec-file-nul", input="\0".join(specs)) + if r.ok: + r = await self.gitop("commit", "--cleanup=whitespace", "-F", "-", input=message) + if not r.ok: + await self.fail("Commit failed", r) + return + self.query_one("#summary", Input).value = "" + self.query_one("#description", TextArea).text = "" + self.excluded.clear() + self.notify(f"Committed: {summary or 'merge'}") + + async def mark_resolved_flow(self) -> None: + st = self.state + fc = next((f for f in st.files if f.path == self.selected), None) + if fc is None or not fc.is_conflict: + return + if await asyncio.to_thread(file_has_markers, os.path.join(self.git.path, fc.path)): + choice = await self.ask( + "Conflict markers are still in this file", + f"{fc.path} still has <<<<<<< / >>>>>>> lines in it. Mark it as resolved anyway?", + [("yes", "Mark as resolved", "warning"), ("no", "Cancel", "default")]) + if choice != "yes": + return + r = await self.gitop("--literal-pathspecs", "add", "--", fc.path) + if r.ok and not st.merging and st.oid: + # a stash conflict: leave the file as a normal (unstaged) change + r = await self.gitop("--literal-pathspecs", "restore", "--staged", "--", fc.path) + if r.ok: + self.notify(f"Marked {fc.path} as resolved.") + else: + await self.fail("Couldn't mark it as resolved", r) + + async def abort_merge_flow(self) -> None: + choice = await self.ask( + "Abort the merge?", + "This throws away the merge and any conflict fixes you've made, and puts the branch back " + "the way it was before the pull.", + [("abort", "Abort merge", "error"), ("cancel", "Cancel", "default")]) + if choice != "abort": + return + r = await self.gitop("merge", "--abort") + if r.ok: + self.notify("Merge aborted.") + else: + await self.fail("Couldn't abort the merge", r) + + async def stash_all_flow(self) -> None: + if not self.idle_or_warn(): + return + if not self.state.files: + self.notify("There's nothing to stash.", severity="warning") + return + name = await self.push_screen_wait(InputDialog( + "Stash all changes", + "Puts all your uncommitted changes (including new files) aside so you have a clean " + "working folder. Give the stash a name if you like:", + "Stash changes", placeholder="e.g. half-done README edit")) + if name is None: + return + + async def flow(): + if await self.stash_push(name.strip() or None): + self.notify("Changes stashed. Bring them back from Stashed changes.") + self.start_op("Stashing", flow) + + async def discard_flow(self, files: List[FileChange]) -> None: + st = self.state + if not self.idle_or_warn(): + return + if not files: + self.notify("Tick the files you want to discard first.", severity="warning") + return + if st.merging or st.conflicts: + self.notify("Finish resolving the conflicts (or abort the merge) first.", severity="warning") + return + if st.oid is None: + self.notify("Discard needs at least one commit in the repository.", severity="warning") + return + n = len(files) + body = Text("These changes will be removed from your folder:\n\n") + for f in files[:12]: + body.append(f" {f.path}\n", style="bold") + if n > 12: + body.append(f" … and {n - 12} more\n", style=C_MUTED) + body.append("\ngitdesk keeps a backup as a stash named 'gitdesk: discarded …', so you can get " + "them back from Stashed changes if you change your mind.", style=C_MUTED) + choice = await self.ask(f"Discard changes to {plural(n, 'file')}?", body, + [("discard", "Discard changes", "error"), ("cancel", "Cancel", "default")], + cancel="cancel") + if choice != "discard": + return + paths: List[str] = [] + for f in files: + paths.append(f.path) + if f.orig: + paths.append(f.orig) + everything = n == len(st.files) + if not everything and sum(len(p) + 3 for p in paths) > 20000: + self.notify("That's too many files to discard in one go - tick fewer.", severity="warning") + return + label = files[0].path if n == 1 else plural(n, "file") + + async def flow(): + limit = None + if not everything: + # A path-limited stash records the whole index, so unstage first (contents are + # untouched); then only the chosen files end up in the backup stash. This also + # turns staged renames/additions into plain deletions/new files that stash handles. + r = await self.gitop("reset", "-q") + if not r.ok: + await self.fail("Couldn't discard the changes", r) + return + now = await self.load_state() + present = {f.path for f in now.files} + limit = [p for p in paths if p in present] + if not limit: + self.notify("Those files have no changes left to discard.") + return + if await self.stash_push(f"gitdesk: discarded {label}", paths=limit): + self.notify("Discarded. A backup is in Stashed changes.") + self.start_op("Discarding", flow) + + async def stashes_flow(self) -> None: + if not self.idle_or_warn(): + return + res = await self.push_screen_wait(StashDialog(self.git)) + if not res: + return + action, ref = res + if action == "drop": + choice = await self.ask("Drop this stash?", + f"{ref} will be deleted for good. This can't be undone.", + [("drop", "Drop stash", "error"), ("cancel", "Cancel", "default")]) + if choice != "drop": + return + + async def drop(): + r = await self.gitop("stash", "drop", ref) + if r.ok: + self.notify(f"Dropped {ref}.") + else: + await self.fail("Couldn't drop the stash", r) + self.start_op("Dropping stash", drop) + return + + async def restore(): + st = await self.load_state() + if st.conflicts: + self.notify("Resolve the current conflicts first.", severity="warning") + return + await self.stash_pop(ref, keep=(action == "apply")) + self.start_op("Restoring stash", restore) + + async def branches_flow(self) -> None: + if not self.idle_or_warn(): + return + st = self.state + remote = self.remote_name() if st.remotes else None + res = await self.push_screen_wait(BranchDialog(self.git, st.branch, remote)) + if not res: + return + action, name, remote_ref = res + if action == "create": + self.start_op("Creating branch", lambda: self.create_branch(name)) + else: + self.start_op("Switching branch", lambda: self.switch_branch(name, remote_ref)) + + async def create_branch(self, name: str) -> None: + r = await self.bg("check-ref-format", "--branch", name) + if not r.ok: + self.notify(f"'{name}' isn't a valid branch name.", severity="error") + return + r = await self.gitop("switch", "-c", name) + if r.ok: + self.notify(f"Created and switched to '{name}'.") + else: + await self.fail("Couldn't create the branch", r) + + async def switch_branch(self, name: str, remote_ref: Optional[str]) -> None: + st = self.state + if self.blocked_by_merge(st): + return + args = ["switch", "--track", remote_ref] if remote_ref else ["switch", name] + r = await self.gitop(*args) + if r.ok: + self.notify(f"Switched to '{name}'.") + return + if not st.files: + await self.fail("Couldn't switch branch", r) + return + choice = await self.ask( + "Your changes are in the way", + f"Switching to '{name}' would overwrite some of your uncommitted changes.\n\n" + "Stash them first? They'll be saved under Stashed changes so you can bring them back " + "later, on any branch.", + [("stash", "Stash & switch", "primary"), ("close", "Cancel", "default")]) + if choice != "stash" or not await self.stash_push(f"gitdesk: changes from {st.branch}"): + return + r = await self.gitop(*args) + if r.ok: + self.notify(f"Switched to '{name}'. Your changes are saved in Stashed changes.", timeout=8) + else: + await self.fail("Couldn't switch branch", r) + + async def open_repo_flow(self, first: bool = False) -> None: + if self.busy: + self.notify("Still busy - try again in a moment.", severity="warning") + return + current = self.git.path if self.git else os.path.abspath(self.start_path or os.getcwd()) + error = f"{current} isn't a git repository." if first and self.start_path and self.git is None else "" + while True: + choice = await self.push_screen_wait(RepoDialog(current, first and self.git is None, error)) + if choice == "add_local": + if await self.add_local_flow(): + return + continue + if choice == "new": + if await self.new_repo_flow(): + return + continue + if choice == "clone": + if await self.clone_flow(): + return + continue + if not choice: + if first and self.git is None: + self.exit() + return + if await self.load_repo(choice): + self.run_worker(self.auto_fetch(), exit_on_error=False) + return + current, error = choice, f"{choice} isn't a git repository." + + # ---- new / clone / remote --------------------------------------------- + async def choose_folder(self, title: str, start: str) -> Optional[str]: + """System folder picker (Explorer on Windows); the in-terminal browser if there isn't one.""" + start = existing_folder(start or "") + status, path = await asyncio.to_thread(pick_folder_native, title, start) + if status == "ok": + return path + if status == "cancel": + return None + return await self.push_screen_wait(FolderBrowser(title, start)) + + def action_repositories(self) -> None: + self.run_worker(self.open_repo_flow(), exit_on_error=False) + + async def add_local_flow(self) -> bool: + if self.busy: + self.notify("Still busy - try again in a moment.", severity="warning") + return False + res = await self.push_screen_wait(AddLocalDialog()) + if not res: + return False + action, path = res + if action == "create_here": + return await self.new_repo_flow(parent=os.path.dirname(path), name=os.path.basename(path)) + if await self.load_repo(path): + self.run_worker(self.auto_fetch(), exit_on_error=False) + self.notify(f"Added {os.path.basename(self.git.path)}.") + return True + await self.error_dialog("Couldn't open the repository", path) + return False + + def action_new_repo(self) -> None: + self.run_worker(self.new_repo_flow(), exit_on_error=False) + + def action_clone_repo(self) -> None: + self.run_worker(self.clone_flow(), exit_on_error=False) + + def action_add_remote(self) -> None: + self.run_worker(self.add_remote_flow(), exit_on_error=False) + + async def new_repo_flow(self, parent: Optional[str] = None, name: str = "") -> bool: + if self.busy: + self.notify("Still busy - try again in a moment.", severity="warning") + return False + o = await self.push_screen_wait(NewRepoDialog(parent or default_parent_folder(), name)) + if not o: + return False + remember_parent_folder(o["parent"]) + path = o["path"] + if os.path.isdir(os.path.join(path, ".git")): + choice = await self.ask("That's already a repository", f"{path} is already a git repository. Open it?", + [("open", "Open it", "primary"), ("cancel", "Cancel", "default")]) + return choice == "open" and await self.load_repo(path) + self.set_busy(True, "Creating repository") + try: + created, problem = await asyncio.to_thread(create_repository, o) + finally: + self.set_busy(False) + if not created: + await self.error_dialog("Couldn't create the repository", problem) + return False + if not await self.load_repo(path): + await self.error_dialog("Couldn't open the new repository", path) + return False + if problem: + hint = hint_for(problem) + await self.error_dialog("The repository was created, but there was a problem", + problem + (f"\n\n{hint}" if hint else "")) + else: + self.notify(f"Created {o['name']}.") + return True + + def _progress_from_thread(self, line: str) -> None: + self.call_from_thread(self._show_progress, line) + + def _show_progress(self, line: str) -> None: + m = re.search(r"([A-Za-z][A-Za-z ]+?):\s+(\d+)%", line) + self.busy_detail = f"{m.group(1)} {m.group(2)}%" if m else line[:40] + self.update_remote_button() + + async def clone_flow(self) -> bool: + if self.busy: + self.notify("Still busy - try again in a moment.", severity="warning") + return False + res = await self.push_screen_wait(CloneDialog(default_parent_folder())) + if not res: + return False + url, path = res + path = os.path.abspath(path) + parent = os.path.dirname(path) + try: + os.makedirs(parent, exist_ok=True) + except OSError as e: + await self.error_dialog("Couldn't create the folder", str(e)) + return False + remember_parent_folder(parent) + args = ["clone", "--progress", url, path] + while True: + self.busy_detail = "Connecting…" + self.set_busy(True, "Cloning") + try: + r = await asyncio.to_thread(run_git_streaming, parent, args, self._progress_from_thread) + finally: + self.busy_detail = "" + self.set_busy(False) + self._log_cmd(args, r) + if r.ok: + break + tail = "\n".join(r.message.splitlines()[-10:]) + body = Text.assemble((tail, "#f97583"), "\n\n", + ("If the server needs a password or token, an SSH passphrase, or wants you to " + "confirm its key, 'Retry in terminal' lets git ask you.", C_MUTED)) + choice = await self.ask("Couldn't clone the repository", body, + [("retry", "Try again", "default"), ("terminal", "Retry in terminal", "primary"), + ("close", "Close", "default")], cancel="close") + if choice == "terminal": + if self.run_in_terminal(["clone", url, path], cwd=parent): + break + return False + if choice != "retry": + return False + if not await self.load_repo(path): + await self.error_dialog("Cloned, but couldn't open it", path) + return False + self.last_fetch = time.time() + self.update_remote_button() + self.notify(f"Cloned {os.path.basename(path)}.") + return True + + async def add_remote_flow(self) -> None: + if not self.idle_or_warn(): + return + st = self.state + name = self.remote_name() if st.remotes else "origin" + current = st.remote_urls.get(name, "") + url = await self.push_screen_wait(InputDialog( + "Remote URL", + f"Paste the address of this repository on GitHub, Gitea, etc. It's saved as the '{name}' remote.\n" + "If it doesn't exist there yet, create an empty repository on the website first " + "(without a README), then paste its URL here.", + "Save", placeholder="e.g. git@github.com:you/project.git", value=current)) + if url is None or not url.strip() or url.strip() == current: + return + url = url.strip() + + async def flow(): + args = ["remote", "set-url", name, url] if name in st.remotes else ["remote", "add", name, url] + r = await self.gitop(*args) + if r.ok: + self._web_cache_key = None + self.notify(f"Saved the {name} remote." + ("" if st.has_upstream else + " Click Publish branch to push your commits.")) + else: + await self.fail("Couldn't save the remote", r) + self.start_op("Saving remote", flow) + + async def actions_flow(self) -> None: + action = await self.push_screen_wait(ActionsDialog()) + if action: + await self.run_action(action) + + +def main() -> None: + if shutil.which("git") is None: + print("gitdesk: git isn't installed or isn't in your PATH.") + sys.exit(1) + ver = git_version() + if ver < (2, 25): + print(f"gitdesk needs git 2.25 or newer (found {ver[0]}.{ver[1]}). Please update git.") + sys.exit(1) + start = sys.argv[1] if len(sys.argv) > 1 else None + if start in ("-h", "--help"): + print(__doc__) + return + GitDesk(start).run() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3ecd026 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +textual>=8.2