Compare commits

..
9 Commits
Author SHA1 Message Date
Christoph 32832f5db7 feat(git): support credentials for remote branch rename
Allow remote branch renames to be performed with optional credentials so
operations against protected remotes succeed when authentication is needed.
The backend accepts username/password and uses an authenticated push path
when provided, while the frontend prompts for and reuses stored credentials.

- Add optional username/password to rename RPC and use authenticated push
- Wire UI to queue rename, open credential dialog, and execute rename
- Extend credential dialog and handling to include the rename action
2026-08-15 17:23:40 +02:00
Christoph 37d2152fcd feat(branch-panel): improve context menu positioning and styling
This update enhances the context menu for branches and tags by
ensuring it fits within the viewport, preventing overflow and
improving usability. Additionally, the styling of the branch
context menu has been adjusted for better visibility and
interaction.

- Added dynamic positioning for context menus to avoid overflow
- Updated CSS for branch context menu to improve layout and usability
- Simplified context menu opening logic for better performance
2026-08-15 17:15:13 +02:00
Christoph 2bc74dc4a7 feat(HelpOverlay): add Git Notes feature documentation
This update introduces documentation for the Git Notes feature, which
allows users to attach additional context to commits without altering
the commit history. The new section includes detailed steps, commands,
and notes on how to effectively use Git Notes within the application.

- Added German and English documentation for Git Notes
- Included commands for viewing and managing commit notes
- Explained how to fetch and push Git Notes to remote repositories
2026-08-15 17:08:53 +02:00
Christoph 4db488415f Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-15 17:00:44 +02:00
Christoph be695d78a9 feat(publishing): add support for prebuilt AppImage package
This update enhances the publishing workflow by introducing a new
prebuilt AppImage package for Gitty, allowing users to install
the application without needing to compile from source. The
workflow now includes steps to generate and publish the binary
AUR package alongside the standard source package.

- Introduced PKGBUILD-bin for the prebuilt AppImage
- Updated workflow to handle both source and binary package publishing
- Enhanced README to guide users on installing the new package
2026-08-15 17:00:42 +02:00
Christoph Brandau 40311275b0 style(app): remove border radius on commit-body
Removes the rounded corners on the commit-body by setting border-radius to 0.
This makes the component use a flat edge and aligns with the design system.

- Remove dependency on ui radius variable for commit-body
2026-08-13 23:07:19 +02:00
Christoph Brandau fe577d78a8 feat(app): add background commit notes fetch and refresh logic
This change adds a background path for fetching commit notes per repo
and a shared cache to avoid duplicate work. When notes are fetched
and the active repo is visible, history is refreshed to reflect
notes without blocking user actions.

- Adds per-path backgroundCommitNotesFetches cache to debounce fetches
- Integrates note fetch into the background tick and refresh flow
- Handles credential errors and shutdown gracefully during fetches
2026-08-13 22:56:53 +02:00
Christoph Brandau a27a8666ee feat(git): add commit-note support and previews in history
This change adds Git notes support to the history UI.
Commits now carry a has_note flag which triggers a note indicator.
Notes can be previewed on hover and loaded on demand,
then the history can be refreshed after edits.

- Adds has_note support on commits and parses from logs.
- Renders a note indicator in history rows with a hover preview.
- Triggers history refresh after note-related actions.
2026-08-13 22:51:24 +02:00
Christoph Brandau c442b3735f feat(auth): add credential mode (credentials/token) support
Introduces a credential mode for stored credentials and wire it to
per-remote URL resolution and operation flows. The UI, storage, and
remote interactions now track and persist the mode, enabling token
based auth alongside username/password credentials.

- Remote URL resolution now considers direction (pull/push) and mode
- Credential dialog, saving, and keychain handling updated to pass and
  respect the mode
- Unique askpass scripts generated per invocation to avoid clashes
2026-08-13 22:27:00 +02:00
12 changed files with 1001 additions and 197 deletions
+93 -47
View File
@@ -121,11 +121,13 @@ jobs:
uv run main.py
publish-arch:
name: Build and publish gitty-desktop to AUR
name: Build and publish AUR packages
needs: publish-ubuntu
runs-on: archlinux
environment: production
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
defaults:
run:
@@ -236,6 +238,43 @@ jobs:
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
- name: Generate and build binary AUR package
run: |
set -euo pipefail
LATEST_JSON="$(curl --fail --location --silent --show-error \
"$ARTIFACT_BASE_URL/gitty/latest.json")"
export LATEST_JSON
APPIMAGE_URL="$(node -e "const p=JSON.parse(process.env.LATEST_JSON); const a=p.platforms?.['linux-x86_64']?.url; if (!a) process.exit(1); process.stdout.write(a)")"
APPIMAGE_NAME="${APPIMAGE_URL##*/}"
AUR_BIN_DIR="$(mktemp -d)"
cp PKGBUILD-bin "$AUR_BIN_DIR/PKGBUILD"
APPIMAGE_PATH="$AUR_BIN_DIR/$APPIMAGE_NAME"
curl --fail --location --silent --show-error \
--output "$APPIMAGE_PATH" "$APPIMAGE_URL"
APPIMAGE_CHECKSUM="$(sha256sum "$APPIMAGE_PATH" | cut -d ' ' -f 1)"
ICON_PATH="$AUR_BIN_DIR/gitty-desktop.png"
curl --fail --location --silent --show-error \
--output "$ICON_PATH" \
"https://git.cbsk-tech.de/Christoph/GitLite/raw/tag/$RELEASE_TAG/src-tauri/icons/icon.png"
ICON_CHECKSUM="$(sha256sum "$ICON_PATH" | cut -d ' ' -f 1)"
sed -i \
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
-e "s/^pkgrel=.*/pkgrel=1/" \
-e "s|^_appimage=.*|_appimage=\"$APPIMAGE_NAME\"|" \
-e "s|^_artifact_url=.*|_artifact_url=\"$APPIMAGE_URL\"|" \
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
-e "/^sha256sums=/,/^[[:space:]]*'SKIP')$/c\\sha256sums=('$APPIMAGE_CHECKSUM'\\n '$ICON_CHECKSUM')" \
"$AUR_BIN_DIR/PKGBUILD"
chown -R builder:builder "$AUR_BIN_DIR"
runuser -u builder -- \
bash -lc "cd '$AUR_BIN_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
echo "AUR_BIN_DIR=$AUR_BIN_DIR" >> "$GITHUB_ENV"
- name: Publish PKGBUILD to AUR
env:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
@@ -273,55 +312,62 @@ jobs:
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
AUR_CHECKOUT_ROOT="$(mktemp -d)"
AUR_CHECKOUT=""
for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ -z "$AUR_CHECKOUT" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2
exit 1
fi
publish_aur_package() {
local package_name="$1"
local package_source_dir="$2"
local checkout_root checkout clone_candidate pushed
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
cd "$AUR_CHECKOUT"
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
fi
git commit -m "Update to $PACKAGE_VERSION"
AUR_PUSHED=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then
AUR_PUSHED=1
break
checkout_root="$(mktemp -d)"
checkout=""
for AUR_ATTEMPT in 1 2 3 4 5; do
clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
echo "$package_name clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \
"ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
checkout="$clone_candidate"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ -z "$checkout" ]; then
echo "Could not clone $package_name after 5 attempts" >&2
return 1
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR push failed; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
cp "$package_source_dir/PKGBUILD" "$package_source_dir/.SRCINFO" "$checkout/"
git -C "$checkout" config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git -C "$checkout" config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git -C "$checkout" add PKGBUILD .SRCINFO
if git -C "$checkout" diff --cached --quiet; then
echo "$package_name already matches release $PACKAGE_VERSION"
return 0
fi
done
if [ "$AUR_PUSHED" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2
exit 1
fi
git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
pushed=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "$package_name push attempt $AUR_ATTEMPT of 5"
if timeout 3m git -C "$checkout" push origin HEAD:master; then
pushed=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR push failed; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$pushed" -ne 1 ]; then
echo "Could not publish $package_name after 5 attempts" >&2
return 1
fi
}
publish_aur_package gitty-desktop "$AUR_SOURCE_DIR"
publish_aur_package gitty-desktop-bin "$AUR_BIN_DIR"
publish-ubuntu:
name: Build and publish Ubuntu AppImage
+45
View File
@@ -0,0 +1,45 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin
pkgver=2026.8.3
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt AppImage)"
arch=('x86_64')
url="https://git.cbsk-tech.de/Christoph/GitLite"
license=('MIT')
depends=('fuse2' 'git' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
provides=('gitty-desktop')
conflicts=('gitty-desktop')
options=('!strip')
_appimage="Gitty_${pkgver}_amd64.AppImage"
_artifact_url="https://cdn.cbsk-tech.de/gitty/${pkgver}/${_appimage}"
_tag=2026.8.3
source=("${_appimage}::${_artifact_url}"
"gitty-desktop.png::${url}/raw/tag/${_tag}/src-tauri/icons/icon.png")
sha256sums=('SKIP'
'SKIP')
package() {
install -Dm755 "$srcdir/$_appimage" \
"$pkgdir/opt/$pkgname/gitty-desktop.AppImage"
install -d "$pkgdir/usr/bin"
ln -s "/opt/$pkgname/gitty-desktop.AppImage" \
"$pkgdir/usr/bin/gitty-desktop"
install -Dm644 "$srcdir/gitty-desktop.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
[Desktop Entry]
Type=Application
Name=Gitty
Comment=$pkgdesc
Exec=gitty-desktop
Icon=gitty-desktop
Terminal=false
Categories=Development;RevisionControl;
StartupWMClass=gitty
EOF
}
+9 -2
View File
@@ -60,6 +60,12 @@ Install Gitty from the AUR with an AUR helper:
yay -S gitty-desktop
```
To install the prebuilt AppImage instead of compiling from source:
```bash
yay -S gitty-desktop-bin
```
Or build the AUR package manually:
```bash
@@ -68,8 +74,9 @@ cd gitty-desktop
makepkg -si
```
The AUR recipe downloads the public Gitea release archive and builds Gitty from
source. The release pipeline updates its version, checksum, and `.SRCINFO`.
The source recipe downloads the public Gitea release archive and builds Gitty.
The `-bin` recipe installs the prebuilt AppImage. The release pipeline updates
both packages' versions, checksums, and `.SRCINFO` files.
---
+217 -25
View File
@@ -119,6 +119,7 @@ pub struct GitCommit {
pub refs: Vec<String>,
pub parents: Vec<String>,
pub files: Vec<GitCommitFile>,
pub has_note: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -682,9 +683,17 @@ pub async fn rename_remote_branch(
remote: String,
old_branch: String,
new_branch: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch)
rename_remote_branch_core(
path,
remote,
old_branch,
new_branch,
username.as_deref().zip(password.as_deref()),
)
})
.await
}
@@ -694,6 +703,7 @@ fn rename_remote_branch_core(
remote: String,
old_branch: String,
new_branch: String,
credentials: Option<(&str, &str)>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
@@ -729,18 +739,23 @@ fn rename_remote_branch_core(
// Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched.
run_git(
&repo,
[
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
],
)?;
let push_args = [
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
];
match credentials {
Some((username, password)) if !username.is_empty() || !password.is_empty() => {
run_git_authenticated(&repo, push_args, username, password)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
// Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups.
@@ -2432,6 +2447,8 @@ const CRED_SERVICE: &str = "tauri_git_lite";
pub struct StoredCredential {
pub username: String,
pub password: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
@@ -2445,17 +2462,27 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
/// Returns the remote URL used for auth key derivation (upstream remote of the
/// current branch, falling back to `origin`, then the first configured remote).
#[tauri::command(async)]
pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
pub fn get_remote_url(
path: String,
remote: Option<String>,
push: Option<bool>,
) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
let remote = upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string());
let remote = match remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(remote) => validate_remote_name(&repo, &remote, true)?,
None => upstream_remote_name(&repo).unwrap_or_else(|| "origin".to_string()),
};
if let Some(url) = remote_url_for(&repo, &remote) {
if let Some(url) = remote_url_for_auth(&repo, &remote, push.unwrap_or(false)) {
return Ok(Some(url));
}
// origin missing → try the first configured remote
if let Some(first) = first_remote_name(&repo) {
if first != remote {
if let Some(url) = remote_url_for(&repo, &first) {
if let Some(url) = remote_url_for_auth(&repo, &first, push.unwrap_or(false)) {
return Ok(Some(url));
}
}
@@ -2463,6 +2490,20 @@ pub fn get_remote_url(path: String) -> Result<Option<String>, String> {
Ok(None)
}
fn remote_url_for_auth(repo: &Path, remote: &str, push: bool) -> Option<String> {
let mut command = git_command();
command.arg("-C").arg(repo).args(["remote", "get-url"]);
if push {
command.arg("--push");
}
let out = command.arg(remote).output().ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = git_command()
.arg("-C")
@@ -2614,9 +2655,22 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
}
#[tauri::command(async)]
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> {
pub fn cred_save(
key: String,
username: String,
password: String,
mode: Option<String>,
) -> Result<(), String> {
let entry = cred_entry(&key)?;
let cred = StoredCredential { username, password };
let mode = match mode.as_deref() {
Some("token") => Some("token".to_string()),
_ => Some("credentials".to_string()),
};
let cred = StoredCredential {
username,
password,
mode,
};
let json = serde_json::to_string(&cred)
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
entry
@@ -3464,7 +3518,10 @@ fn commit_page_for_repo(
repo,
[
"log",
"--all",
"--branches",
"--remotes",
"--tags",
"HEAD",
"--topo-order",
"--decorate=short",
"--name-status",
@@ -3479,7 +3536,28 @@ fn commit_page_for_repo(
],
)?;
parse_commit_log_inline(&output)
let mut commits = parse_commit_log_inline(&output)?;
mark_commits_with_notes(repo, &mut commits)?;
Ok(commits)
}
fn mark_commits_with_notes(repo: &Path, commits: &mut [GitCommit]) -> Result<(), String> {
if commits.is_empty() || !ref_exists(repo, COMMIT_NOTES_REF)? {
return Ok(());
}
let output = run_git(repo, ["notes", "--ref", COMMIT_NOTES_REF, "list"])?;
let noted_commits: BTreeSet<String> = String::from_utf8_lossy(&output)
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(ToString::to_string)
.collect();
for commit in commits {
commit.has_note = noted_commits.contains(&commit.hash);
}
Ok(())
}
#[tauri::command]
@@ -3665,7 +3743,9 @@ fn list_file_history_core(
let output = run_git_cancellable(repo, args, cancellation, "Git file history failed")?;
check_search_cancelled(cancellation)?;
parse_commit_log(repo, &output)
let mut commits = parse_commit_log(repo, &output)?;
mark_commits_with_notes(repo, &mut commits)?;
Ok(commits)
}
#[tauri::command]
@@ -4688,6 +4768,13 @@ fn run_git_clone(
password: Option<&str>,
) -> Result<(), String> {
let mut command = git_command();
let has_explicit_credentials = matches!(
(username, password),
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty()
);
if has_explicit_credentials {
command.arg("-c").arg("credential.helper=");
}
command
.arg("clone")
.arg("--")
@@ -5093,6 +5180,7 @@ fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
parents,
summary: String::from_utf8_lossy(parts[7]).to_string(),
files: parse_commit_files(files_bytes)?,
has_note: false,
});
}
@@ -5141,6 +5229,7 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
parents,
summary: fields[7].to_string(),
files,
has_note: false,
});
}
@@ -5187,6 +5276,7 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
parents,
summary: fields[7].to_string(),
files: Vec::new(),
has_note: false,
});
}
@@ -5649,10 +5739,20 @@ fn run_apply_patch_command(
run_git(repo, args).map(|_| ())
}
static ASKPASS_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_askpass_path(extension: &str) -> std::path::PathBuf {
let sequence = ASKPASS_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"gitty-askpass-{}-{sequence}.{extension}",
std::process::id()
))
}
#[cfg(unix)]
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join("gitlite_askpass.sh");
let path = next_askpass_path("sh");
let script = "#!/bin/sh\ncase \"$1\" in\n *[Uu]sername*) printf '%s\\n' \"$GIT_CRED_USER\" ;;\n *) printf '%s\\n' \"$GIT_CRED_PASS\" ;;\nesac\n";
std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?;
@@ -5663,8 +5763,17 @@ fn write_askpass_script() -> Result<std::path::PathBuf, String> {
#[cfg(not(unix))]
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
let path = std::env::temp_dir().join("gitlite_askpass.bat");
let script = "@echo off\necho %1 | findstr /I \"sername\" >nul 2>&1\nif %errorlevel% == 0 (echo %GIT_CRED_USER%) else (echo %GIT_CRED_PASS%)\n";
let path = next_askpass_path("bat");
// Reading the value from PowerShell avoids cmd.exe interpreting special
// characters such as &, |, ^ or % from passwords and access tokens.
let script = r#"@echo off
echo %1 | findstr /I "sername" >nul 2>&1
if %errorlevel% == 0 (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_USER)"
) else (
powershell.exe -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine($env:GIT_CRED_PASS)"
)
"#;
std::fs::write(&path, script)
.map_err(|e| format!("Could not write authentication script: {e}"))?;
Ok(path)
@@ -5706,6 +5815,8 @@ where
let askpass = write_askpass_script()?;
let result = git_command()
.arg("-c")
.arg("credential.helper=")
.arg("-C")
.arg(repo)
.args(args)
@@ -6302,6 +6413,19 @@ mod tests {
"Review: sieht gut aus\nBuild: 42",
)
.expect("note should be created");
let commits = commits_for_repo(&repo.path, Some(20)).expect("history should load");
assert!(
commits
.iter()
.find(|commit| commit.hash == commit_before)
.expect("annotated commit should be in history")
.has_note
);
assert!(
commits
.iter()
.all(|commit| commit.summary != "Notes added by 'git notes add'")
);
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
Some("Review: sieht gut aus\nBuild: 42".to_string())
@@ -6315,6 +6439,14 @@ mod tests {
);
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted");
let commits = commits_for_repo(&repo.path, Some(20)).expect("history should reload");
assert!(
!commits
.iter()
.find(|commit| commit.hash == commit_before)
.expect("commit should remain in history")
.has_note
);
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before)
.expect("deleted note lookup should work"),
@@ -6715,6 +6847,7 @@ mod tests {
],
summary: "Add history panel".to_string(),
files: Vec::new(),
has_note: false,
}]
);
}
@@ -7146,6 +7279,64 @@ mod tests {
);
}
#[test]
fn credential_payload_remains_backward_compatible() {
let legacy: StoredCredential =
serde_json::from_str(r#"{"username":"alice","password":"secret"}"#)
.expect("legacy credential should deserialize");
assert_eq!(legacy.username, "alice");
assert_eq!(legacy.password, "secret");
assert_eq!(legacy.mode, None);
let token = StoredCredential {
username: "alice".to_string(),
password: "token".to_string(),
mode: Some("token".to_string()),
};
let encoded = serde_json::to_string(&token).expect("credential should serialize");
assert!(encoded.contains(r#""mode":"token""#));
}
#[test]
fn auth_remote_url_uses_the_requested_direction() {
let repo = init_temp_repo("auth_remote_url_direction");
run_git_test(
&repo.path,
[
"remote",
"add",
"origin",
"https://gitea.example/fetch/repo.git",
],
);
run_git_test(
&repo.path,
[
"remote",
"set-url",
"--push",
"origin",
"https://gitea.example/push/repo.git",
],
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", false).as_deref(),
Some("https://gitea.example/fetch/repo.git")
);
assert_eq!(
remote_url_for_auth(&repo.path, "origin", true).as_deref(),
Some("https://gitea.example/push/repo.git")
);
}
#[test]
fn askpass_scripts_use_unique_paths() {
let first = next_askpass_path("test");
let second = next_askpass_path("test");
assert_ne!(first, second);
}
#[tokio::test]
#[cfg_attr(
windows,
@@ -7485,6 +7676,7 @@ mod tests {
"origin".to_string(),
"feature/old-name".to_string(),
"feature/new-name".to_string(),
None,
)
.unwrap();
+260 -58
View File
@@ -111,7 +111,6 @@
launchExternalTool,
credLoad,
credSave,
credDelete,
getFilePatch,
readConflict,
resolveConflict,
@@ -189,7 +188,8 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] }
@@ -410,8 +410,12 @@
let autoRefreshInFlight = false;
let credDialogOpen = false;
let credDialogAction: CredentialAction | null = null;
let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let credDialogUsername = "";
let credDialogMode: CredentialMode = "credentials";
const rejectedCredentialKeys = new Set<string>();
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
@@ -424,6 +428,7 @@
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false;
const backgroundCommitNotesFetches = new Map<string, Promise<boolean>>();
let backgroundRepoStatusInFlight = false;
let backgroundRepoStatusIndex = 0;
let appShuttingDown = false;
@@ -669,6 +674,15 @@
// Keep the cached tab data if this repo is unavailable at startup.
}
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (notesFetched && sameRepoPath(path, activeRepoPath)) {
try {
await refreshCommitHistory(path);
} catch {
// The active repository may still be opening; its own background pass retries.
}
}
}
} finally {
backgroundFetchInFlight = false;
@@ -714,18 +728,91 @@
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup.
function preferredNotesRemote(remotes: GitRemote[]): GitRemote | undefined {
return remotes.find((remote) => remote.name === selectedRemote)
?? remotes.find((remote) => remote.name === "origin")
?? remotes[0];
}
async function backgroundFetchCommitNotesCore(path: string): Promise<boolean> {
if (appShuttingDown || !autoRefreshEnabled || !path) return false;
if (commitNoteTarget && sameRepoPath(path, commitNoteRepoPath)) return false;
let credentialKey: string | null = null;
try {
const remote = preferredNotesRemote(await listRemotes(path));
if (!remote) return false;
credentialKey = orgKeyFromUrl(remote.fetch_url);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) return false;
const credential = await loadStoredCredential(credentialKey);
if (/^https?:\/\//i.test(remote.fetch_url) && !credential) return false;
await fetchCommitNotes(path, remote.name, credential?.username, credential?.password);
if (credentialKey) rejectedCredentialKeys.delete(credentialKey);
trackEvent("commit_notes_background_fetched");
return true;
} catch (error) {
if (credentialKey && isAuthError(errorToMessage(error))) {
rejectedCredentialKeys.add(credentialKey);
}
if (import.meta.env.DEV) console.info("[Gitty notes] Background fetch skipped", errorToMessage(error));
return false;
}
}
function backgroundFetchCommitNotes(path: string): Promise<boolean> {
const key = repoKey(path);
const current = backgroundCommitNotesFetches.get(key);
if (current) return current;
const request = backgroundFetchCommitNotesCore(path).finally(() => {
backgroundCommitNotesFetches.delete(key);
});
backgroundCommitNotesFetches.set(key, request);
return request;
}
async function waitForBackgroundCommitNotes(path: string) {
await backgroundCommitNotesFetches.get(repoKey(path));
}
async function backgroundFetchCommitNotesAndRefresh(path: string) {
if (!await backgroundFetchCommitNotes(path)) return;
if (!sameRepoPath(path, activeRepoPath)) return;
try {
await refreshCommitHistory(path);
} catch {
// Repository changes can invalidate this best-effort background refresh.
}
}
async function backgroundFetchTick() {
if (appShuttingDown || !autoRefreshEnabled) return;
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
const path = activeRepoPath;
backgroundFetchInFlight = true;
try {
await fetchRemote(activeRepoPath);
applyStatus(await getStatus(activeRepoPath));
await refreshRefsAndCommitGraph(activeRepoPath);
let refsFetched = false;
try {
await fetchRemote(path);
refsFetched = true;
} catch {
// Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (sameRepoPath(path, activeRepoPath)) {
if (refsFetched) {
applyStatus(await getStatus(path));
await refreshRefsAndCommitGraph(path);
} else if (notesFetched) {
await refreshCommitHistory(path);
}
}
} catch {
// ignore — see comment above
// ignore transient refresh failures
} finally {
backgroundFetchInFlight = false;
}
@@ -739,14 +826,26 @@
backgroundFetchInFlight = true;
try {
await fetchRemote(path);
const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus);
let refsFetched = false;
try {
await fetchRemote(path);
refsFetched = true;
} catch {
// Manual Fetch/Pull surfaces remote errors; background work stays silent.
}
const notesFetched = await backgroundFetchCommitNotes(path);
if (refsFetched) {
const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) {
applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus);
} else if (notesFetched && sameRepoPath(path, activeRepoPath)) {
await refreshCommitHistory(path);
}
} catch {
// ignore; manual Fetch/Pull surfaces auth or network problems
// ignore transient refresh failures
} finally {
backgroundFetchInFlight = false;
}
@@ -2177,7 +2276,8 @@
changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0,
});
void backgroundFetchRepo(activeRepoPath);
if (backgroundFetchInFlight) void backgroundFetchCommitNotesAndRefresh(activeRepoPath);
else void backgroundFetchRepo(activeRepoPath);
});
}
@@ -2207,6 +2307,7 @@
password?: string,
key?: string | null,
fromStore = false,
credentialMode: CredentialMode = "credentials",
) {
if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
@@ -2219,7 +2320,16 @@
if (!username && !password) {
const stored = await loadStoredCredential(credentialKey);
if (stored) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
const storedMode = credentialModeFor(stored);
if (credentialKey && rejectedCredentialKeys.has(credentialKey)) {
credDialogUsername = stored.username === "oauth2" ? "" : stored.username;
credDialogMode = storedMode;
credDialogAction = "clone";
credDialogKey = credentialKey;
credDialogOpen = true;
return;
}
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
return;
}
}
@@ -2260,7 +2370,9 @@
errorMessage = "";
setCloneDialogError("");
if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {});
if (credentialKey) rejectedCredentialKeys.add(credentialKey);
credDialogUsername = username === "oauth2" ? "" : (username ?? "");
credDialogMode = credentialMode;
const detail = summarizeGitError(message);
credDialogError = detail
? `${detail} — please sign in again.`
@@ -2511,12 +2623,14 @@
const oldRemoteBranch = branch.name.slice(slash + 1);
if (name === oldRemoteBranch) return;
await runOperation(`Renaming ${branch.name} on remote`, async () => {
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name));
renameBranchTarget = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name };
const key = await currentCredKey("rename");
const stored = await loadStoredCredential(key);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored));
} else {
await openCredentialDialog("rename", key, stored);
}
return;
}
@@ -2917,7 +3031,7 @@
async function pushLocalTag(tag: GitTag) {
if (!activeRepoPath || isBusy) return;
const key = await currentCredKey();
const key = await currentCredKey("push");
const stored = await loadStoredCredential(key);
const credential = stored ?? null;
@@ -2983,6 +3097,11 @@
commitNoteLoading = false;
}
async function loadCommitNotePreview(commit: GitCommit): Promise<string | null> {
if (!activeRepoPath) return null;
return getCommitNote(activeRepoPath, commit.hash);
}
async function saveActiveCommitNote(note: string) {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
@@ -2991,8 +3110,10 @@
commitNoteError = "";
commitNoteStatus = "";
try {
await waitForBackgroundCommitNotes(repo);
await setCommitNote(repo, commit.hash, note);
commitNoteText = note;
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item);
commitNoteStatus = appLanguage === "de"
? "Notiz gespeichert. Der Commit-Hash ist unverändert."
: "Note saved. The commit hash is unchanged.";
@@ -3012,8 +3133,10 @@
commitNoteError = "";
commitNoteStatus = "";
try {
await waitForBackgroundCommitNotes(repo);
await deleteCommitNote(repo, commit.hash);
commitNoteText = "";
commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item);
commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
trackEvent("commit_note_deleted");
} catch (error) {
@@ -3046,9 +3169,11 @@
commitNoteError = "";
commitNoteStatus = "";
try {
await waitForBackgroundCommitNotes(repo);
const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
await refreshCommitHistory(repo);
commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
commitNoteStatus = appLanguage === "de"
? `Notizen von ${remote} geladen und zusammengeführt.`
@@ -3102,11 +3227,18 @@
});
}
// Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> {
function credentialModeFor(credential: StoredCredential): CredentialMode {
if (credential.mode === "token" || credential.username === "oauth2") return "token";
return "credentials";
}
// Resolve the keychain key (host/org) from the exact remote URL used by the
// operation. Push URLs may intentionally differ from fetch URLs.
async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" = "fetch"): Promise<string | null> {
if (!activeRepoPath) return null;
try {
const url = await getRemoteUrl(activeRepoPath);
const remote = action === "rename" ? pendingRemoteRename?.remote : selectedRemote;
const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename");
return url ? orgKeyFromUrl(url) : null;
} catch {
return null;
@@ -3122,21 +3254,37 @@
}
}
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
async function openCredentialDialog(
action: CredentialAction,
key?: string | null,
credential?: StoredCredential | null,
) {
if (!activeRepoPath && action !== "clone") return;
credDialogError = "";
credDialogAction = action;
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
credDialogKey = key === undefined && action !== "clone"
? await currentCredKey(action)
: (key ?? null);
credDialogUsername = credential?.username === "oauth2" ? "" : (credential?.username ?? "");
credDialogMode = credential ? credentialModeFor(credential) : "credentials";
credDialogOpen = true;
trackEvent("credential_dialog_opened", {
action,
});
}
// Post-process a pull/push result: surface errors, and on rejected/expired
// credentials drop the stored entry and re-open the login dialog.
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
// Post-process a pull/push result. Rejected credentials stay in the keychain
// so a temporary 401/403 cannot erase a valid token; the key is only skipped
// for the rest of this session until the user replaces it successfully.
function handleRemoteResult(
action: "push" | "pull" | "fetch" | "rename",
key: string | null,
fromStore: boolean,
username: string,
mode: CredentialMode,
) {
if (!errorMessage) {
if (key) rejectedCredentialKeys.delete(key);
credDialogOpen = false;
credDialogAction = null;
return;
@@ -3147,7 +3295,9 @@
if (fromStore) {
if (auth) {
if (key) void credDelete(key).catch(() => {});
if (key) rejectedCredentialKeys.add(key);
credDialogUsername = username === "oauth2" ? "" : username;
credDialogMode = mode;
const detail = summarizeGitError(message);
credDialogError = detail
? `${detail} — please sign in again.`
@@ -3160,6 +3310,7 @@
errorMessage = message;
}
} else {
if (auth && key) rejectedCredentialKeys.add(key);
credDialogError = message || "Sign-in failed.";
}
}
@@ -3169,6 +3320,7 @@
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Pulling", async () => {
@@ -3179,7 +3331,7 @@
changed_files: status?.files.length ?? 0,
});
});
handleRemoteResult("pull", key, fromStore);
handleRemoteResult("pull", key, fromStore, username, mode);
}
async function doActualFetch(
@@ -3187,6 +3339,7 @@
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Fetching", async () => {
@@ -3199,7 +3352,7 @@
behind: status?.behind ?? 0,
});
});
handleRemoteResult("fetch", key, fromStore);
handleRemoteResult("fetch", key, fromStore, username, mode);
}
async function doActualPush(
@@ -3207,6 +3360,7 @@
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Pushing", async () => {
@@ -3235,12 +3389,12 @@
if (!fromStore) credDialogError = "";
await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password));
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath);
});
if (errorMessage) {
handleRemoteResult("pull", key, fromStore);
handleRemoteResult("pull", key, fromStore, username, mode);
return;
}
@@ -3252,7 +3406,7 @@
}
await runOperation("Pushing after pull", async () => {
applyStatus(await push(activeRepoPath, username, password));
applyStatus(await push(activeRepoPath, username, password, false, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath, { files: false });
trackEvent("repository_pushed_after_pull", {
from_stored_credential: fromStore ? 1 : 0,
@@ -3261,14 +3415,59 @@
});
}
handleRemoteResult("push", key, fromStore);
handleRemoteResult("push", key, fromStore, username, mode);
}
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
async function doActualRemoteRename(
username: string,
password: string,
key: string | null,
fromStore: boolean,
mode: CredentialMode,
) {
const rename = pendingRemoteRename;
if (!activeRepoPath || !rename) return;
errorMessage = "";
await runOperation(`Renaming ${rename.remote}/${rename.oldBranch} on remote`, async () => {
applyStatus(await renameRemoteBranch(
activeRepoPath,
rename.remote,
rename.oldBranch,
rename.newBranch,
username,
password,
));
renameBranchTarget = null;
pendingRemoteRename = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
handleRemoteResult("rename", key, fromStore, username, mode);
}
async function handleCredentialSubmit(
username: string,
password: string,
save: boolean,
mode: CredentialMode,
) {
const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
// Honour "Save in keychain" immediately. A successful authentication
// followed by an unrelated refresh/non-fast-forward error must not lose the
// token and force the user to type it again on the next operation.
if (save && key) {
try {
await credSave(key, username, password, mode);
} catch (error) {
credDialogError = errorToMessage(error);
return;
}
}
if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
@@ -3278,17 +3477,9 @@
password,
key,
false,
mode,
);
}
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
try {
await credSave(key, username, password);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
}
async function startRemoteAction(action: "push" | "pull" | "fetch") {
@@ -3296,17 +3487,18 @@
trackEvent("remote_action_started", {
action,
});
const key = await currentCredKey();
const key = await currentCredKey(action);
const stored = await loadStoredCredential(key);
if (stored) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true);
if (stored && (!key || !rejectedCredentialKeys.has(key))) {
const mode = credentialModeFor(stored);
if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
else await doActualPush(stored.username, stored.password, key, true, mode);
return;
}
await openCredentialDialog(action, key);
await openCredentialDialog(action, key, stored);
}
async function fetchRepo() {
@@ -5015,6 +5207,7 @@
onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit}
onOpenCommitNote={openCommitNoteDialog}
onLoadCommitNote={loadCommitNotePreview}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
@@ -5375,8 +5568,17 @@
action={credDialogAction}
error={credDialogError}
{isBusy}
initialUsername={credDialogUsername}
initialMode={credDialogMode}
onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
onCancel={() => {
credDialogOpen = false;
credDialogAction = null;
credDialogError = "";
credDialogKey = null;
credDialogUsername = "";
credDialogMode = "credentials";
}}
/>
{/if}
+162 -2
View File
@@ -2212,7 +2212,11 @@
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.branch-context-menu,
.branch-context-menu {
position: fixed;
max-height: calc(100vh - 16px);
overflow-y: auto;
}
.history-context-menu { position: absolute; }
.explorer-context-menu,
.repo-tab-context-menu { position: fixed; }
@@ -2542,6 +2546,107 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-note-indicator {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.commit-note-presence {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 18px;
padding: 1px 5px 1px 4px;
border: 1px solid transparent;
border-radius: 5px;
color: #dcb96c;
background: linear-gradient(90deg, rgba(216, 167, 74, 0.11), rgba(216, 167, 74, 0.045));
box-shadow: inset 0 -1px 0 rgba(216, 167, 74, 0.22);
font-size: 9px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.015em;
}
.commit-note-presence:hover:not(:disabled) {
border-color: rgba(216, 167, 74, 0.24);
color: #efd08a;
background: linear-gradient(90deg, rgba(216, 167, 74, 0.17), rgba(216, 167, 74, 0.075));
}
.commit-note-presence:focus-visible {
outline: 2px solid rgba(216, 167, 74, 0.32);
outline-offset: 2px;
}
.commit-note-tooltip {
position: absolute;
z-index: 40;
top: calc(100% + 7px);
right: 0;
display: grid;
width: min(270px, calc(100vw - 36px));
padding: 9px 10px 10px;
border: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
border-radius: 7px;
color: var(--color-ink-muted);
background: color-mix(in srgb, #d8a74a 4%, var(--color-surface-solid));
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.34), inset 2px 0 0 rgba(216, 167, 74, 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(-3px);
visibility: hidden;
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
}
.commit-note-tooltip::before {
content: "";
position: absolute;
top: -4px;
right: 12px;
width: 7px;
height: 7px;
border-top: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
border-left: 1px solid color-mix(in srgb, #d8a74a 24%, var(--color-border));
background: color-mix(in srgb, #d8a74a 4%, var(--color-surface-solid));
transform: rotate(45deg);
}
.commit-note-indicator:hover,
.commit-note-indicator:focus-within { z-index: 40; }
.commit-note-indicator:hover .commit-note-tooltip,
.commit-note-indicator:focus-within .commit-note-tooltip {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
.commit-note-tooltip-head {
display: flex;
align-items: center;
gap: 5px;
padding-bottom: 6px;
border-bottom: 1px solid var(--color-border-subtle);
color: #dcb96c;
font-size: 9px;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.commit-note-tooltip-head small {
margin-left: auto;
color: var(--color-ink-faint);
font-size: 8px;
font-weight: 650;
letter-spacing: 0;
text-transform: none;
}
.commit-note-tooltip-body {
display: -webkit-box;
overflow: hidden;
padding-top: 7px;
color: var(--color-ink-muted);
font-size: 10.5px;
line-height: 1.45;
overflow-wrap: anywhere;
white-space: pre-wrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
}
.commit-ref-area {
position: relative;
@@ -5979,12 +6084,35 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); }
.commit-body {
border-radius: var(--ui-radius-sm);
border-radius: 0;
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
box-shadow: none;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
.commit-note-rail {
position: absolute;
z-index: 2;
top: 10px;
bottom: 10px;
left: 0;
width: 2px;
border-radius: 0 2px 2px 0;
background: #d8a74a;
box-shadow: 0 0 8px rgba(216, 167, 74, 0.16);
opacity: 0.68;
pointer-events: none;
}
.graph-row.has-note .commit-body {
background:
linear-gradient(90deg, rgba(216, 167, 74, 0.075), rgba(216, 167, 74, 0.025) 36%, transparent 68%),
color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
}
.graph-row.has-note:hover .commit-body {
background:
linear-gradient(90deg, rgba(216, 167, 74, 0.105), rgba(216, 167, 74, 0.035) 36%, transparent 68%),
var(--color-surface-hover);
}
.graph-row {
content-visibility: auto;
contain-intrinsic-block-size: 108px;
@@ -6219,6 +6347,38 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: rgba(235,241,250,0.9);
}
:root[data-theme="light"] .graph-row.has-note .commit-body {
background:
linear-gradient(90deg, rgba(194, 132, 35, 0.09), rgba(194, 132, 35, 0.025) 38%, transparent 68%),
rgba(255,255,255,0.76);
}
:root[data-theme="light"] .graph-row.has-note:hover .commit-body {
background:
linear-gradient(90deg, rgba(194, 132, 35, 0.12), rgba(194, 132, 35, 0.035) 38%, transparent 68%),
rgba(235,241,250,0.92);
}
:root[data-theme="light"] .commit-note-presence {
border-color: transparent;
color: #986313;
background: linear-gradient(90deg, rgba(194, 132, 35, 0.12), rgba(194, 132, 35, 0.05));
box-shadow: inset 0 -1px 0 rgba(168, 105, 16, 0.2);
}
:root[data-theme="light"] .commit-note-tooltip,
:root[data-theme="light"] .commit-note-tooltip::before {
background: color-mix(in srgb, #c28423 4%, #ffffff);
}
:root[data-theme="light"] .commit-note-tooltip {
box-shadow: 0 12px 30px rgba(35, 45, 68, 0.16), inset 2px 0 0 rgba(194, 132, 35, 0.48);
}
:root[data-theme="light"] .commit-note-tooltip-head {
color: #986313;
}
:root[data-theme="light"] .graph-row.merge-row .commit-body {
background: rgba(248,244,252,0.82);
}
+35 -20
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -99,11 +100,12 @@
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
@@ -243,20 +245,31 @@
onCheckout(branch);
}
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
const rect = element?.getBoundingClientRect();
const width = rect?.width ?? 184;
const height = rect?.height ?? 0;
return {
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
};
}
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2;
await tick();
if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
contextMenuX = position.x;
contextMenuY = position.y;
}
function closeBranchContextMenu() {
@@ -335,20 +348,20 @@
tagsOpen = true;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
tagContextMenuX = event.clientX + 2;
tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
}
function closeTagContextMenu() {
@@ -381,7 +394,7 @@
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head">
<div>
<span class="eyebrow">Branches</span>
@@ -676,6 +689,7 @@
{#if contextBranch}
<div
bind:this={branchContextMenuElement}
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
@@ -723,6 +737,7 @@
{#if contextTag}
<div
bind:this={tagContextMenuElement}
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
+33 -28
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import {
AlertCircle,
Download,
@@ -14,10 +15,12 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch" | "clone";
action: "push" | "pull" | "fetch" | "clone" | "rename";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean) => void;
initialUsername?: string;
initialMode?: Mode;
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
onCancel: () => void;
}
@@ -25,14 +28,16 @@
action,
error = "",
isBusy = false,
initialUsername = "",
initialMode = "credentials",
onSubmit,
onCancel,
}: Props = $props();
type Mode = "credentials" | "token";
let mode = $state<Mode>("credentials");
let username = $state("");
let mode = $state<Mode>(untrack(() => initialMode));
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(true);
@@ -40,19 +45,21 @@
let canSubmit = $derived(
!isBusy &&
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
username.trim().length > 0,
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : "Pull");
let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "fetch"
: action === "rename"
? "Authenticate remote rename"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push"
let actionHint = $derived(action === "push" || action === "rename"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
@@ -61,7 +68,7 @@
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
onSubmit(username.trim(), password, saveSession, mode);
}
</script>
@@ -73,7 +80,7 @@
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
{#if action === "push"}
{#if action === "push" || action === "rename"}
<Upload size={27} aria-hidden="true" />
{:else}
<Download size={27} aria-hidden="true" />
@@ -92,7 +99,7 @@
<div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" />
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
<span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
</div>
</div>
@@ -116,27 +123,25 @@
aria-pressed={mode === "token"}
>
<Key size={13} aria-hidden="true" />
Token
Access token
</button>
</div>
<div class="cred-fields">
{#if mode === "credentials"}
<div class="cred-field">
<label class="cred-field-label" for="cred-username">Username</label>
<div class="cred-input">
<User size={15} class="cred-field-icon" aria-hidden="true" />
<input
id="cred-username"
type="text"
bind:value={username}
placeholder="e.g. my-github-username"
autocomplete="username"
disabled={isBusy}
/>
</div>
<div class="cred-field">
<label class="cred-field-label" for="cred-username">Username</label>
<div class="cred-input">
<User size={15} class="cred-field-icon" aria-hidden="true" />
<input
id="cred-username"
type="text"
bind:value={username}
placeholder="Your account username"
autocomplete="username"
disabled={isBusy}
/>
</div>
{/if}
</div>
<div class="cred-field">
<label class="cred-field-label" for="cred-password">
@@ -174,7 +179,7 @@
{#if mode === "token"}
<div class="cred-token-hint">
<Key size={13} aria-hidden="true" />
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
<span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
</div>
{/if}
+38
View File
@@ -547,6 +547,25 @@
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
],
},
{
id: "app-git-notes",
title: "Commits mit Git Notes ergänzen",
summary: "Git Notes speichern zusätzliche Informationen zu einem Commit, ohne dessen Hash oder die Historie zu verändern. Sie eignen sich etwa für Review-Hinweise, Ticket-Kontext, Build-IDs oder Freigabestatus.",
steps: [
"Öffne im Commit-Verlauf über das Notiz-Symbol oder das Kontextmenü die Commit-Notiz.",
"Schreibe oder bearbeite die Notiz und speichere sie. Commits mit einer Notiz sind im Verlauf markiert; beim Überfahren der Markierung erscheint eine Vorschau.",
"Löschen entfernt nur die Notiz. Der zugehörige Commit und seine Dateien bleiben unverändert.",
"Gitty lädt Git Notes im Hintergrund vom bevorzugten Remote. Nutze im Dialog Vom Remote laden, um sie bei Bedarf gezielt zu aktualisieren.",
"Nutze Zum Remote senden, um lokale Notizen zu veröffentlichen. Ein normaler Branch-Push überträgt Git Notes nicht automatisch.",
],
commands: [
{ command: "git notes show <commit>", description: "Notiz eines Commits in der Kommandozeile anzeigen" },
{ command: "git notes add <commit>", description: "Notiz zu einem Commit hinzufügen oder im Editor verfassen" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Commit-Notizen gezielt vom Remote laden" },
{ command: "git push <remote> refs/notes/commits", description: "Lokale Commit-Notizen zum Remote senden" },
],
note: "Git Notes liegen standardmäßig unter refs/notes/commits und werden getrennt von Branches synchronisiert. Prüfe vor einem Push, ob der Ziel-Remote diese Referenz akzeptiert.",
},
{
id: "app-search",
title: "Code-Ursprung mit Global Search finden",
@@ -990,6 +1009,25 @@
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
],
},
{
id: "app-git-notes",
title: "Add context to commits with Git Notes",
summary: "Git Notes attach additional information to a commit without changing its hash or rewriting history. They are useful for review findings, ticket context, build IDs, or approval status.",
steps: [
"Open the commit note from the note icon or the commit context menu in History.",
"Write or edit the note and save it. Commits with a note are marked in History, and hovering over the marker shows a preview.",
"Deleting removes only the note. The associated commit and its files remain unchanged.",
"Gitty fetches Git Notes from the preferred remote in the background. Use Fetch from remote in the dialog to refresh them explicitly when needed.",
"Use Push to remote to publish local notes. A regular branch push does not transfer Git Notes automatically.",
],
commands: [
{ command: "git notes show <commit>", description: "Show a commit's note on the command line" },
{ command: "git notes add <commit>", description: "Add a note to a commit or compose it in an editor" },
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Fetch commit notes explicitly from a remote" },
{ command: "git push <remote> refs/notes/commits", description: "Push local commit notes to a remote" },
],
note: "Git Notes are stored under refs/notes/commits by default and synchronize separately from branches. Before pushing, make sure the destination remote accepts this reference.",
},
{
id: "app-search",
title: "Find code origins with Global Search",
+93 -10
View File
@@ -80,6 +80,7 @@
onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void;
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
onSelectCommit: (commit: GitCommit) => void;
}
@@ -108,6 +109,7 @@
onCherryPickCommit = () => {},
onRevertCommit = () => {},
onOpenCommitNote = () => {},
onLoadCommitNote = async () => null,
onSelectCommit = () => {},
}: Props = $props();
@@ -120,6 +122,16 @@
let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let notePreviews = $state<Record<string, string>>({});
let notePreviewLoading = $state<Set<string>>(new Set());
let notePreviewErrors = $state<Set<string>>(new Set());
$effect(() => {
repositoryKey;
notePreviews = {};
notePreviewLoading = new Set();
notePreviewErrors = new Set();
});
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
@@ -503,6 +515,34 @@
await onOpenCommitNote(commit);
}
async function loadCommitNotePreview(commit: GitCommit) {
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
const loading = new Set(notePreviewLoading);
loading.add(commit.hash);
notePreviewLoading = loading;
const errors = new Set(notePreviewErrors);
errors.delete(commit.hash);
notePreviewErrors = errors;
try {
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
};
} catch {
const nextErrors = new Set(notePreviewErrors);
nextErrors.add(commit.hash);
notePreviewErrors = nextErrors;
} finally {
const nextLoading = new Set(notePreviewLoading);
nextLoading.delete(commit.hash);
notePreviewLoading = nextLoading;
}
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
@@ -792,6 +832,7 @@
<article
class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash}
class:has-note={item.has_note}
data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
@@ -852,6 +893,9 @@
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
>
{#if item.has_note}
<span class="commit-note-rail" aria-hidden="true"></span>
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
@@ -966,6 +1010,43 @@
<div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
{#if item.has_note}
<span class="commit-note-indicator">
<button
class="commit-note-presence"
type="button"
onpointerenter={() => void loadCommitNotePreview(item)}
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
</button>
<span
class="commit-note-tooltip"
id={`commit-note-preview-${item.hash}`}
role="tooltip"
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
Loading note…
{:else if notePreviewErrors.has(item.hash)}
Note could not be loaded.
{:else}
{notePreviews[item.hash] ?? "Hover to load the note."}
{/if}
</span>
</span>
</span>
{/if}
</div>
</div>
</div>
@@ -1008,16 +1089,18 @@
<div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button
class="commit-menu-button commit-note-button"
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Open internal note for ${item.short_hash}`}
aria-label={`Open internal note for ${item.short_hash}`}
>
<StickyNote size={14} aria-hidden="true" />
</button>
{#if !item.has_note}
<button
class="commit-menu-button commit-note-button"
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Add a Git note to ${item.short_hash}`}
aria-label={`Add a Git note to ${item.short_hash}`}
>
<StickyNote size={14} aria-hidden="true" />
</button>
{/if}
<button
class="commit-menu-button"
type="button"
+14 -5
View File
@@ -138,8 +138,17 @@ export function renameRemoteBranch(
remote: string,
oldBranch: string,
newBranch: string,
username?: string,
password?: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
return invoke<GitStatus>("rename_remote_branch", {
path,
remote,
oldBranch,
newBranch,
username: username ?? null,
password: password ?? null,
});
}
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
@@ -373,16 +382,16 @@ export function push(path: string, username?: string, password?: string, forceWi
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
}
export function getRemoteUrl(path: string): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path });
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
}
export function credLoad(key: string): Promise<StoredCredential | null> {
return invoke<StoredCredential | null>("cred_load", { key });
}
export function credSave(key: string, username: string, password: string): Promise<void> {
return invoke<void>("cred_save", { key, username, password });
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
return invoke<void>("cred_save", { key, username, password, mode });
}
export function credDelete(key: string): Promise<void> {
+2
View File
@@ -178,6 +178,7 @@ export interface GitCommit {
refs: string[];
parents: string[];
files: GitCommitFile[];
has_note: boolean;
}
export interface GitCommitFile {
@@ -310,4 +311,5 @@ export interface ReflogEntry {
export interface StoredCredential {
username: string;
password: string;
mode?: "credentials" | "token";
}