Merge branch 'master' into ndom91/add-nix-flake

This commit is contained in:
ndom91 2024-05-05 22:27:07 +02:00
commit 214667b17b
No known key found for this signature in database
184 changed files with 6184 additions and 3117 deletions

View File

@ -15,6 +15,10 @@ runs:
steps:
- uses: ./.github/actions/init-env-rust
- run: |
cargo build --locked -p gitbutler-git --bins
shell: bash
- run: |
printf '%s\n' "$JSON_DOC" > /tmp/features.json
cat /tmp/features.json | jq -r 'if . == "*" then "--all-features" elif . == "" then "" elif type == "array" then if length == 0 then "--no-default-features" else "--no-default-features --features " + join(",") end else . end' > /tmp/features
@ -23,7 +27,9 @@ runs:
FEATURES: ${{ inputs.features }}
shell: bash
- run: cargo test --locked -p ${{ inputs.crate }} --all-targets $(cat /tmp/features)
- run: |
cargo build -p gitbutler-git --bins
cargo test --locked -p ${{ inputs.crate }} --all-targets $(cat /tmp/features)
if: inputs.action == 'test'
env:
GITBUTLER_TESTS_NO_CLEANUP: "1"

View File

@ -3,13 +3,6 @@ description: prepare runner for rust related tasks
runs:
using: "composite"
steps:
- name: Cache rust dependencies
if: runner.name != 'ScottsMacStudio' # internet in berlin is very slow
uses: Swatinem/rust-cache@v2
with:
prefix-key: gitbutler-client
shared-key: rust
- name: Check versions
shell: bash
run: |

View File

@ -29,9 +29,10 @@ jobs:
strategy:
fail-fast: false
matrix:
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-private-repositories
platform:
- macos-latest # [macOs, x64]
- macos-latest-xlarge # [macOs, ARM64]
- macos-13 # [macOs, x64]
- macos-latest # [macOs, ARM64]
- ubuntu-20.04 # [linux, x64]
- windows-latest # [windows, x64]
@ -191,9 +192,10 @@ jobs:
strategy:
fail-fast: false
matrix:
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-private-repositories
platform:
- macos-latest # [macOs, x64]
- macos-latest-xlarge # [macOs, ARM64]
- macos-13 # [macOs, x64]
- macos-latest # [macOs, ARM64]
- ubuntu-20.04 # [linux, x64]
- windows-latest # [windows, x64]
steps:

View File

@ -18,6 +18,7 @@ jobs:
gitbutler-tauri: ${{ steps.filter.outputs.gitbutler-tauri }}
gitbutler-changeset: ${{ steps.filter.outputs.gitbutler-changeset }}
gitbutler-git: ${{ steps.filter.outputs.gitbutler-git }}
gitbutler-cli: ${{ steps.filter.outputs.gitbutler-cli }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -51,6 +52,9 @@ jobs:
gitbutler-git:
- *rust
- 'crates/gitbutler-git/**'
gitbutler-cli:
- *rust
- 'crates/gitbutler-cli/**'
lint-node:
needs: changes
@ -79,7 +83,7 @@ jobs:
- uses: ./.github/actions/init-env-node
- run: pnpm test
rust-init:
rust-lint:
needs: changes
if: ${{ needs.changes.outputs.rust == 'true' }}
runs-on: ubuntu-latest
@ -89,7 +93,6 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- run: cargo fmt --check --all
- run: cargo build --locked --all-targets --tests
rust-docs:
needs: changes
@ -107,7 +110,7 @@ jobs:
RUSTDOCFLAGS: -Dwarnings
check-gitbutler-tauri:
needs: [changes, rust-init]
needs: changes
if: ${{ needs.changes.outputs.gitbutler-tauri == 'true' }}
runs-on: ubuntu-latest
container:
@ -123,6 +126,7 @@ jobs:
- [devtools]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-tauri
@ -130,7 +134,7 @@ jobs:
action: ${{ matrix.action }}
check-gitbutler-changeset:
needs: [changes, rust-init]
needs: changes
if: ${{ needs.changes.outputs.gitbutler-changeset == 'true' }}
runs-on: ubuntu-latest
container:
@ -147,6 +151,7 @@ jobs:
- []
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-changeset
@ -154,7 +159,7 @@ jobs:
action: ${{ matrix.action }}
check-gitbutler-git:
needs: [changes, rust-init]
needs: changes
if: ${{ needs.changes.outputs.gitbutler-git == 'true' }}
runs-on: ubuntu-latest
container:
@ -171,6 +176,7 @@ jobs:
- [tokio]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-git
@ -178,7 +184,7 @@ jobs:
action: ${{ matrix.action }}
check-gitbutler-core:
needs: [changes, rust-init]
needs: changes
if: ${{ needs.changes.outputs.gitbutler-core == 'true' }}
runs-on: ubuntu-latest
container:
@ -194,12 +200,38 @@ jobs:
- []
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-core
features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }}
check-gitbutler-cli:
needs: changes
if: ${{ needs.changes.outputs.gitbutler-cli == 'true' }}
runs-on: ubuntu-latest
container:
image: ghcr.io/gitbutlerapp/ci-base-image:latest
strategy:
matrix:
action:
- test
- check
- check-tests
features:
- ''
- '*'
- []
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-env-rust
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-cli
features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }}
check-rust:
if: always()
needs:
@ -208,7 +240,9 @@ jobs:
- check-gitbutler-core
- check-gitbutler-changeset
- check-gitbutler-git
- check-gitbutler-cli
- check-rust-windows
- rust-lint
runs-on: ubuntu-latest
steps:
- name: Decide whether the needed jobs succeeded or failed
@ -223,6 +257,6 @@ jobs:
if: ${{ needs.changes.outputs.rust == 'true' }}
steps:
- uses: actions/checkout@v3
- uses: Swatinem/rust-cache@v2
- uses: ./.github/actions/init-env-rust
- name: "cargo check"
run: cargo check --all --bins --examples
run: cargo check --all --bins --examples --features windows

3
.gitignore vendored
View File

@ -7,3 +7,6 @@
.idea
.DS_Store
.env
.env.*

470
Cargo.lock generated
View File

@ -97,14 +97,60 @@ dependencies = [
]
[[package]]
name = "anyhow"
version = "1.0.81"
name = "anstream"
version = "0.6.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247"
checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
dependencies = [
"backtrace",
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
[[package]]
name = "anstyle-parse"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]]
name = "anyhow"
version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
[[package]]
name = "arc-swap"
version = "1.7.1"
@ -300,9 +346,9 @@ checksum = "fbb36e985947064623dbd357f727af08ffd077f93d696782f3c56365fa2e2799"
[[package]]
name = "async-trait"
version = "0.1.79"
version = "0.1.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681"
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [
"proc-macro2",
"quote",
@ -434,6 +480,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.6.0"
@ -752,9 +804,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chrono"
version = "0.4.37"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
@ -775,6 +827,33 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim 0.11.1",
]
[[package]]
name = "clap_lex"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce"
[[package]]
name = "clru"
version = "0.6.1"
@ -817,6 +896,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
[[package]]
name = "combine"
version = "4.6.6"
@ -1095,7 +1180,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.10.0",
"syn 2.0.58",
]
@ -1123,16 +1208,6 @@ dependencies = [
"parking_lot_core 0.9.9",
]
[[package]]
name = "debugid"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid",
]
[[package]]
name = "der"
version = "0.7.9"
@ -1415,6 +1490,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1"
dependencies = [
"errno-dragonfly",
"libc",
"winapi 0.3.9",
]
[[package]]
name = "errno"
version = "0.3.8"
@ -1425,6 +1511,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "errno-dragonfly"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "event-listener"
version = "2.5.3"
@ -1598,18 +1694,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "findshlibs"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64"
dependencies = [
"cc",
"lazy_static",
"libc",
"winapi 0.3.9",
]
[[package]]
name = "flate2"
version = "1.0.28"
@ -2031,21 +2115,6 @@ dependencies = [
"thiserror",
]
[[package]]
name = "gitbutler-analytics"
version = "0.0.0"
dependencies = [
"async-trait",
"chrono",
"gitbutler-core",
"reqwest 0.12.2",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "gitbutler-changeset"
version = "0.0.0"
@ -2054,6 +2123,17 @@ dependencies = [
"thiserror",
]
[[package]]
name = "gitbutler-cli"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"gitbutler-core",
"pager",
]
[[package]]
name = "gitbutler-core"
version = "0.0.0"
@ -2084,7 +2164,7 @@ dependencies = [
"rand 0.8.5",
"refinery",
"regex",
"reqwest 0.12.2",
"reqwest 0.12.4",
"resolve-path",
"rusqlite",
"serde",
@ -2094,6 +2174,7 @@ dependencies = [
"slug",
"ssh-key",
"ssh2",
"strum",
"tempfile",
"thiserror",
"tokio",
@ -2131,7 +2212,6 @@ dependencies = [
"console-subscriber",
"futures",
"git2",
"gitbutler-analytics",
"gitbutler-core",
"gitbutler-testsupport",
"gitbutler-watcher",
@ -2140,9 +2220,7 @@ dependencies = [
"nonzero_ext",
"once_cell",
"pretty_assertions",
"reqwest 0.12.2",
"sentry",
"sentry-tracing",
"reqwest 0.12.4",
"serde",
"serde_json",
"slug",
@ -2182,7 +2260,6 @@ dependencies = [
"crossbeam-channel",
"futures",
"git2",
"gitbutler-analytics",
"gitbutler-core",
"gitbutler-testsupport",
"itertools 0.12.1",
@ -3117,6 +3194,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.3.9"
@ -3147,17 +3230,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi 0.3.9",
]
[[package]]
name = "html5ever"
version = "0.26.0"
@ -3526,6 +3598,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]]
name = "itertools"
version = "0.11.0"
@ -3860,12 +3938,6 @@ dependencies = [
"tendril",
]
[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]]
name = "matchers"
version = "0.1.0"
@ -4399,6 +4471,16 @@ dependencies = [
"sha2",
]
[[package]]
name = "pager"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2599211a5c97fbbb1061d3dc751fa15f404927e4846e07c643287d6d1f462880"
dependencies = [
"errno 0.2.8",
"libc",
]
[[package]]
name = "pango"
version = "0.15.10"
@ -5291,7 +5373,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"serde_urlencoded",
@ -5311,11 +5393,11 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.2"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d66674f2b6fb864665eea7a3c1ac4e3dfacd2fda83cf6f935a612e01b0e3338"
checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
@ -5335,7 +5417,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"rustls-pemfile 2.1.2",
"serde",
"serde_json",
"serde_urlencoded",
@ -5348,7 +5430,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg 0.50.0",
"winreg 0.52.0",
]
[[package]]
@ -5496,7 +5578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2"
dependencies = [
"bitflags 1.3.2",
"errno",
"errno 0.3.8",
"io-lifetimes",
"libc",
"linux-raw-sys 0.3.8",
@ -5510,7 +5592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89"
dependencies = [
"bitflags 2.5.0",
"errno",
"errno 0.3.8",
"libc",
"linux-raw-sys 0.4.13",
"windows-sys 0.52.0",
@ -5525,6 +5607,22 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d"
dependencies = [
"base64 0.22.1",
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54"
[[package]]
name = "rustversion"
version = "1.0.14"
@ -5648,140 +5746,20 @@ dependencies = [
"serde",
]
[[package]]
name = "sentry"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "766448f12e44d68e675d5789a261515c46ac6ccd240abdd451a9c46c84a49523"
dependencies = [
"httpdate",
"native-tls",
"reqwest 0.11.27",
"sentry-anyhow",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-debug-images",
"sentry-panic",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
name = "sentry-anyhow"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4da4015667c99f88d68ca7ff02b90c762d6154a4ceb7c02922b9a1dbd3959eeb"
dependencies = [
"anyhow",
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-backtrace"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32701cad8b3c78101e1cd33039303154791b0ff22e7802ed8cc23212ef478b45"
dependencies = [
"backtrace",
"once_cell",
"regex",
"sentry-core",
]
[[package]]
name = "sentry-contexts"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ddd2a91a13805bd8dab4ebf47323426f758c35f7bf24eacc1aded9668f3824"
dependencies = [
"hostname",
"libc",
"os_info",
"rustc_version",
"sentry-core",
"uname",
]
[[package]]
name = "sentry-core"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1189f68d7e7e102ef7171adf75f83a59607fafd1a5eecc9dc06c026ff3bdec4"
dependencies = [
"once_cell",
"rand 0.8.5",
"sentry-types",
"serde",
"serde_json",
]
[[package]]
name = "sentry-debug-images"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4d0a615e5eeca5699030620c119a094e04c14cf6b486ea1030460a544111a7"
dependencies = [
"findshlibs",
"once_cell",
"sentry-core",
]
[[package]]
name = "sentry-panic"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c18d0b5fba195a4950f2f4c31023725c76f00aabb5840b7950479ece21b5ca"
dependencies = [
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-tracing"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3012699a9957d7f97047fd75d116e22d120668327db6e7c59824582e16e791b2"
dependencies = [
"sentry-backtrace",
"sentry-core",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "sentry-types"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7173fd594569091f68a7c37a886e202f4d0c1db1e1fa1d18a051ba695b2e2ec"
dependencies = [
"debugid",
"hex",
"rand 0.8.5",
"serde",
"serde_json",
"thiserror",
"time",
"url",
"uuid",
]
[[package]]
name = "serde"
version = "1.0.197"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.197"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc"
dependencies = [
"proc-macro2",
"quote",
@ -5790,9 +5768,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.115"
version = "1.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd"
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
dependencies = [
"indexmap 2.2.6",
"itoa 1.0.11",
@ -6129,9 +6107,9 @@ dependencies = [
[[package]]
name = "ssh-key"
version = "0.6.5"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b71299a724c8d84956caaf8fc3b3ea57c3587fe2d0b800cd0dc1f3599905d7e"
checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc"
dependencies = [
"ed25519-dalek",
"p256",
@ -6213,6 +6191,34 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.58",
]
[[package]]
name = "subtle"
version = "2.5.0"
@ -6274,9 +6280,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.30.8"
version = "0.30.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b1a378e48fb3ce3a5cf04359c456c9c98ff689bcf1c1bc6e6a31f247686f275"
checksum = "87341a165d73787554941cd5ef55ad728011566fe714e987d1b976c15dbc3a83"
dependencies = [
"cfg-if",
"core-foundation-sys",
@ -6417,9 +6423,9 @@ checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f"
[[package]]
name = "tauri"
version = "1.6.1"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f078117725e36d55d29fafcbb4b1e909073807ca328ae8deb8c0b3843aac0fed"
checksum = "047aefcc7721bfb8024a9bc39d4719112262610502de7a224fa62c4570cd78d4"
dependencies = [
"anyhow",
"bytes",
@ -6433,7 +6439,7 @@ dependencies = [
"glib",
"glob",
"gtk",
"heck 0.4.1",
"heck 0.5.0",
"http 0.2.12",
"ignore",
"indexmap 1.9.3",
@ -6551,7 +6557,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [
"byte-unit",
"fern",
@ -6566,7 +6572,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [
"log",
"serde",
@ -6580,7 +6586,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-store"
version = "0.0.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [
"log",
"serde",
@ -6592,7 +6598,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-window-state"
version = "0.1.1"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#773b4983928831b90c16304b51d2fdded9d75066"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#5e3900e682e13f3759b439116ae2f77a6d389ca2"
dependencies = [
"bincode",
"bitflags 2.5.0",
@ -6715,18 +6721,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
[[package]]
name = "thiserror"
version = "1.0.58"
version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.58"
version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
dependencies = [
"proc-macro2",
"quote",
@ -7122,15 +7128,6 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "uname"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8"
dependencies = [
"libc",
]
[[package]]
name = "unicode-bidi"
version = "0.3.15"
@ -7164,19 +7161,6 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
[[package]]
name = "ureq"
version = "2.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f214ce18d8b2cbe84ed3aa6486ed3f5b285cf8d8fbdbce9f3f767a724adc35"
dependencies = [
"base64 0.21.7",
"log",
"native-tls",
"once_cell",
"url",
]
[[package]]
name = "url"
version = "2.5.0"
@ -7207,6 +7191,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
version = "1.8.0"

View File

@ -1,12 +1,12 @@
[workspace]
members = [
"crates/gitbutler-analytics",
"crates/gitbutler-core",
"crates/gitbutler-tauri",
"crates/gitbutler-changeset",
"crates/gitbutler-git",
"crates/gitbutler-watcher",
"crates/gitbutler-testsupport",
"crates/gitbutler-cli",
]
resolver = "2"
@ -15,18 +15,18 @@ gix = { version = "0.62.0", default-features = false, features = [] } # add perf
git2 = { version = "0.18.3", features = ["vendored-openssl", "vendored-libgit2"] }
uuid = { version = "1.8.0", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0.58"
thiserror = "1.0.59"
rusqlite = { version = "0.29.0", features = [ "bundled", "blob" ] }
tokio = { version = "1.37.0", default-features = false }
gitbutler-git = { path = "crates/gitbutler-git" }
gitbutler-core = { path = "crates/gitbutler-core" }
gitbutler-analytics = { path = "crates/gitbutler-analytics" }
gitbutler-watcher = { path = "crates/gitbutler-watcher" }
gitbutler-testsupport = { path = "crates/gitbutler-testsupport" }
gitbutler-cli ={ path = "crates/gitbutler-cli" }
[profile.release]
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations
opt-level = "s" # Optimize for binary size
debug = true # Enable debug symbols, for sentry
debug = true # Enable debug symbols, for profiling

View File

@ -168,29 +168,21 @@ We use `pnpm`, which requires a relatively recent version of Node.js.
Make sure that the latest stable version of Node.js is installed and
on the PATH, and then `npm i -g pnpm`.
This often causes file permissions. First, the AppData folder may not
be present. Be sure to create it if it isn't.
Sometimes npm's prefix is incorrect on Windows, we can check this via:
```
mkdir %APPDATA%\npm
```sh
npm config get prefix
```
Secondly, typically folders within `Program Files` are not writable.
You'll need to fix the security permissions for the `nodejs` folder.
If it's not `C:\Users\<username>\AppData\Roaming\npm` or another folder that is
normally writable, then we can set it in Powershell:
> **NOTE:** Under specific circumstances, depending on your usage of
> Node.js, this may pose a security concern. Be sure to understand
> the implications of this before proceeding.
```sh
mkdir -p $APPDATA\npm
npm config set prefix $env:APPDATA\npm
```
1. Right-click on the `nodejs` folder in `Program Files`.
2. Click on `Properties`.
3. Click on the `Security` tab.
4. Click on `Edit` next to "change permissions".
5. Click on `Add`.
6. Type in the name of your user account, or type `Everyone` (case-sensitive).
Click `Check Names` to verify (they will be underlined if correct).
7. Make sure that `Full Control` is checked under `Allow`.
8. Apply / click OK as needed to close the dialogs.
Afterwards, add this folder to your PATH.
### Perl
@ -198,6 +190,7 @@ A Perl interpreter is required to be installed in order to configure the `openss
crate. We've used [Strawberry Perl](https://strawberryperl.com/) without issue.
Make sure it's installed and `perl` is available on the `PATH` (it is by default
after installation, just make sure to restart the terminal after installing).
[Scoop](https://scoop.sh/) users can install this via `scoop install perl`.
Note that it might appear that the build has hung or frozen on the `openssl-sys` crate.
It's not, it's just that Cargo can't report the status of a C/C++ build happening

View File

@ -40,17 +40,17 @@
"@lezer/highlight": "^1.2.0",
"@octokit/rest": "^20.1.0",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@sentry/sveltekit": "^7.111.0",
"@sentry/sveltekit": "^7.112.2",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.30.4",
"@tauri-apps/api": "^1.5.3",
"@tauri-apps/api": "^1.5.4",
"@types/crypto-js": "^4.2.2",
"@types/diff": "^5.2.0",
"@types/diff-match-patch": "^1.0.36",
"@types/lscache": "^1.3.4",
"@types/marked": "^5.0.2",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"autoprefixer": "^10.4.19",
"class-transformer": "^0.5.1",
"crypto-js": "^4.2.0",
@ -61,7 +61,7 @@
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-square-svelte-store": "^1.0.0",
"eslint-plugin-svelte": "^2.37.0",
"eslint-plugin-svelte": "^2.38.0",
"inter-ui": "^4.0.2",
"leven": "^4.0.0",
"lscache": "^1.3.2",
@ -71,14 +71,14 @@
"nanoid": "^5.0.7",
"postcss": "^8.4.38",
"postcss-load-config": "^5.0.3",
"posthog-js": "1.128.2",
"posthog-js": "1.130.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3",
"prettier-plugin-tailwindcss": "^0.5.14",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"svelte": "^4.2.15",
"svelte-check": "^3.6.9",
"svelte-check": "^3.7.0",
"svelte-floating-ui": "^1.5.8",
"svelte-french-toast": "^1.2.0",
"svelte-loadable-store": "^2.0.1",
@ -95,6 +95,6 @@
"vitest": "^0.34.6"
},
"dependencies": {
"openai": "^4.38.2"
"openai": "^4.38.5"
}
}

9
app/src/global.d.ts vendored
View File

@ -1,9 +0,0 @@
declare type Item = import('svelte-dnd-action').Item;
declare type DndEvent<ItemType = Item> = import('svelte-dnd-action/typings').DndEvent<ItemType>;
declare namespace svelte.JSX {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> {
onconsider?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
onfinalize?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
}
}

View File

@ -0,0 +1,34 @@
import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry';
import { appAnalyticsConfirmed } from '$lib/config/appSettings';
import {
appMetricsEnabled,
appErrorReportingEnabled,
appNonAnonMetricsEnabled
} from '$lib/config/appSettings';
import posthog from 'posthog-js';
export function initAnalyticsIfEnabled() {
const analyticsConfirmed = appAnalyticsConfirmed();
analyticsConfirmed.onDisk().then((confirmed) => {
if (confirmed) {
appErrorReportingEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initSentry();
});
appMetricsEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initPostHog();
});
appNonAnonMetricsEnabled()
.onDisk()
.then((enabled) => {
enabled
? posthog.capture('nonAnonMetricsEnabled')
: posthog.capture('nonAnonMetricsDisabled');
});
}
});
}

View File

@ -7,7 +7,7 @@ export async function initPostHog() {
const [appName, appVersion] = await Promise.all([getName(), getVersion()]);
posthog.init(PUBLIC_POSTHOG_API_KEY, {
api_host: 'https://eu.posthog.com',
disable_session_recording: appName !== 'GitButler', // only record sessions in production
disable_session_recording: true,
capture_performance: false,
request_batching: true,
persistence: 'localStorage',

View File

@ -1,6 +1,7 @@
<script lang="ts">
import AnalyticsSettings from './AnalyticsSettings.svelte';
import Button from './Button.svelte';
import { initAnalyticsIfEnabled } from '$lib/analytics/analytics';
import type { Writable } from 'svelte/store';
export let analyticsConfirmed: Writable<boolean>;
@ -17,6 +18,7 @@
icon="chevron-right-small"
on:click={() => {
$analyticsConfirmed = true;
initAnalyticsIfEnabled();
}}
>
Continue

View File

@ -1,22 +1,27 @@
<script lang="ts">
import InfoMessage from './InfoMessage.svelte';
import Link from './Link.svelte';
import SectionCard from './SectionCard.svelte';
import Toggle from './Toggle.svelte';
import { appErrorReportingEnabled, appMetricsEnabled } from '$lib/config/appSettings';
import {
appErrorReportingEnabled,
appMetricsEnabled,
appNonAnonMetricsEnabled
} from '$lib/config/appSettings';
const errorReportingEnabled = appErrorReportingEnabled();
const metricsEnabled = appMetricsEnabled();
let updatedTelemetrySettings = false;
const nonAnonMetricsEnabled = appNonAnonMetricsEnabled();
function toggleErrorReporting() {
$errorReportingEnabled = !$errorReportingEnabled;
updatedTelemetrySettings = true;
}
function toggleMetrics() {
$metricsEnabled = !$metricsEnabled;
updatedTelemetrySettings = true;
}
function toggleNonAnonMetrics() {
$nonAnonMetricsEnabled = !$nonAnonMetricsEnabled;
}
</script>
@ -24,7 +29,13 @@
<div class="analytics-settings__content">
<p class="text-base-body-13 analytics-settings__text">
GitButler uses telemetry strictly to help us improve the client. We do not collect any
personal information.
personal information (<Link
target="_blank"
rel="noreferrer"
href="https://gitbutler.com/privacy"
>
privacy policy
</Link>).
</p>
<p class="text-base-body-13 analytics-settings__text">
We kindly ask you to consider keeping these settings enabled as it helps us catch issues more
@ -61,13 +72,19 @@
</svelte:fragment>
</SectionCard>
{#if updatedTelemetrySettings}
<InfoMessage>
<svelte:fragment slot="content"
>Changes will take effect on the next application start.</svelte:fragment
>
</InfoMessage>
{/if}
<SectionCard labelFor="nonAnonMetricsEnabledToggle" on:click={toggleMetrics} orientation="row">
<svelte:fragment slot="title">Non-anonymous usage metrics</svelte:fragment>
<svelte:fragment slot="caption"
>Toggle sharing of identifiable usage statistics.</svelte:fragment
>
<svelte:fragment slot="actions">
<Toggle
id="nonAnonMetricsEnabledToggle"
checked={$nonAnonMetricsEnabled}
on:change={toggleNonAnonMetrics}
/>
</svelte:fragment>
</SectionCard>
</div>
</section>

View File

@ -5,7 +5,7 @@
export let help = '';
</script>
<div class="badge text-base-10 text-semibold" use:tooltip={help}>
<div class="badge text-base-10 text-bold" use:tooltip={help}>
{count}
</div>
@ -15,12 +15,12 @@
align-items: center;
justify-content: center;
text-align: center;
height: var(--size-16);
min-width: var(--size-16);
border-radius: var(--size-16);
height: var(--size-14);
min-width: var(--size-14);
border-radius: var(--size-14);
padding: 0 var(--size-4);
color: var(--clr-scale-ntrl-100);
background-color: var(--clr-scale-ntrl-50);
background-color: var(--clr-scale-ntrl-40);
line-height: 90%;
}
</style>

View File

@ -48,7 +48,7 @@
</Button>
<div class="commits-list">
{#each base.upstreamCommits as commit}
<CommitCard {commit} commitUrl={base.commitUrl(commit.id)} />
<CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} />
{/each}
</div>
<Spacer margin={2} />
@ -62,7 +62,7 @@
Local
</h1>
{#each base.recentCommits as commit}
<CommitCard {commit} commitUrl={base.commitUrl(commit.id)} />
<CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} />
{/each}
</div>
</div>

View File

@ -102,7 +102,7 @@
}
.row_1 {
display: flex;
gap: var(--size-6);
gap: var(--size-4);
align-items: center;
color: var(--clr-scale-ntrl-10);
}

View File

@ -0,0 +1,110 @@
<script lang="ts">
import Button from './Button.svelte';
import InfoMessage from './InfoMessage.svelte';
import Select from './Select.svelte';
import { Project } from '$lib/backend/projects';
import SectionCard from '$lib/components/SectionCard.svelte';
import SelectItem from '$lib/components/SelectItem.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import { getContext, getContextStore } from '$lib/utils/context';
import { getRemoteBranches } from '$lib/vbranches/baseBranch';
import { BranchController } from '$lib/vbranches/branchController';
import { BaseBranch } from '$lib/vbranches/types';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
const baseBranch = getContextStore(BaseBranch);
const vbranchService = getContext(VirtualBranchService);
const branchController = getContext(BranchController);
const activeBranches = vbranchService.activeBranches;
let project = getContext(Project);
let selectedBranch: {
name: string;
} = {
name: $baseBranch.branchName
};
let isSwitching = false;
async function onSetBaseBranchClick() {
if (!selectedBranch) return;
// while target is setting, display loading
isSwitching = true;
await branchController
.setTarget(selectedBranch.name)
.catch((err) => {
console.log('error', err);
})
.finally(() => {
isSwitching = false;
});
}
$: console.log('selectedBranch', selectedBranch);
</script>
{#if $activeBranches}
<SectionCard>
<svelte:fragment slot="title">Current base branch</svelte:fragment>
<form class="form-wrapper">
{#await getRemoteBranches(project.id)}
loading remote branches...
{:then remoteBranches}
<div class="fields-wrapper">
<Select
items={remoteBranches}
bind:value={selectedBranch}
itemId="name"
labelId="name"
selectedItemId={$baseBranch.branchName}
wide
disabled={$activeBranches.length > 0}
>
<SelectItem slot="template" let:item let:selected {selected}>
{item.name}
</SelectItem>
</Select>
<Button
size="cta"
style="ghost"
kind="solid"
on:click={onSetBaseBranchClick}
id="set-base-branch"
loading={isSwitching}
disabled={selectedBranch.name === $baseBranch.branchName}
>
Switch branch
</Button>
</div>
{#if $activeBranches.length > 0}
<InfoMessage filled outlined={false}>
<svelte:fragment slot="content">
You have {$activeBranches.length === 1
? '1 active branch'
: `${$activeBranches.length} active branches`} in your workspace. Please clear the workspace
before switching the base branch.
</svelte:fragment>
</InfoMessage>
{/if}
{/await}
</form>
</SectionCard>
<Spacer />
{/if}
<style>
.fields-wrapper {
display: flex;
gap: var(--size-8);
}
.form-wrapper {
display: flex;
flex-direction: column;
gap: var(--size-16);
}
</style>

View File

@ -16,7 +16,6 @@
const branchController = getContext(BranchController);
const baseBranch = getContextStore(BaseBranch);
const project = getContext(Project);
const activeBranchesError = vbranchService.activeBranchesError;
const activeBranches = vbranchService.activeBranches;
@ -27,12 +26,16 @@
let dragHandle: any;
let clone: any;
let isSwitching = false;
</script>
{#if $activeBranchesError}
<div class="p-4" data-tauri-drag-region>Something went wrong...</div>
{:else if !$activeBranches}
<FullviewLoading />
{:else if isSwitching}
<div class="middle-message">switching base branch...</div>
{:else}
<div
class="board"
@ -202,6 +205,12 @@
height: 100%;
}
.spacer {
display: flex;
flex-direction: column;
gap: var(--size-16);
}
.branch {
height: 100%;
}
@ -255,6 +264,38 @@
padding-left: var(--size-4);
}
.branch-switcher {
margin-top: 8px;
padding: 8px;
background-color: var(--clr-bg-2);
border-width: 1px;
border-color: var(--clr-border-2);
border-radius: 4px;
}
.branch-display {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
margin-bottom: 2px;
}
.branch-name {
font-weight: 600;
font-family: monospace;
}
.middle-message {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
font-size: 2em;
color: #888888;
}
.empty-board__image-frame {
flex-shrink: 0;
position: relative;

View File

@ -41,7 +41,7 @@
flex: 1;
display: flex;
flex-direction: column;
background: var(--clr-theme-container-light);
background: var(--clr-bg-1);
border-radius: var(--radius-m) var(--radius-m) 0 0;
padding: 0 var(--size-14) var(--size-14);
}

View File

@ -88,7 +88,7 @@
branchName={branch.upstreamName ?? branchName}
{isUnapplied}
{hasIntegratedCommits}
remoteExists={!!branch.upstreamName}
remoteExists={!!branch.upstream}
isLaneCollapsed={$isLaneCollapsed}
/>
{#if branch.selectedForChanges}
@ -118,7 +118,7 @@
branchName={branch.upstreamName ?? branchName}
{isUnapplied}
{hasIntegratedCommits}
remoteExists={!!branch.upstreamName}
remoteExists={!!branch.upstream}
isLaneCollapsed={$isLaneCollapsed}
/>

View File

@ -61,11 +61,9 @@
border: 1px solid transparent;
}
.branch-name-mesure-el {
pointer-events: auto;
pointer-events: none;
visibility: hidden;
border: 2px solid transparent;
top: -9999px;
left: -9999px;
color: black;
position: fixed;
display: inline-block;

View File

@ -42,6 +42,10 @@
async function setAIConfigurationValid(user: User | undefined) {
aiConfigurationValid = await aiService.validateConfiguration(user?.access_token);
}
function close() {
visible = false;
}
</script>
{#if visible}
@ -52,7 +56,7 @@
label="Unapply"
on:click={() => {
if (branch.id) branchController.unapplyBranch(branch.id);
visible = false;
close();
}}
/>
{/if}
@ -69,7 +73,7 @@
} else {
deleteBranchModal.show(branch);
}
visible = false;
close();
}}
/>
@ -77,7 +81,7 @@
label="Generate branch name"
on:click={() => {
dispatch('action', 'generate-branch-name');
visible = false;
close();
}}
disabled={isUnapplied ||
!($aiGenEnabled && aiConfigurationValid) ||
@ -91,7 +95,7 @@
disabled={isUnapplied || hasIntegratedCommits}
on:click={() => {
newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || '';
visible = false;
close();
renameRemoteModal.show(branch);
}}
/>
@ -101,7 +105,7 @@
label="Create branch to the left"
on:click={() => {
branchController.createBranch({ order: branch.order });
visible = false;
close();
}}
/>
@ -109,7 +113,7 @@
label="Create branch to the right"
on:click={() => {
branchController.createBranch({ order: branch.order + 1 });
visible = false;
close();
}}
/>
</ContextMenuSection>
@ -122,7 +126,6 @@
on:submit={() => {
branchController.updateBranchRemoteName(branch.id, newRemoteName);
renameRemoteModal.close();
visible = false;
}}
>
<svelte:fragment>
@ -136,9 +139,9 @@
</Modal>
<Modal width="small" title="Delete branch" bind:this={deleteBranchModal} let:item={branch}>
<div>
<svelte:fragment>
Deleting <code class="code-string">{branch.name}</code> cannot be undone.
</div>
</svelte:fragment>
<svelte:fragment slot="controls" let:close let:item={branch}>
<Button style="ghost" kind="solid" on:click={close}>Cancel</Button>
<Button
@ -146,7 +149,7 @@
kind="solid"
on:click={async () => {
await branchController.deleteBranch(branch.id);
visible = false;
deleteBranchModal.close();
}}
>
Delete

View File

@ -12,7 +12,7 @@
export let id: string | undefined = undefined;
export let loading = false;
export let tabindex: number | undefined = undefined;
export let type: 'submit' | 'reset' | undefined = undefined;
export let type: 'submit' | 'reset' | 'button' | undefined = undefined;
// Layout props
export let reversedDirection: boolean = false;
export let width: number | undefined = undefined;
@ -102,39 +102,7 @@
&:disabled {
cursor: default;
pointer-events: none;
opacity: 0.7;
&.neutral.solid,
&.pop.solid,
&.success.solid,
&.error.solid,
&.warning.solid,
&.purple.solid {
--btn-clr: var(--clr-text-2);
--btn-bg: var(--clr-bg-3);
& .badge {
--btn-bg: var(--clr-scale-ntrl-100);
}
}
&.neutral.soft,
&.pop.soft,
&.success.soft,
&.error.soft,
&.warning.soft,
&.purple.soft {
--btn-clr: var(--clr-text-2);
--btn-bg: var(--clr-bg-3);
}
&.ghost {
--btn-clr: var(--clr-text-2);
}
&.ghost.solid {
border-color: var(--clr-bg-3);
}
opacity: 0.5;
}
&.wide {
display: flex;
@ -238,7 +206,7 @@
.pop {
&.soft {
--btn-clr: var(--clr-theme-pop-on-container);
--btn-clr: var(--clr-theme-pop-on-soft);
--btn-bg: var(--clr-scale-pop-80);
/* if button */
&:not(.not-clickable, &:disabled):hover {
@ -261,7 +229,7 @@
.success {
&.soft {
--btn-clr: var(--clr-theme-succ-on-container);
--btn-clr: var(--clr-theme-succ-on-soft);
--btn-bg: var(--clr-scale-succ-80);
/* if button */
&:not(.not-clickable, &:disabled):hover {
@ -284,7 +252,7 @@
.error {
&.soft {
--btn-clr: var(--clr-theme-err-on-container);
--btn-clr: var(--clr-theme-err-on-soft);
--btn-bg: var(--clr-scale-err-80);
/* if button */
&:not(.not-clickable, &:disabled):hover {
@ -307,7 +275,7 @@
.warning {
&.soft {
--btn-clr: var(--clr-theme-warn-on-container);
--btn-clr: var(--clr-theme-warn-on-soft);
--btn-bg: var(--clr-scale-warn-80);
/* if button */
&:not(.not-clickable, &:disabled):hover {
@ -330,7 +298,7 @@
.purple {
&.soft {
--btn-clr: var(--clr-theme-purp-on-container);
--btn-clr: var(--clr-theme-purp-on-soft);
--btn-bg: var(--clr-scale-purp-80);
/* if button */
&:not(.not-clickable, &:disabled):hover {

View File

@ -1,9 +1,13 @@
<script lang="ts">
import BranchFilesList from './BranchFilesList.svelte';
import { Project } from '$lib/backend/projects';
import Button from '$lib/components/Button.svelte';
import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import Modal from '$lib/components/Modal.svelte';
import Tag from '$lib/components/Tag.svelte';
import TimeAgo from '$lib/components/TimeAgo.svelte';
import { persistedCommitMessage } from '$lib/config/config';
import { featureAdvancedCommitOperations } from '$lib/config/uiFeatureFlags';
import { draggable } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import { getContext, getContextStore } from '$lib/utils/context';
@ -26,6 +30,7 @@
const project = getContext(Project);
const selectedFiles = getSelectedFiles();
const fileIdSelection = getContext(FileIdSelection);
const advancedCommitOperations = featureAdvancedCommitOperations();
const commitStore = createCommitStore(commit);
$: commitStore.set(commit);
@ -47,6 +52,7 @@
function toggleFiles() {
showFiles = !showFiles;
if (showFiles) loadFiles();
}
@ -56,22 +62,76 @@
}
}
function resetHeadCommit() {
function undoCommit(commit: Commit | RemoteCommit) {
if (!branch || !$baseBranch) {
console.error('Unable to reset head commit');
console.error('Unable to undo commit');
return;
}
if (branch.commits.length > 1) {
branchController.resetBranch(branch.id, branch.commits[1].id);
} else if (branch.commits.length === 1 && $baseBranch) {
branchController.resetBranch(branch.id, $baseBranch.baseSha);
}
branchController.undoCommit(branch.id, commit.id);
}
const isUndoable = isHeadCommit && !isUnapplied;
function insertBlankCommit(commit: Commit | RemoteCommit, offset: number) {
if (!branch || !$baseBranch) {
console.error('Unable to insert commit');
return;
}
branchController.insertBlankCommit(branch.id, commit.id, offset);
}
function reorderCommit(commit: Commit | RemoteCommit, offset: number) {
if (!branch || !$baseBranch) {
console.error('Unable to move commit');
return;
}
branchController.reorderCommit(branch.id, commit.id, offset);
}
let isUndoable = false;
$: if ($advancedCommitOperations) {
isUndoable = !!branch?.active && commit instanceof Commit;
} else {
isUndoable = isHeadCommit;
}
const hasCommitUrl = !commit.isLocal && commitUrl;
let commitMessageModal: Modal;
let commitMessageValid = false;
let description = '';
function openCommitMessageModal(e: Event) {
e.stopPropagation();
description = commit.description;
commitMessageModal.show();
}
function submitCommitMessageModal() {
commit.description = description;
if (branch) {
branchController.updateCommitMessage(branch.id, commit.id, description);
}
commitMessageModal.close();
}
</script>
<Modal bind:this={commitMessageModal}>
<CommitMessageInput bind:commitMessage={description} bind:valid={commitMessageValid} />
<svelte:fragment slot="controls">
<Button style="ghost" kind="solid" on:click={() => commitMessageModal.close()}>Cancel</Button>
<Button
style="pop"
kind="solid"
grow
disabled={!commitMessageValid}
on:click={submitCommitMessageModal}>Submit</Button
>
</svelte:fragment>
</Modal>
<div
use:draggable={commit instanceof Commit
? {
@ -83,30 +143,58 @@
>
<div class="commit__header" on:click={toggleFiles} on:keyup={onKeyup} role="button" tabindex="0">
<div class="commit__message">
{#if $advancedCommitOperations}
{#if !showFiles}
<div class="commit__id">
<code>{commit.id.substring(0, 6)}</code>
</div>
{/if}
{/if}
<div class="commit__row">
<span class="commit__title text-semibold text-base-12" class:truncate={!showFiles}>
{commit.descriptionTitle}
</span>
{#if isUndoable && !showFiles}
<Tag
style="ghost"
kind="solid"
icon="undo-small"
clickable
on:click={(e) => {
currentCommitMessage.set(commit.description);
e.stopPropagation();
resetHeadCommit();
}}>Undo</Tag
>
{#if isUndoable}
{#if commit.descriptionTitle}
<span class="commit__title text-semibold text-base-12" class:truncate={!showFiles}>
{commit.descriptionTitle}
</span>
{:else}
<span
class="commit__title_no_desc text-base-12 text-zinc-400"
class:truncate={!showFiles}
>
<i>empty commit message</i>
</span>
{/if}
{#if !showFiles}
<Tag
style="ghost"
kind="solid"
icon="undo-small"
clickable
on:click={(e) => {
currentCommitMessage.set(commit.description);
e.stopPropagation();
undoCommit(commit);
}}>Undo</Tag
>
{/if}
{:else}
<span class="commit__title text-base-12" class:truncate={!showFiles}>
{commit.descriptionTitle}
</span>
{/if}
</div>
{#if showFiles && commit.descriptionBody}
<div class="commit__row" transition:slide={{ duration: 100 }}>
<span class="commit__body text-base-body-12">
{commit.descriptionBody}
</span>
</div>
{#if showFiles}
{#if commit.descriptionBody}
<div class="commit__row" transition:slide={{ duration: 100 }}>
<span class="commit__body text-base-body-12">
{commit.descriptionBody}
</span>
</div>
{/if}
{#if $advancedCommitOperations && isUndoable}
<Tag clickable on:click={openCommitMessageModal}>Edit</Tag>
{/if}
{/if}
</div>
<div class="commit__row">
@ -130,12 +218,50 @@
{#if showFiles}
<div class="files-container" transition:slide={{ duration: 100 }}>
<BranchFilesList {files} {isUnapplied} readonly />
<BranchFilesList {files} {isUnapplied} />
</div>
{#if hasCommitUrl || isUndoable}
<div class="files__footer">
{#if isUndoable}
{#if $advancedCommitOperations}
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
reorderCommit(commit, -1);
}}>Move Up</Tag
>
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
reorderCommit(commit, 1);
}}>Move Down</Tag
>
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
insertBlankCommit(commit, -1);
}}>Add Before</Tag
>
<Tag
style="ghost"
kind="solid"
clickable
on:click={(e) => {
e.stopPropagation();
insertBlankCommit(commit, 1);
}}>Add After</Tag
>
{/if}
<Tag
style="ghost"
kind="solid"
@ -144,7 +270,7 @@
on:click={(e) => {
currentCommitMessage.set(commit.description);
e.stopPropagation();
resetHeadCommit();
undoCommit(commit);
}}>Undo</Tag
>
{/if}
@ -221,6 +347,11 @@
color: var(--clr-scale-ntrl-0);
width: 100%;
}
.commit__title_no_desc {
flex: 1;
display: block;
width: 100%;
}
.commit__body {
flex: 1;
@ -237,6 +368,21 @@
gap: var(--size-8);
}
.commit__id {
display: flex;
align-items: center;
justify-content: center;
margin-top: -14px;
}
.commit__id > code {
background-color: #eeeeee;
padding: 1px 12px;
color: #888888;
font-size: x-small;
border-radius: 0px 0px 6px 6px;
margin-bottom: -8px;
}
.commit__author {
display: block;
flex: 1;
@ -268,6 +414,7 @@
.files__footer {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--size-8);
padding: var(--size-14);
background-color: var(--clr-bg-1);

View File

@ -1,80 +1,31 @@
<script lang="ts">
import Button from './Button.svelte';
import { AIService } from '$lib/ai/service';
import Checkbox from '$lib/components/Checkbox.svelte';
import DropDownButton from '$lib/components/DropDownButton.svelte';
import Icon from '$lib/components/Icon.svelte';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import {
projectAiGenEnabled,
projectCommitGenerationExtraConcise,
projectCommitGenerationUseEmojis,
projectRunCommitHooks,
persistedCommitMessage
} from '$lib/config/config';
import { showError } from '$lib/notifications/toasts';
import { User } from '$lib/stores/user';
import { splitMessage } from '$lib/utils/commitMessage';
import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import { projectRunCommitHooks, persistedCommitMessage } from '$lib/config/config';
import { getContext, getContextStore } from '$lib/utils/context';
import { tooltip } from '$lib/utils/tooltip';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { BranchController } from '$lib/vbranches/branchController';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, type LocalFile } from '$lib/vbranches/types';
import { createEventDispatcher, onMount } from 'svelte';
import { Branch } from '$lib/vbranches/types';
import { quintOut } from 'svelte/easing';
import { fly, slide } from 'svelte/transition';
import { slide } from 'svelte/transition';
import type { Writable } from 'svelte/store';
const aiService = getContext(AIService);
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
}>();
export let projectId: string;
export let expanded: Writable<boolean>;
const branchController = getContext(BranchController);
const selectedOwnership = getContextStore(Ownership);
const branch = getContextStore(Branch);
const user = getContextStore(User);
const aiGenEnabled = projectAiGenEnabled(projectId);
const runCommitHooks = projectRunCommitHooks(projectId);
const commitMessage = persistedCommitMessage(projectId, $branch.id);
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(projectId);
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(projectId);
let isCommitting = false;
let aiLoading = false;
let contextMenu: ContextMenu;
let titleTextArea: HTMLTextAreaElement;
let descriptionTextArea: HTMLTextAreaElement;
$: ({ title, description } = splitMessage($commitMessage));
$: if ($commitMessage) updateHeights();
function concatMessage(title: string, description: string) {
return `${title}\n\n${description}`;
}
function focusTextareaOnMount(el: HTMLTextAreaElement) {
el.focus();
}
function updateHeights() {
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
let commitMessageValid = false;
async function commit() {
const message = concatMessage(title, description);
const message = $commitMessage;
isCommitting = true;
try {
await branchController.commitBranch(
@ -88,158 +39,16 @@
isCommitting = false;
}
}
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
// or the user has to manually trigger the name generation from the meatball menu
// This saves people this extra click
if ($branch.name.toLowerCase().includes('virtual branch')) {
dispatch('action', 'generate-branch-name');
}
aiLoading = true;
try {
const generatedMessage = await aiService.summarizeCommit({
hunks,
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise,
userToken: $user?.access_token
});
if (generatedMessage) {
$commitMessage = generatedMessage;
} else {
throw new Error('Prompt generated no response');
}
} catch (e: any) {
showError('Failed to generate commit message', e);
} finally {
aiLoading = false;
}
setTimeout(() => {
updateHeights();
descriptionTextArea.focus();
}, 0);
}
let aiConfigurationValid = false;
onMount(async () => {
aiConfigurationValid = await aiService.validateConfiguration($user?.access_token);
});
</script>
<div class="commit-box" class:commit-box__expanded={$expanded}>
{#if $expanded}
<div class="commit-box__expander" transition:slide={{ duration: 150, easing: quintOut }}>
<div class="commit-box__textarea-wrapper text-input">
<textarea
value={title}
placeholder="Commit summary"
disabled={aiLoading}
class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
useAutoHeight(titleTextArea);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(e.currentTarget.value, description);
}}
on:keydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
descriptionTextArea.focus();
}
}}
/>
{#if title.length > 0 || description}
<textarea
value={description}
disabled={aiLoading}
placeholder="Commit description (optional)"
class="text-base-body-13 commit-box__textarea commit-box__textarea__description"
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(title, e.currentTarget.value);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
useAutoHeight(e.currentTarget);
} else if (e.key == 'a' && (e.metaKey || e.ctrlKey) && value.length == 0) {
// select previous textarea on cmd+a if this textarea is empty
e.preventDefault();
titleTextArea.select();
}
}}
/>
{/if}
{#if title.length > 50}
<div
transition:fly={{ y: 2, duration: 150 }}
class="commit-box__textarea-tooltip"
use:tooltip={{
text: '50 characters or less is best. Extra info can be added in the description.',
delay: 200
}}
>
<Icon name="blitz" />
</div>
{/if}
<div
class="commit-box__texarea-actions"
use:tooltip={$aiGenEnabled && aiConfigurationValid
? ''
: 'You must be logged in or have provided your own API key and have summary generation enabled to use this feature'}
>
<DropDownButton
style="ghost"
kind="solid"
icon="ai-small"
disabled={!($aiGenEnabled && aiConfigurationValid)}
loading={aiLoading}
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<ContextMenuSection>
<ContextMenuItem
label="Extra concise"
on:click={() => ($commitGenerationExtraConcise = !$commitGenerationExtraConcise)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationExtraConcise} />
</ContextMenuItem>
<ContextMenuItem
label="Use emojis 😎"
on:click={() => ($commitGenerationUseEmojis = !$commitGenerationUseEmojis)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationUseEmojis} />
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
</DropDownButton>
</div>
</div>
<CommitMessageInput
bind:commitMessage={$commitMessage}
bind:valid={commitMessageValid}
{commit}
/>
</div>
{/if}
<div class="actions">
@ -260,7 +69,7 @@
kind="solid"
grow
loading={isCommitting}
disabled={(isCommitting || !title || $selectedOwnership.isEmpty()) && $expanded}
disabled={(isCommitting || !commitMessageValid || $selectedOwnership.isEmpty()) && $expanded}
id="commit-to-branch"
on:click={() => {
if ($expanded) {
@ -292,57 +101,6 @@
margin-bottom: var(--size-12);
}
.commit-box__textarea-wrapper {
display: flex;
position: relative;
padding: 0 0 var(--size-48);
flex-direction: column;
gap: var(--size-4);
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-16);
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
display: flex;
bottom: var(--size-12);
left: var(--size-12);
padding: var(--size-2);
border-radius: 100%;
background: var(--clr-bg-2);
color: var(--clr-scale-ntrl-40);
}
.commit-box__textarea__title {
padding: var(--size-12) var(--size-12) 0 var(--size-12);
}
.commit-box__textarea__description {
padding: 0 var(--size-12) 0 var(--size-12);
}
.commit-box__texarea-actions {
position: absolute;
display: flex;
right: var(--size-12);
bottom: var(--size-12);
}
.actions {
display: flex;
justify-content: right;

View File

@ -6,8 +6,15 @@
import { dropzone } from '$lib/dragging/dropzone';
import { getContext, getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import { filesToOwnership } from '$lib/vbranches/ownership';
import { RemoteCommit, Branch, type Commit, BaseBranch } from '$lib/vbranches/types';
import { filesToOwnership, filesToSimpleOwnership } from '$lib/vbranches/ownership';
import {
RemoteCommit,
Branch,
type Commit,
BaseBranch,
LocalFile,
RemoteFile
} from '$lib/vbranches/types';
export let commit: Commit | RemoteCommit;
export let isHeadCommit: boolean;
@ -32,11 +39,6 @@
return false;
}
// only allow to amend the head commit
if (commit.id != $branch.commits.at(0)?.id) {
return false;
}
if (data instanceof DraggableHunk && data.branchId == $branch.id) {
return true;
} else if (data instanceof DraggableFile && data.branchId == $branch.id) {
@ -47,14 +49,25 @@
};
}
function onAmend(data: DraggableFile | DraggableHunk) {
if (data instanceof DraggableHunk) {
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.amendBranch($branch.id, newOwnership);
} else if (data instanceof DraggableFile) {
const newOwnership = filesToOwnership(data.files);
branchController.amendBranch($branch.id, newOwnership);
}
function onAmend(commit: Commit | RemoteCommit) {
return (data: any) => {
if (data instanceof DraggableHunk) {
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.amendBranch($branch.id, commit.id, newOwnership);
} else if (data instanceof DraggableFile) {
if (data.file instanceof LocalFile) {
// this is an uncommitted file change being amended to a previous commit
const newOwnership = filesToOwnership(data.files);
branchController.amendBranch($branch.id, commit.id, newOwnership);
} else if (data.file instanceof RemoteFile) {
// this is a file from a commit, rather than an uncommitted file
const newOwnership = filesToSimpleOwnership(data.files);
if (data.commit) {
branchController.moveCommitFile($branch.id, data.commit.id, commit.id, newOwnership);
}
}
}
};
}
function acceptSquash(commit: Commit | RemoteCommit) {
@ -104,7 +117,7 @@
active: 'amend-dz-active',
hover: 'amend-dz-hover',
accepts: acceptAmend(commit),
onDrop: onAmend
onDrop: onAmend(commit)
}}
use:dropzone={{
active: 'squash-dz-active',

View File

@ -0,0 +1,268 @@
<script lang="ts">
import { AIService } from '$lib/ai/service';
import { Project } from '$lib/backend/projects';
import Checkbox from '$lib/components/Checkbox.svelte';
import DropDownButton from '$lib/components/DropDownButton.svelte';
import Icon from '$lib/components/Icon.svelte';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import {
projectAiGenEnabled,
projectCommitGenerationExtraConcise,
projectCommitGenerationUseEmojis
} from '$lib/config/config';
import { showError } from '$lib/notifications/toasts';
import { User } from '$lib/stores/user';
import { splitMessage } from '$lib/utils/commitMessage';
import { getContext, getContextStore } from '$lib/utils/context';
import { tooltip } from '$lib/utils/tooltip';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, LocalFile } from '$lib/vbranches/types';
import { createEventDispatcher, onMount } from 'svelte';
import { fly } from 'svelte/transition';
export let commitMessage: string;
export let valid: boolean = false;
export let commit: (() => void) | undefined = undefined;
const user = getContextStore(User);
const selectedOwnership = getContextStore(Ownership);
const aiService = getContext(AIService);
const branch = getContextStore(Branch);
const project = getContext(Project);
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
}>();
const aiGenEnabled = projectAiGenEnabled(project.id);
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(project.id);
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(project.id);
let aiLoading = false;
let aiConfigurationValid = false;
let contextMenu: ContextMenu;
let titleTextArea: HTMLTextAreaElement;
let descriptionTextArea: HTMLTextAreaElement;
$: ({ title, description } = splitMessage(commitMessage));
$: if (commitMessage) updateHeights();
$: valid = !!title;
function concatMessage(title: string, description: string) {
return `${title}\n\n${description}`;
}
function updateHeights() {
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
function focusTextareaOnMount(el: HTMLTextAreaElement) {
el.focus();
}
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
// or the user has to manually trigger the name generation from the meatball menu
// This saves people this extra click
if ($branch.name.toLowerCase().includes('virtual branch')) {
dispatch('action', 'generate-branch-name');
}
aiLoading = true;
try {
const generatedMessage = await aiService.summarizeCommit({
hunks,
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise,
userToken: $user?.access_token
});
if (generatedMessage) {
commitMessage = generatedMessage;
} else {
throw new Error('Prompt generated no response');
}
} catch (e: any) {
showError('Failed to generate commit message', e);
} finally {
aiLoading = false;
}
setTimeout(() => {
updateHeights();
descriptionTextArea.focus();
}, 0);
}
onMount(async () => {
aiConfigurationValid = await aiService.validateConfiguration($user?.access_token);
});
</script>
<div class="commit-box__textarea-wrapper text-input">
<textarea
value={title}
placeholder="Commit summary"
disabled={aiLoading}
class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
useAutoHeight(titleTextArea);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(e.currentTarget.value, description);
}}
on:keydown={(e) => {
if (commit && (e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
descriptionTextArea.focus();
}
}}
/>
{#if title.length > 0 || description}
<textarea
value={description}
disabled={aiLoading}
placeholder="Commit description (optional)"
class="text-base-body-13 commit-box__textarea commit-box__textarea__description"
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(title, e.currentTarget.value);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
useAutoHeight(e.currentTarget);
} else if (e.key == 'a' && (e.metaKey || e.ctrlKey) && value.length == 0) {
// select previous textarea on cmd+a if this textarea is empty
e.preventDefault();
titleTextArea.select();
}
}}
/>
{/if}
{#if title.length > 50}
<div
transition:fly={{ y: 2, duration: 150 }}
class="commit-box__textarea-tooltip"
use:tooltip={{
text: '50 characters or less is best. Extra info can be added in the description.',
delay: 200
}}
>
<Icon name="blitz" />
</div>
{/if}
<div
class="commit-box__texarea-actions"
use:tooltip={$aiGenEnabled && aiConfigurationValid
? ''
: 'You must be logged in or have provided your own API key and have summary generation enabled to use this feature'}
>
<DropDownButton
style="ghost"
kind="solid"
icon="ai-small"
disabled={!($aiGenEnabled && aiConfigurationValid)}
loading={aiLoading}
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<ContextMenuSection>
<ContextMenuItem
label="Extra concise"
on:click={() => ($commitGenerationExtraConcise = !$commitGenerationExtraConcise)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationExtraConcise} />
</ContextMenuItem>
<ContextMenuItem
label="Use emojis 😎"
on:click={() => ($commitGenerationUseEmojis = !$commitGenerationUseEmojis)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationUseEmojis} />
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
</DropDownButton>
</div>
</div>
<style lang="postcss">
.commit-box__textarea-wrapper {
display: flex;
position: relative;
padding: 0 0 var(--size-48);
flex-direction: column;
gap: var(--size-4);
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-16);
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
display: flex;
bottom: var(--size-12);
left: var(--size-12);
padding: var(--size-2);
border-radius: 100%;
background: var(--clr-bg-2);
color: var(--clr-scale-ntrl-40);
}
.commit-box__textarea__title {
padding: var(--size-12) var(--size-12) 0 var(--size-12);
}
.commit-box__textarea__description {
padding: 0 var(--size-12) 0 var(--size-12);
}
.commit-box__texarea-actions {
position: absolute;
display: flex;
right: var(--size-12);
bottom: var(--size-12);
}
</style>

View File

@ -4,7 +4,7 @@
import LargeDiffMessage from './LargeDiffMessage.svelte';
import { computeAddedRemovedByHunk } from '$lib/utils/metrics';
import { tooltip } from '$lib/utils/tooltip';
import { getLocalCommits } from '$lib/vbranches/contexts';
import { getLocalCommits, getRemoteCommits } from '$lib/vbranches/contexts';
import { getLockText } from '$lib/vbranches/tooltip';
import type { HunkSection, ContentSection } from '$lib/utils/fileSections';
@ -21,6 +21,9 @@
$: minWidth = getGutterMinWidth(maxLineNumber);
const localCommits = isFileLocked ? getLocalCommits() : undefined;
const remoteCommits = isFileLocked ? getRemoteCommits() : undefined;
const commits = isFileLocked ? ($localCommits || []).concat($remoteCommits || []) : undefined;
let alwaysShow = false;
function getGutterMinWidth(max: number) {
@ -52,10 +55,10 @@
<div class="indicators text-base-11">
<span class="added">+{added}</span>
<span class="removed">-{removed}</span>
{#if section.hunk.lockedTo && $localCommits}
{#if section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
<div
use:tooltip={{
text: getLockText(section.hunk.lockedTo, $localCommits),
text: getLockText(section.hunk.lockedTo, commits),
delay: 500
}}
>

View File

@ -111,7 +111,7 @@
}
}}
use:draggable={{
data: new DraggableFile($branch?.id || '', file, selectedFiles),
data: new DraggableFile($branch?.id || '', file, $commit, selectedFiles),
disabled: readonly || isUnapplied,
viewportId: 'board-viewport',
selector: '.selected-draggable'

View File

@ -0,0 +1,90 @@
<script lang="ts">
import Button from './Button.svelte';
import { invoke } from '$lib/backend/ipc';
import { getContext } from '$lib/utils/context';
import { toHumanReadableTime } from '$lib/utils/time';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { onMount } from 'svelte';
export let projectId: string;
const snapshotsLimit = 30;
const vbranchService = getContext(VirtualBranchService);
vbranchService.activeBranches.subscribe(() => {
listSnapshots(projectId, snapshotsLimit);
});
type Trailer = {
key: string;
value: string;
};
type SnapshotDetails = {
title: string;
operation: string;
body: string | undefined;
trailers: Trailer[];
};
type Snapshot = {
id: string;
details: SnapshotDetails | undefined;
createdAt: number;
};
let snapshots: Snapshot[] = [];
async function listSnapshots(projectId: string, limit: number) {
const resp = await invoke<Snapshot[]>('list_snapshots', {
projectId: projectId,
limit: limit
});
console.log(resp);
snapshots = resp;
}
async function restoreSnapshot(projectId: string, sha: string) {
const resp = await invoke<string>('restore_snapshot', {
projectId: projectId,
sha: sha
});
console.log(resp);
}
onMount(async () => {
listSnapshots(projectId, snapshotsLimit);
});
</script>
<div class="container">
{#each snapshots as entry, idx}
<div class="card">
<div class="entry">
<div>
{entry.details?.operation}
</div>
<div>
<span>
{toHumanReadableTime(entry.createdAt)}
</span>
{#if idx != 0}
<Button
style="pop"
size="tag"
icon="undo-small"
on:click={async () => await restoreSnapshot(projectId, entry.id)}>restore</Button
>
{/if}
</div>
</div>
</div>
{/each}
</div>
<style>
.container {
width: 50rem;
padding: 0.5rem;
border-left-width: 1px;
overflow-y: auto;
}
.entry {
flex: auto;
flex-direction: column;
}
</style>

View File

@ -158,19 +158,19 @@
}
&.error {
background-color: var(--clr-theme-err-container);
background-color: var(--clr-theme-err-bg);
}
&.pop {
background-color: var(--clr-theme-pop-container);
background-color: var(--clr-theme-pop-bg);
}
&.warning {
background-color: var(--clr-theme-warn-container);
background-color: var(--clr-theme-warn-bg);
}
&.success {
background-color: var(--clr-theme-succ-container);
background-color: var(--clr-theme-succ-bg);
}
}

View File

@ -1,59 +1,102 @@
<script lang="ts">
import Overlay from './Overlay.svelte';
import Icon from '$lib/components/Icon.svelte';
import { onMount } from 'svelte';
import OutClick from 'svelte-outclick';
import type iconsJson from '$lib/icons/icons.json';
export function show(newItem?: any) {
item = newItem;
modal.show();
}
export function close() {
item = undefined;
modal.close();
}
let dialog: HTMLDialogElement;
let item: any;
let open = false;
export let width: 'default' | 'small' | 'large' = 'default';
export let title: string | undefined = undefined;
export let icon: keyof typeof iconsJson | undefined = undefined;
export let hoverText: string | undefined = undefined;
let item: any;
let modal: Overlay;
export function show(newItem?: any) {
item = newItem;
dialog.showModal();
open = true;
}
export function close() {
item = undefined;
dialog.close();
open = false;
}
onMount(() => {
document.body.appendChild(dialog);
});
</script>
<Overlay bind:this={modal} let:close on:close {width}>
<form on:submit>
{#if title}
<div class="modal__header">
<div class="modal__header__content" class:adjust-header={$$slots.header_controls}>
{#if icon}
<Icon name={icon} />
<dialog
class="dialog-wrap"
class:s-default={width == 'default'}
class:s-small={width == 'small'}
class:s-large={width == 'large'}
bind:this={dialog}
on:close={close}
>
{#if open}
<OutClick on:outclick={close}>
<div class="dialog">
<form class="modal-content" on:submit>
{#if title}
<div class="modal__header">
{#if icon}
<Icon name={icon} />
{/if}
<h2 class="text-base-14 text-semibold">
{title}
</h2>
</div>
{/if}
<h2 class="text-base-14 text-semibold" title={hoverText}>
{title}
</h2>
</div>
{#if $$slots.header_controls}
<div class="modal__header__actions">
<slot name="header_controls" />
<div class="modal__body custom-scrollbar">
<slot {item} {close} />
</div>
{/if}
</div>
{/if}
<div class="modal__body custom-scrollbar">
<slot {item} {close} />
</div>
{#if $$slots.controls}
<div class="modal__footer">
<slot name="controls" {item} {close} />
{#if $$slots.controls}
<div class="modal__footer">
<slot name="controls" {item} {close} />
</div>
{/if}
</form>
</div>
{/if}
</form>
</Overlay>
</OutClick>
{/if}
</dialog>
<style lang="postcss">
.dialog-wrap {
position: relative;
width: 100%;
max-height: calc(100vh - 5rem);
border-radius: var(--radius-l);
background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
box-shadow: var(--fx-shadow-l);
}
.dialog {
display: flex;
flex-direction: column;
}
/* modifiers */
.s-large {
max-width: calc(var(--size-64) * 13);
}
.s-default {
max-width: calc(var(--size-64) * 9);
}
.s-small {
max-width: calc(var(--size-64) * 6);
}
.modal__header {
display: flex;
padding: var(--size-16);
@ -61,17 +104,6 @@
border-bottom: 1px solid var(--clr-border-2);
}
.modal__header__content {
display: flex;
gap: var(--size-8);
flex: 1;
}
.modal__header__actions {
display: flex;
gap: var(--size-8);
}
.modal__body {
overflow: auto;
padding: var(--size-16);
@ -86,8 +118,4 @@
border-top: 1px solid var(--clr-border-2);
background-color: var(--clr-bg-1);
}
.adjust-header {
margin-top: var(--size-6);
}
</style>

View File

@ -1,69 +0,0 @@
<script lang="ts">
import OutClick from 'svelte-outclick';
let dialog: HTMLDialogElement;
let open = false;
export let width: 'default' | 'small' | 'large' = 'default';
export function show() {
if (open) return;
dialog.showModal();
open = true;
}
export function close() {
if (!open) return;
dialog.close();
open = false;
}
</script>
<dialog
class="dialog"
class:open-modal={open}
class:s-default={width == 'default'}
class:s-small={width == 'small'}
class:s-large={width == 'large'}
bind:this={dialog}
on:close={close}
on:close
>
{#if open}
<OutClick on:outclick={close}>
<slot {close} isOpen={open} />
</OutClick>
{/if}
</dialog>
<style lang="postcss">
.dialog {
flex-direction: column;
position: relative;
width: 100%;
max-height: calc(100vh - 5rem);
border-radius: var(--radius-l);
background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
box-shadow: var(--fx-shadow-l);
}
/* modifiers */
.s-large {
max-width: calc(var(--size-64) * 13);
}
.s-default {
max-width: calc(var(--size-64) * 9);
}
.s-small {
max-width: calc(var(--size-64) * 6);
}
.open-modal {
display: flex;
}
</style>

View File

@ -87,7 +87,7 @@
color: var(--clr-scale-ntrl-0);
gap: var(--size-12);
padding: var(--size-20);
background-color: var(--clr-theme-err-container);
background-color: var(--clr-theme-err-bg);
border-radius: var(--radius-m);
margin-bottom: var(--size-12);
}

View File

@ -122,11 +122,11 @@
function getChecksCount(status: ChecksStatus): string {
if (!status) return 'Running checks';
const completed = status.completed || 0;
const skipped = status.skipped || 0;
const total = (status.totalCount || 0) - skipped;
const queued = total - (status.queued || 0);
return `Running checks ${queued}/${total}`;
return `Checks completed ${completed}/${total}`;
}
function getChecksTagInfo(

View File

@ -48,7 +48,9 @@
reversedDirection
loading={isDeleting}
icon="bin-small"
on:click={onDeleteClicked}>Remove</Button
on:click={() => {
onDeleteClicked().then(close);
}}>Remove</Button
>
<Button style="pop" kind="solid" on:click={close}>Cancel</Button>
</svelte:fragment>

View File

@ -93,11 +93,11 @@
}
.success {
background: var(--clr-theme-pop-container);
background: var(--clr-theme-pop-bg);
}
.error {
background: var(--clr-theme-warn-container);
background: var(--clr-theme-warn-bg);
}
.extra-padding {
padding: var(--size-20);

View File

@ -34,12 +34,6 @@
dispatch('select', { value });
listOpen = false;
}
function scrollIntoView() {
const selected = element.querySelector('.selected');
if (selected) selected.scrollIntoView();
}
function setMaxHeight() {
maxHeight = window.innerHeight - element.getBoundingClientRect().bottom - maxPadding;
}
@ -52,7 +46,6 @@
function openList() {
setMaxHeight();
listOpen = true;
setTimeout(() => scrollIntoView(), 50);
}
function closeList() {

View File

@ -59,4 +59,12 @@
overflow-x: hidden;
}
}
.selected {
background-color: var(--clr-bg-2);
& .label {
opacity: 0.5;
}
}
</style>

View File

@ -44,7 +44,7 @@
opacity: 0.5;
}
.success.setup-feature {
background: var(--clr-theme-pop-container, #f3fcfb);
background: var(--clr-theme-pop-bg);
}
.setup-feature__content {

View File

@ -40,7 +40,7 @@
color: var(--clr-scale-ntrl-0);
gap: var(--size-12);
padding: var(--size-20);
background-color: var(--clr-theme-err-container);
background-color: var(--clr-theme-err-bg);
border-radius: var(--radius-m);
margin-bottom: var(--size-12);
}

View File

@ -114,7 +114,7 @@
.pop {
&.soft {
color: var(--clr-theme-pop-on-container);
color: var(--clr-theme-pop-on-soft);
background: var(--clr-scale-pop-80);
/* if button */
&:not(.not-button, &:disabled):hover {
@ -134,7 +134,7 @@
.success {
&.soft {
color: var(--clr-theme-succ-on-container);
color: var(--clr-theme-succ-on-soft);
background: var(--clr-scale-succ-80);
/* if button */
&:not(.not-button, &:disabled):hover {
@ -154,7 +154,7 @@
.error {
&.soft {
color: var(--clr-theme-err-on-container);
color: var(--clr-theme-err-on-soft);
background: var(--clr-scale-err-80);
/* if button */
&:not(.not-button, &:disabled):hover {
@ -174,7 +174,7 @@
.warning {
&.soft {
color: var(--clr-theme-warn-on-container);
color: var(--clr-theme-warn-on-soft);
background: var(--clr-scale-warn-80);
/* if button */
&:not(.not-button, &:disabled):hover {
@ -194,7 +194,7 @@
.purple {
&.soft {
color: var(--clr-theme-purp-on-container);
color: var(--clr-theme-purp-on-soft);
background: var(--clr-scale-purp-80);
/* if button */
&:not(.not-button, &:disabled):hover {
@ -214,43 +214,15 @@
/* modifiers */
.not-button {
cursor: default;
user-select: none;
}
.disabled {
cursor: default;
pointer-events: none;
/* opacity: 0.5; */
opacity: 0.6;
}
&.neutral.solid,
&.pop.solid,
&.success.solid,
&.error.solid,
&.warning.solid,
&.purple.solid {
color: var(--clr-text-2);
background: oklch(from var(--clr-scale-ntrl-60) l c h / 0.15);
}
&.neutral.soft,
&.pop.soft,
&.success.soft,
&.error.soft,
&.warning.soft,
&.purple.soft {
color: var(--clr-text-2);
background: oklch(from var(--clr-scale-ntrl-60) l c h / 0.15);
}
&.ghost {
color: var(--clr-text-2);
}
&.ghost.solid {
border: 1px solid oklch(from var(--clr-scale-ntrl-0) l c h / 0.1);
}
.not-button {
cursor: default;
user-select: none;
}
.reversedDirection {

View File

@ -65,7 +65,7 @@
>
{#if icon}
<div class="textbox__icon">
<Icon name={icon} />
<Icon name={!disabled ? icon : 'locked'} />
</div>
{/if}
@ -152,8 +152,18 @@
.textbox__input-wrap {
position: relative;
&.disabled .textbox__icon {
color: var(--clr-scale-ntrl-60);
&.disabled {
/* background-color: var(--clr-bg-1); */
& .textbox__icon {
color: var(--clr-scale-ntrl-60);
}
& .textbox__input {
color: var(--clr-text-2);
background-color: var(--clr-bg-2);
border: 1px solid var(--clr-border-3);
}
}
}
@ -198,7 +208,8 @@
}
/* select */
.textbox__input[type='select'] {
.textbox__input[type='select']:not([disabled]),
.textbox__input[type='select']:not([readonly]) {
cursor: pointer;
}

View File

@ -101,6 +101,16 @@
<span class="text-base-14 text-semibold">Telemetry</span>
</button>
</li>
<li>
<button
class="profile-sidebar__menu-item"
class:item_selected={currentSection == 'experimental'}
on:mousedown={() => onMenuClick('experimental')}
>
<Icon name="idea" />
<span class="text-base-14 text-semibold">Experimental</span>
</button>
</li>
</ul>
</div>
</section>

View File

@ -35,6 +35,15 @@ export function appErrorReportingEnabled() {
return persisted(true, 'appErrorReportingEnabled');
}
/**
* Provides a writable store for obtaining or setting the current state of non-anonemous application metrics.
* The setting can be enabled or disabled by setting the value of the store to true or false.
* @returns A writable store with the appNonAnonMetricsEnabled config.
*/
export function appNonAnonMetricsEnabled() {
return persisted(false, 'appNonAnonMetricsEnabled');
}
function persisted<T>(initial: T, key: string): Writable<T> & { onDisk: () => Promise<T> } {
async function setAndPersist(value: T, set: (value: T) => void) {
await store.set(key, value);

View File

@ -0,0 +1,17 @@
/**
* This file contains functions for managing ui-specific feature flags.
* The values are persisted in local storage. Entries are prefixed with 'feature'.
*
* @module appSettings
*/
import { persisted, type Persisted } from '$lib/persisted/persisted';
export function featureBaseBranchSwitching(): Persisted<boolean> {
const key = 'featureBaseBranchSwitching';
return persisted(false, key);
}
export function featureAdvancedCommitOperations(): Persisted<boolean> {
const key = 'featureAdvancedCommitOperations';
return persisted(false, key);
}

View File

@ -1,5 +1,5 @@
import { get, type Readable } from 'svelte/store';
import type { AnyFile, Commit, Hunk, RemoteCommit } from '../vbranches/types';
import type { AnyCommit, AnyFile, Commit, Hunk, RemoteCommit } from '../vbranches/types';
export function nonDraggable() {
return {
@ -18,7 +18,8 @@ export class DraggableHunk {
export class DraggableFile {
constructor(
public readonly branchId: string,
private file: AnyFile,
public file: AnyFile,
public commit: AnyCommit | undefined,
private selection: Readable<AnyFile[]> | undefined
) {}

View File

@ -22,7 +22,8 @@ export function showToast(toast: Toast) {
}
export function showError(title: string, err: any) {
if (err.message) showToast({ title, errorMessage: err.message, style: 'error' });
const errorMessage = err.message ? err.message : err;
showToast({ title, errorMessage: errorMessage, style: 'error' });
}
export function dismissToast(messageId: string | undefined) {

View File

@ -17,6 +17,7 @@ export interface Settings {
zoom: number;
scrollbarVisabilityOnHover: boolean;
tabSize: number;
showHistoryView: boolean;
}
const defaults: Settings = {
@ -31,7 +32,8 @@ const defaults: Settings = {
stashedBranchesHeight: 150,
zoom: 1,
scrollbarVisabilityOnHover: false,
tabSize: 4
tabSize: 4,
showHistoryView: false
};
export function loadUserSettings(): Writable<Settings> {

View File

@ -0,0 +1,16 @@
import { normalizeBranchName } from '$lib/utils/branch';
import { describe, expect, test } from 'vitest';
describe.concurrent('normalizeBranchName', () => {
test('it should remove undesirable symbols', () => {
expect(normalizeBranchName('a£^&*() b')).toEqual('a-b');
});
test('it should preserve capital letters', () => {
expect(normalizeBranchName('Hello World')).toEqual('Hello-World');
});
test('it should preserve `#`, `_`, `/`, and `.`', () => {
expect(normalizeBranchName('hello#_./world')).toEqual('hello#_./world');
});
});

View File

@ -1,3 +1,3 @@
export function normalizeBranchName(value: string) {
return value.toLowerCase().replace(/[^0-9a-z/_.]+/g, '-');
return value.replace(/[^A-Za-z0-9_/.#]+/g, '-');
}

View File

@ -0,0 +1,4 @@
// If a value occurs > 1 times then all but one will fail this condition.
export function unique(value: any, index: number, array: any[]) {
return array.indexOf(value) === index;
}

View File

@ -1,3 +1,7 @@
export function isDefined<T>(file: T | undefined): file is T {
export function isDefined<T>(file: T | undefined | null): file is T {
return file !== undefined;
}
export function notNull<T>(file: T | undefined | null): file is T {
return file !== null;
}

View File

@ -18,6 +18,7 @@ export class BranchController {
async setTarget(branch: string) {
try {
await this.targetBranchService.setTarget(branch);
return branch;
// TODO: Reloading seems to trigger 4 invocations of `list_virtual_branches`
} catch (err: any) {
showError('Failed to set base branch', err);
@ -293,11 +294,12 @@ export class BranchController {
}
}
async amendBranch(branchId: string, ownership: string) {
async amendBranch(branchId: string, commitOid: string, ownership: string) {
try {
await invoke<void>('amend_virtual_branch', {
projectId: this.projectId,
branchId,
commitOid,
ownership
});
} catch (err: any) {
@ -305,6 +307,76 @@ export class BranchController {
}
}
async moveCommitFile(
branchId: string,
fromCommitOid: string,
toCommitOid: string,
ownership: string
) {
try {
await invoke<void>('move_commit_file', {
projectId: this.projectId,
branchId,
fromCommitOid,
toCommitOid,
ownership
});
} catch (err: any) {
showError('Failed to amend commit', err);
}
}
async undoCommit(branchId: string, commitOid: string) {
try {
await invoke<void>('undo_commit', {
projectId: this.projectId,
branchId,
commitOid
});
} catch (err: any) {
showError('Failed to amend commit', err);
}
}
async updateCommitMessage(branchId: string, commitOid: string, message: string) {
try {
await invoke<void>('update_commit_message', {
projectId: this.projectId,
branchId,
commitOid,
message
});
} catch (err: any) {
showError('Failed to change commit message', err);
}
}
async insertBlankCommit(branchId: string, commitOid: string, offset: number) {
try {
await invoke<void>('insert_blank_commit', {
projectId: this.projectId,
branchId,
commitOid,
offset
});
} catch (err: any) {
showError('Failed to insert blank commit', err);
}
}
async reorderCommit(branchId: string, commitOid: string, offset: number) {
try {
await invoke<void>('reorder_commit', {
projectId: this.projectId,
branchId,
commitOid,
offset
});
} catch (err: any) {
showError('Failed to reorder blank commit', err);
}
}
async moveCommit(targetBranchId: string, commitOid: string) {
try {
await invoke<void>('move_commit', {

View File

@ -1,4 +1,4 @@
import type { Branch, AnyFile, Hunk, RemoteHunk } from './types';
import type { Branch, AnyFile, Hunk, RemoteHunk, RemoteFile } from './types';
export function filesToOwnership(files: AnyFile[]) {
return files
@ -6,6 +6,15 @@ export function filesToOwnership(files: AnyFile[]) {
.join('\n');
}
export function filesToSimpleOwnership(files: RemoteFile[]) {
return files
.map(
(f) =>
`${f.path}:${f.hunks.map(({ new_start, new_lines }) => `${new_start}-${new_start + new_lines}`).join(',')}`
)
.join('\n');
}
// These types help keep track of what maps to what.
// TODO: refactor code for clarity, these types should not be needed
export type AnyHunk = Hunk | RemoteHunk;

View File

@ -1,13 +1,17 @@
import type { Commit } from './types';
import { HunkLock, type Commit } from './types';
import { unique } from '$lib/utils/filters';
export function getLockText(commitId: string[] | string, commits: Commit[]): string {
if (!commitId || commits === undefined) return 'Depends on a committed change';
export function getLockText(hunkLocks: HunkLock | HunkLock[] | string, commits: Commit[]): string {
if (!hunkLocks || commits === undefined) return 'Depends on a committed change';
const lockedIds = typeof commitId == 'string' ? [commitId] : (commitId as string[]);
const locks = hunkLocks instanceof HunkLock ? [hunkLocks] : (hunkLocks as HunkLock[]);
const descriptions = lockedIds
.map((id) => {
const commit = commits.find((commit) => commit.id == id);
const descriptions = locks
.filter(unique)
.map((lock) => {
const commit = commits.find((c) => {
return c.id == lock.commitId;
});
const shortCommitId = commit?.id.slice(0, 7);
if (commit) {
const shortTitle = commit.descriptionTitle?.slice(0, 35) + '...';
@ -17,5 +21,13 @@ export function getLockText(commitId: string[] | string, commits: Commit[]): str
}
})
.join('\n');
return 'Locked due to dependency on:\n' + descriptions;
const branchCount = locks.map((lock) => lock.branchId).filter(unique).length;
if (branchCount > 1) {
return (
'Warning, undefined behavior due to lock on multiple branches!\n\n' +
'Locked because changes depend on:\n' +
descriptions
);
}
return 'Locked because changes depend on:\n' + descriptions;
}

View File

@ -1,6 +1,7 @@
import 'reflect-metadata';
import { splitMessage } from '$lib/utils/commitMessage';
import { hashCode } from '$lib/utils/string';
import { isDefined, notNull } from '$lib/utils/typeguards';
import { Type, Transform } from 'class-transformer';
export type ChangeType =
@ -21,8 +22,16 @@ export class Hunk {
filePath!: string;
hash?: string;
locked!: boolean;
lockedTo!: string | undefined;
@Type(() => HunkLock)
lockedTo!: HunkLock[];
changeType!: ChangeType;
new_start!: number;
new_lines!: number;
}
export class HunkLock {
branchId!: string;
commitId!: string;
}
export type AnyFile = LocalFile | RemoteFile;
@ -58,14 +67,15 @@ export class LocalFile {
get locked(): boolean {
return this.hunks
? this.hunks.map((hunk) => hunk.lockedTo).reduce((a, b) => !!(a || b), false)
? this.hunks.map((hunk) => hunk.locked).reduce((a, b) => !!(a || b), false)
: false;
}
get lockedIds(): string[] {
get lockedIds(): HunkLock[] {
return this.hunks
.map((hunk) => hunk.lockedTo)
.filter((lockedTo): lockedTo is string => !!lockedTo);
.flatMap((hunk) => hunk.lockedTo)
.filter(notNull)
.filter(isDefined);
}
}
@ -210,6 +220,8 @@ export const UNKNOWN_COMMITS = Symbol('UnknownCommits');
export class RemoteHunk {
diff!: string;
hash?: string;
new_start!: number;
new_lines!: number;
get id(): string {
return hashCode(this.diff);
@ -250,7 +262,7 @@ export class RemoteFile {
return this.hunks.map((h) => h.id);
}
get lockedIds(): string[] {
get lockedIds(): HunkLock[] {
return [];
}

View File

@ -67,6 +67,12 @@
hotkeys.on('Backspace', (e) => {
// This prevent backspace from navigating back
e.preventDefault();
}),
hotkeys.on('$mod+Shift+H', () => {
userSettings.update((s) => ({
...s,
showHistoryView: !$userSettings.showHistoryView
}));
})
);
});

View File

@ -1,13 +1,11 @@
import { AIService } from '$lib/ai/service';
import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry';
import { initAnalyticsIfEnabled } from '$lib/analytics/analytics';
import { AuthService } from '$lib/backend/auth';
import { GitConfigService } from '$lib/backend/gitConfigService';
import { HttpClient } from '$lib/backend/httpClient';
import { ProjectService } from '$lib/backend/projects';
import { PromptService } from '$lib/backend/prompt';
import { UpdaterService } from '$lib/backend/updater';
import { appMetricsEnabled, appErrorReportingEnabled } from '$lib/config/appSettings';
import { GitHubService } from '$lib/github/service';
import { UserService } from '$lib/stores/user';
import lscache from 'lscache';
@ -24,16 +22,7 @@ export const prerender = false;
export const csr = true;
export async function load() {
appErrorReportingEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initSentry();
});
appMetricsEnabled()
.onDisk()
.then((enabled) => {
if (enabled) initPostHog();
});
initAnalyticsIfEnabled();
// TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905

View File

@ -2,11 +2,14 @@
import { Project } from '$lib/backend/projects';
import { syncToCloud } from '$lib/backend/sync';
import { BranchService } from '$lib/branches/service';
import History from '$lib/components/History.svelte';
import Navigation from '$lib/components/Navigation.svelte';
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte';
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
import { getContextStoreBySymbol } from '$lib/utils/context';
import * as hotkeys from '$lib/utils/hotkeys';
import { unsubscribe } from '$lib/utils/unsubscribe';
import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch';
@ -33,6 +36,7 @@
$: baseBranch = baseBranchService.base;
$: baseError = baseBranchService.error;
$: projectError = projectService.error;
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
$: setContext(VirtualBranchService, vbranchService);
$: setContext(BranchController, branchController);
@ -90,6 +94,9 @@
<div class="view-wrap" role="group" on:dragover|preventDefault>
<Navigation />
<slot />
{#if $userSettings.showHistoryView}
<History {projectId} />
{/if}
</div>
{/if}
{/key}

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { Project, ProjectService } from '$lib/backend/projects';
import BaseBranchSwitch from '$lib/components/BaseBranchSwitch.svelte';
import CloudForm from '$lib/components/CloudForm.svelte';
import DetailsForm from '$lib/components/DetailsForm.svelte';
import KeysForm from '$lib/components/KeysForm.svelte';
@ -8,6 +9,7 @@
import SectionCard from '$lib/components/SectionCard.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte';
import { featureBaseBranchSwitching } from '$lib/config/uiFeatureFlags';
import { showError } from '$lib/notifications/toasts';
import { getContext } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts';
@ -15,6 +17,7 @@
import { from } from 'rxjs';
import { goto } from '$app/navigation';
const baseBranchSwitching = featureBaseBranchSwitching();
const projectService = getContext(ProjectService);
const project = getContext(Project);
const platformName = from(platform());
@ -39,6 +42,9 @@
</script>
<ContentWrapper title="Project settings">
{#if $baseBranchSwitching}
<BaseBranchSwitch />
{/if}
<CloudForm />
<DetailsForm />
{#if $platformName != 'win32'}

View File

@ -0,0 +1,53 @@
<script lang="ts">
import SectionCard from '$lib/components/SectionCard.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte';
import {
featureBaseBranchSwitching,
featureAdvancedCommitOperations
} from '$lib/config/uiFeatureFlags';
const baseBranchSwitching = featureBaseBranchSwitching();
const advancedCommitOperations = featureAdvancedCommitOperations();
</script>
<ContentWrapper title="Experimental features">
<p class="text-base-body-13 experimental-settings__text">
This sections contains a list of feature flags for features that are still in development or in
an experimental stage.
</p>
<SectionCard labelFor="baseBranchSwitching" orientation="row">
<svelte:fragment slot="title">Switching the base branch</svelte:fragment>
<svelte:fragment slot="caption">
This allows changing of the base branch (trunk) after the initial project setup from within
the project settings. Not fully tested yet, use with caution.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="baseBranchSwitching"
checked={$baseBranchSwitching}
on:change={() => ($baseBranchSwitching = !$baseBranchSwitching)}
/>
</svelte:fragment>
</SectionCard>
<SectionCard labelFor="advancedCommitOperations" orientation="row">
<svelte:fragment slot="title">Advanced commit operations</svelte:fragment>
<svelte:fragment slot="caption">
Allows for reordeing of commits, changing the message as well as undoing of commits anywhere
in the stack. In addition it allows for adding an empty commit between two other commits.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="advancedCommitOperations"
checked={$advancedCommitOperations}
on:change={() => ($advancedCommitOperations = !$advancedCommitOperations)}
/>
</svelte:fragment>
</SectionCard>
</ContentWrapper>
<style>
.experimental-settings__text {
color: var(--clr-text-2);
margin-bottom: var(--size-12);
}
</style>

View File

@ -116,10 +116,36 @@ button {
/* DIALOG STYLES */
dialog[open] {
animation: dialog-zoom 0.25s cubic-bezier(0.34, 1.35, 0.7, 1);
}
@keyframes dialog-zoom {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}
dialog::backdrop {
background-color: rgba(214, 214, 214, 0.4);
}
dialog[open]::backdrop {
animation: dialog-fade 0.2s ease-out;
}
@keyframes dialog-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dark dialog::backdrop {
background-color: rgba(0, 0, 0, 0.35);
}

View File

@ -82,7 +82,7 @@
/* text Base Body Classes */
.text-base-body-10 {
font-family: var(--base-font-family);
font-size: 625rem;
font-size: 0.625rem;
font-weight: var(--base-font-weight);
line-height: var(--text-body-line-height);
}

View File

@ -1,26 +0,0 @@
[package]
name = "gitbutler-analytics"
version = "0.0.0"
edition = "2021"
publish = false
[lib]
doctest = false
test = false
[dependencies]
gitbutler-core.workspace = true
thiserror.workspace = true
tracing = "0.1.40"
tokio.workspace = true
serde.workspace = true
serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
async-trait = "0.1.79"
chrono = { version = "0.4.37", features = ["serde"] }
reqwest = { version = "0.12.2", features = ["json"] }
[lints.clippy]
all = "deny"
perf = "deny"
correctness = "deny"

View File

@ -1,100 +0,0 @@
//! A client to provide analytics.
use std::{fmt, str, sync::Arc};
use gitbutler_core::{projects::ProjectId, users::User};
mod posthog;
pub struct Config<'c> {
pub posthog_token: Option<&'c str>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
HeadChange {
project_id: ProjectId,
reference_name: String,
},
}
impl fmt::Display for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Event::HeadChange {
project_id,
reference_name,
} => write!(
f,
"HeadChange(project_id: {}, reference_name: {})",
project_id, reference_name
),
}
}
}
impl Event {
pub fn project_id(&self) -> ProjectId {
match self {
Event::HeadChange { project_id, .. } => *project_id,
}
}
fn into_posthog_event(self, user: &User) -> posthog::Event {
match self {
Event::HeadChange {
project_id,
reference_name: reference,
} => {
let mut event =
posthog::Event::new("git::head_changed", &format!("user_{}", user.id));
event.insert_prop("project_id", format!("project_{}", project_id));
event.insert_prop("reference", reference);
event
}
}
}
}
/// NOTE: Needs to be `Clone` only because the watcher wants to obtain it from `tauri`.
/// It's just for dependency injection.
#[derive(Clone)]
pub struct Client {
client: Arc<dyn posthog::Client + Sync + Send>,
}
impl Client {
pub fn new(app_name: String, app_version: String, config: &Config) -> Self {
let client: Arc<dyn posthog::Client + Sync + Send> =
if let Some(posthog_token) = config.posthog_token {
let real = posthog::real::Client::new(posthog::real::ClientOptions {
api_key: posthog_token.to_string(),
app_name,
app_version,
});
let real_with_retry = posthog::retry::Client::new(real);
Arc::new(real_with_retry)
} else {
Arc::<posthog::mock::Client>::default()
};
Client { client }
}
/// Send `event` to analytics and associate it with `user` without blocking.
pub fn send_non_anonymous_event_nonblocking(&self, user: &User, event: &Event) {
let client = self.client.clone();
let event = event.clone().into_posthog_event(user);
tokio::spawn(async move {
if let Err(error) = client.capture(&[event]).await {
tracing::warn!(?error, "failed to send analytics");
}
});
}
}
impl Default for Client {
fn default() -> Self {
Self {
client: Arc::new(posthog::mock::Client),
}
}
}

View File

@ -1,67 +0,0 @@
pub mod mock;
pub mod real;
pub mod retry;
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use serde::Serialize;
#[async_trait]
pub trait Client {
async fn capture(&self, events: &[Event]) -> Result<(), Error>;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{code}: {message}")]
BadRequest { code: u16, message: String },
#[error("Connection error: {0}")]
Connection(#[from] reqwest::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
#[derive(Serialize, Debug, PartialEq, Eq, Clone)]
pub struct Event {
event: String,
properties: Properties,
timestamp: Option<NaiveDateTime>,
}
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub struct Properties {
distinct_id: String,
props: HashMap<String, serde_json::Value>,
}
impl Properties {
fn new<S: Into<String>>(distinct_id: S) -> Self {
Self {
distinct_id: distinct_id.into(),
props: HashMap::default(),
}
}
pub fn insert<K: Into<String>, P: Serialize>(&mut self, key: K, prop: P) {
let as_json =
serde_json::to_value(prop).expect("safe serialization of a analytics property");
let _ = self.props.insert(key.into(), as_json);
}
}
impl Event {
pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
Self {
event: event.into(),
properties: Properties::new(distinct_id),
timestamp: None,
}
}
/// Errors if `prop` fails to serialize
pub fn insert_prop<K: Into<String>, P: Serialize>(&mut self, key: K, prop: P) {
self.properties.insert(key, prop);
}
}

View File

@ -1,13 +0,0 @@
use async_trait::async_trait;
use tracing::instrument;
#[derive(Default)]
pub struct Client;
#[async_trait]
impl super::Client for Client {
#[instrument(skip(self), level = "debug")]
async fn capture(&self, _events: &[super::Event]) -> Result<(), super::Error> {
Ok(())
}
}

View File

@ -1,96 +0,0 @@
use std::time::Duration;
use async_trait::async_trait;
use chrono::NaiveDateTime;
use reqwest::{header::CONTENT_TYPE, Client as HttpClient};
use serde::Serialize;
use tracing::instrument;
const API_ENDPOINT: &str = "https://eu.posthog.com/batch/";
const TIMEOUT: &Duration = &Duration::from_millis(800);
pub struct ClientOptions {
pub app_name: String,
pub app_version: String,
pub api_key: String,
}
pub struct Client {
options: ClientOptions,
client: HttpClient,
}
impl Client {
pub fn new<C: Into<ClientOptions>>(options: C) -> Self {
let client = HttpClient::builder().timeout(*TIMEOUT).build().unwrap(); // Unwrap here is as safe as `HttpClient::new`
Client {
options: options.into(),
client,
}
}
}
#[async_trait]
impl super::Client for Client {
#[instrument(skip(self), level = "debug")]
async fn capture(&self, events: &[super::Event]) -> Result<(), super::Error> {
let events = events
.iter()
.map(|event| {
let event = &mut event.clone();
event
.properties
.insert("appName", self.options.app_name.clone());
event
.properties
.insert("appVersion", self.options.app_version.clone());
Event::from(event)
})
.collect::<Vec<_>>();
let batch = Batch {
api_key: &self.options.api_key,
batch: events.as_slice(),
};
let response = self
.client
.post(API_ENDPOINT)
.header(CONTENT_TYPE, "application/json")
.body(serde_json::to_string(&batch)?)
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(super::Error::BadRequest {
code: response.status().as_u16(),
message: response.text().await.unwrap_or_default(),
})
}
}
}
#[derive(Serialize)]
struct Batch<'a> {
api_key: &'a str,
batch: &'a [Event],
}
#[derive(Serialize)]
struct Event {
event: String,
properties: super::Properties,
timestamp: Option<NaiveDateTime>,
}
impl From<&mut super::Event> for Event {
fn from(event: &mut super::Event) -> Self {
Self {
event: event.event.clone(),
properties: event.properties.clone(),
timestamp: event.timestamp,
}
}
}

View File

@ -1,118 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::Mutex;
use tracing::instrument;
#[derive(Clone)]
pub struct Client<T: super::Client + Sync> {
inner: T,
/// Events that failed to be sent
/// and are waiting to be retried.
batch: Arc<Mutex<Vec<super::Event>>>,
}
impl<T: super::Client + Sync> Client<T> {
pub fn new(inner: T) -> Self {
Client {
inner,
batch: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl<T: super::Client + Sync> super::Client for Client<T> {
#[instrument(skip(self), level = "debug")]
async fn capture(&self, events: &[super::Event]) -> Result<(), super::Error> {
let mut batch = self.batch.lock().await;
batch.extend_from_slice(events);
if let Err(error) = self.inner.capture(&batch).await {
tracing::warn!("Failed to send analytics: {}", error);
} else {
batch.clear();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use super::{super::Client, *};
#[derive(Clone)]
struct MockClient {
sent: Arc<AtomicUsize>,
is_failing: Arc<AtomicBool>,
}
impl MockClient {
fn new() -> Self {
MockClient {
sent: Arc::new(AtomicUsize::new(0)),
is_failing: Arc::new(AtomicBool::new(false)),
}
}
fn set_failing(&self, is_failing: bool) {
self.is_failing.store(is_failing, Ordering::SeqCst);
}
fn get_sent(&self) -> usize {
self.sent.load(Ordering::SeqCst)
}
}
#[async_trait]
impl super::super::Client for MockClient {
async fn capture(&self, events: &[super::super::Event]) -> Result<(), super::super::Error> {
if self.is_failing.load(Ordering::SeqCst) {
Err(super::super::Error::BadRequest {
code: 400,
message: "Bad request".to_string(),
})
} else {
self.sent.fetch_add(events.len(), Ordering::SeqCst);
Ok(())
}
}
}
#[tokio::test]
async fn retry() {
let inner_client = MockClient::new();
let retry_client = super::Client::new(inner_client.clone());
inner_client.set_failing(true);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 0);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 0);
inner_client.set_failing(false);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 3);
retry_client
.capture(&[super::super::Event::new("test", "test")])
.await
.unwrap();
assert_eq!(inner_client.get_sent(), 4);
}
}

View File

@ -43,15 +43,15 @@ fn score_ignores_whitespace() {
assert_score!(sig, "\t\t hel lo\n\two rld \t\t", 1.0);
}
const TEXT1: &str = include_str!("../fixtures/text1.txt");
const TEXT2: &str = include_str!("../fixtures/text2.txt");
const TEXT3: &str = include_str!("../fixtures/text3.txt");
const CODE1: &str = include_str!("../fixtures/code1.txt");
const CODE2: &str = include_str!("../fixtures/code2.txt");
const CODE3: &str = include_str!("../fixtures/code3.txt");
const CODE4: &str = include_str!("../fixtures/code4.txt");
const LARGE1: &str = include_str!("../fixtures/large1.txt");
const LARGE2: &str = include_str!("../fixtures/large2.txt");
const TEXT1: &str = include_str!("fixtures/text1.txt");
const TEXT2: &str = include_str!("fixtures/text2.txt");
const TEXT3: &str = include_str!("fixtures/text3.txt");
const CODE1: &str = include_str!("fixtures/code1.txt");
const CODE2: &str = include_str!("fixtures/code2.txt");
const CODE3: &str = include_str!("fixtures/code3.txt");
const CODE4: &str = include_str!("fixtures/code4.txt");
const LARGE1: &str = include_str!("fixtures/large1.txt");
const LARGE2: &str = include_str!("fixtures/large2.txt");
macro_rules! real_test {
($a: ident, $b: ident, are_similar) => {

View File

@ -0,0 +1,19 @@
[package]
name = "gitbutler-cli"
version = "0.0.0"
edition = "2021"
authors = ["GitButler <gitbutler@gitbutler.com>"]
publish = false
[[bin]]
name = "gitbutler-cli"
path = "src/main.rs"
[dependencies]
gitbutler-core.workspace = true
clap = "4.5.4"
anyhow = "1.0.82"
chrono = "0.4.10"
[target."cfg(unix)".dependencies]
pager = "0.16.1"

View File

@ -0,0 +1,75 @@
use anyhow::Result;
use gitbutler_core::{projects::Project, snapshots::snapshot};
use clap::{arg, Command};
#[cfg(not(windows))]
use pager::Pager;
fn cli() -> Command {
Command::new("gitbutler-cli")
.about("A CLI tool for GitButler")
.arg(arg!(-C <path> "Run as if gitbutler-cli was started in <path> instead of the current working directory."))
.subcommand_required(true)
.arg_required_else_help(true)
.allow_external_subcommands(true)
.subcommand(
Command::new("snapshot")
.about("List and restore snapshots.")
.subcommand(Command::new("restore")
.about("Restores the state of the working direcory as well as virtual branches to a given snapshot.")
.arg(arg!(<SNAPSHOT_ID> "The snapshot to restore"))),
)
}
fn main() -> Result<()> {
#[cfg(not(windows))]
Pager::new().setup();
let matches = cli().get_matches();
let cwd = std::env::current_dir()?.to_string_lossy().to_string();
let repo_dir = matches.get_one::<String>("path").unwrap_or(&cwd);
match matches.subcommand() {
Some(("snapshot", sub_matches)) => match sub_matches.subcommand() {
Some(("restore", sub_matches)) => {
let snapshot_id = sub_matches
.get_one::<String>("SNAPSHOT_ID")
.expect("required");
restore_snapshot(repo_dir, snapshot_id)?;
}
_ => {
list_snapshots(repo_dir)?;
}
},
_ => unreachable!(),
}
Ok(())
}
fn list_snapshots(repo_dir: &str) -> Result<()> {
let project = project_from_path(repo_dir);
let snapshots = snapshot::list(&project, 100)?;
for snapshot in snapshots {
let ts = chrono::DateTime::from_timestamp(snapshot.created_at / 1000, 0);
let details = snapshot.details;
if let (Some(ts), Some(details)) = (ts, details) {
println!("{} {} {}", ts, snapshot.id, details.operation);
}
}
Ok(())
}
fn restore_snapshot(repo_dir: &str, snapshot_id: &str) -> Result<()> {
let project = project_from_path(repo_dir);
snapshot::restore(&project, snapshot_id.to_owned())?;
Ok(())
}
fn project_from_path(repo_dir: &str) -> Project {
Project {
path: std::path::PathBuf::from(repo_dir),
enable_snapshots: Some(true),
..Default::default()
}
}

View File

@ -8,15 +8,17 @@ publish = false
[dev-dependencies]
once_cell = "1.19"
pretty_assertions = "1.4"
tempfile = "3.10"
gitbutler-testsupport.workspace = true
gitbutler-git = { workspace = true, features = ["test-askpass-path" ]}
[dependencies]
toml = "0.8.12"
anyhow = "1.0.81"
async-trait = "0.1.79"
anyhow = "1.0.82"
async-trait = "0.1.80"
backtrace = { version = "0.3.71", optional = true }
bstr = "1.9.1"
chrono = { version = "0.4.37", features = ["serde"] }
chrono = { version = "0.4.38", features = ["serde"] }
diffy = "0.3.0"
filetime = "0.2.23"
fslock = "0.2.1"
@ -33,7 +35,7 @@ r2d2_sqlite = "0.22.0"
rand = "0.8.5"
refinery = { version = "0.8", features = [ "rusqlite" ] }
regex = "1.10"
reqwest = { version = "0.12.2", features = ["json"] }
reqwest = { version = "0.12.4", features = ["json"] }
resolve-path = "0.1.0"
rusqlite.workspace = true
serde.workspace = true
@ -41,8 +43,9 @@ serde_json = { version = "1.0", features = [ "std", "arbitrary_precision" ] }
sha2 = "0.10.8"
similar = { version = "2.5.0", features = ["unicode"] }
slug = "0.1.5"
ssh-key = { version = "0.6.5", features = [ "alloc", "ed25519" ] }
ssh-key = { version = "0.6.6", features = [ "alloc", "ed25519" ] }
ssh2 = { version = "0.9.4", features = ["vendored-openssl"] }
strum = { version = "0.26", features = ["derive"] }
log = "^0.4"
thiserror.workspace = true
tokio = { workspace = true, features = [ "rt-multi-thread", "rt", "macros" ] }
@ -52,7 +55,6 @@ urlencoding = "2.1.3"
uuid.workspace = true
walkdir = "2.5.0"
zip = "0.6.5"
tempfile = "3.10"
gitbutler-git.workspace = true
[features]

View File

@ -93,15 +93,12 @@ impl Proxy {
async fn proxy_author(&self, author: Author) -> Author {
Author {
gravatar_url: self
.proxy(&author.gravatar_url)
.await
.unwrap_or_else(|error| {
tracing::error!(gravatar_url = %author.gravatar_url, ?error, "failed to proxy gravatar url");
author.gravatar_url
}),
..author
}
gravatar_url: self.proxy(&author.gravatar_url).await.unwrap_or_else(|error| {
tracing::error!(gravatar_url = %author.gravatar_url, ?error, "failed to proxy gravatar url");
author.gravatar_url
}),
..author
}
}
async fn proxy_remote_commit(&self, commit: RemoteCommit) -> RemoteCommit {

View File

@ -1,7 +1,4 @@
use std::{
fmt::{Display, Formatter},
time::SystemTime,
};
use std::fmt::{Display, Formatter};
use anyhow::Result;
@ -50,29 +47,20 @@ impl Document {
};
let operations = operations::get_delta_operations(&self.to_string(), new_text);
let delta = if operations.is_empty() {
if operations.is_empty() {
if let Some(reader::Content::UTF8(value)) = value {
if !value.is_empty() {
return Ok(None);
}
}
}
delta::Delta {
operations,
timestamp_ms: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
}
} else {
delta::Delta {
operations,
timestamp_ms: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
}
let delta = delta::Delta {
operations,
timestamp_ms: crate::time::now_ms(),
};
apply_deltas(&mut self.doc, &vec![delta.clone()])?;
self.deltas.push(delta.clone());
Ok(Some(delta))

View File

@ -1,8 +1,11 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::Result;
use bstr::BString;
use gix::dir::walk::EmissionMode;
use gix::tempfile::create_dir::Retries;
use gix::tempfile::{AutoRemove, ContainingDirectory};
use walkdir::WalkDir;
// Returns an ordered list of relative paths for files inside a directory recursively.
@ -48,3 +51,71 @@ pub fn iter_worktree_files(
.filter_map(Result::ok)
.map(|e| e.entry.rela_path))
}
/// Write a single file so that the write either fully succeeds, or fully fails,
/// assuming the containing directory already exists.
pub(crate) fn write<P: AsRef<Path>>(
file_path: P,
contents: impl AsRef<[u8]>,
) -> anyhow::Result<()> {
#[cfg(windows)]
{
Ok(std::fs::write(file_path, contents)?)
}
#[cfg(not(windows))]
{
let mut temp_file = gix::tempfile::new(
file_path.as_ref().parent().unwrap(),
ContainingDirectory::Exists,
AutoRemove::Tempfile,
)?;
temp_file.write_all(contents.as_ref())?;
Ok(persist_tempfile(temp_file, file_path)?)
}
}
/// Write a single file so that the write either fully succeeds, or fully fails,
/// and create all leading directories.
pub(crate) fn create_dirs_then_write<P: AsRef<Path>>(
file_path: P,
contents: impl AsRef<[u8]>,
) -> std::io::Result<()> {
#[cfg(windows)]
{
let dir = file_path.as_ref().parent().unwrap();
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(file_path, contents)
}
#[cfg(not(windows))]
{
let mut temp_file = gix::tempfile::new(
file_path.as_ref().parent().unwrap(),
ContainingDirectory::CreateAllRaceProof(Retries::default()),
AutoRemove::Tempfile,
)?;
temp_file.write_all(contents.as_ref())?;
persist_tempfile(temp_file, file_path)
}
}
fn persist_tempfile(
tempfile: gix::tempfile::Handle<gix::tempfile::handle::Writable>,
to_path: impl AsRef<Path>,
) -> std::io::Result<()> {
match tempfile.persist(to_path) {
Ok(Some(_opened_file)) => {
// EXPERIMENT: Does this fix #3601?
#[cfg(windows)]
_opened_file.sync_all()?;
Ok(())
}
Ok(None) => unreachable!(
"BUG: a signal has caused the tempfile to be removed, but we didn't install a handler"
),
Err(err) => Err(err.error),
}
}

View File

@ -4,7 +4,7 @@ use std::{
collections::HashSet,
fs::File,
io::{BufReader, Read},
path, time,
path,
};
use anyhow::{anyhow, bail, Context, Result};
@ -217,7 +217,8 @@ impl Repository {
// Push to the remote
remote
.push(&[&remote_refspec], Some(&mut push_options)).map_err(|error| match error {
.push(&[&remote_refspec], Some(&mut push_options))
.map_err(|error| match error {
git::Error::Network(error) => {
tracing::warn!(project_id = %self.project.id, error = %error, "failed to push gb repo");
RemoteError::Network
@ -279,10 +280,7 @@ impl Repository {
&self,
project_repository: &project_repository::Repository,
) -> Result<sessions::Session> {
let now_ms = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_millis();
let now_ms = crate::time::now_ms();
let meta = match project_repository.get_head() {
Result::Ok(head) => sessions::Meta {
@ -335,10 +333,7 @@ impl Repository {
let updated_session = sessions::Session {
meta: sessions::Meta {
last_timestamp_ms: time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_millis(),
last_timestamp_ms: crate::time::now_ms(),
..current_session.meta
},
..current_session

View File

@ -113,7 +113,8 @@ impl Helper {
}
}
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self {
pub fn from_path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let keys = keys::Controller::from_path(&path);
let users = users::Controller::from_path(path);
let home_dir = std::env::var_os("HOME").map(PathBuf::from);

View File

@ -9,7 +9,6 @@ use tracing::instrument;
use super::Repository;
use crate::git;
use crate::virtual_branches::BranchStatus;
pub type DiffByPathMap = HashMap<PathBuf, FileDiff>;
@ -53,6 +52,7 @@ pub struct GitHunk {
#[serde(rename = "diff", serialize_with = "crate::serde::as_string_lossy")]
pub diff_lines: BString,
pub binary: bool,
pub locked_to: Box<[HunkLock]>,
pub change_type: ChangeType,
}
@ -69,6 +69,7 @@ impl GitHunk {
diff_lines: hex_id.into(),
binary: true,
change_type,
locked_to: Box::new([]),
}
}
@ -82,6 +83,7 @@ impl GitHunk {
diff_lines: Default::default(),
binary: false,
change_type: ChangeType::Modified,
locked_to: Box::new([]),
}
}
}
@ -91,6 +93,21 @@ impl GitHunk {
pub fn contains(&self, line: u32) -> bool {
self.new_start <= line && self.new_start + self.new_lines >= line
}
pub fn with_locks(mut self, locks: &[HunkLock]) -> Self {
self.locked_to = locks.to_owned().into();
self
}
}
// A hunk is locked when it depends on changes in commits that are in your
// workspace. A hunk can be locked to more than one branch if it overlaps
// with more than one committed hunk.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Copy)]
#[serde(rename_all = "camelCase")]
pub struct HunkLock {
pub branch_id: uuid::Uuid,
pub commit_id: git::Oid,
}
#[derive(Debug, PartialEq, Clone, Serialize, Default)]
@ -298,6 +315,7 @@ fn hunks_by_filepath(repo: Option<&Repository>, diff: &git2::Diff) -> Result<Dif
diff_lines: line.into_owned(),
binary: false,
change_type,
locked_to: Box::new([]),
}
}
LineOrHexHash::HexHashOfBinaryBlob(id) => {
@ -404,12 +422,13 @@ pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
diff_lines: diff,
binary: hunk.binary,
change_type: hunk.change_type,
locked_to: Box::new([]),
})
}
}
// TODO(ST): turning this into an iterator will trigger a cascade of changes that
// mean less unnecessary copies. It also leads to `virtual.rs` - 4k SLOC!
pub fn diff_files_into_hunks(files: DiffByPathMap) -> BranchStatus {
HashMap::from_iter(files.into_iter().map(|(path, file)| (path, file.hunks)))
pub fn diff_files_into_hunks(
files: DiffByPathMap,
) -> impl Iterator<Item = (PathBuf, Vec<GitHunk>)> {
files.into_iter().map(|(path, file)| (path, file.hunks))
}

View File

@ -20,6 +20,8 @@ pub enum Error {
Hooks(#[from] git2_hooks::HooksError),
#[error("http error: {0}")]
Http(git2::Error),
#[error("blame error: {0}")]
Blame(git2::Error),
#[error("checkout error: {0}")]
Checkout(git2::Error),
#[error(transparent)]

View File

@ -64,6 +64,8 @@ impl FromStr for Refname {
return Err(Error::NotRemote(value.to_string()));
};
// TODO(ST): use `gix` (which respects refspecs and settings) to do this transformation
// Alternatively, `git2` also has support for respecting refspecs.
let value = value.strip_prefix("refs/remotes/").unwrap();
if let Some((remote, branch)) = value.split_once('/') {

View File

@ -1,6 +1,6 @@
use std::{io::Write, path::Path, str};
use git2::Submodule;
use git2::{BlameOptions, Submodule};
use git2_hooks::HookResult;
use super::{
@ -478,6 +478,24 @@ impl Repository {
git2_hooks::hooks_post_commit(&self.0, Some(&["../.husky"]))?;
Ok(())
}
pub fn blame(
&self,
path: &Path,
min_line: u32,
max_line: u32,
oldest_commit: &Oid,
newest_commit: &Oid,
) -> Result<git2::Blame> {
let mut opts = BlameOptions::new();
opts.min_line(min_line as usize)
.max_line(max_line as usize)
.newest_commit(git2::Oid::from(*newest_commit))
.oldest_commit(git2::Oid::from(*oldest_commit));
self.0
.blame_file(path, Some(&mut opts))
.map_err(super::Error::Blame)
}
}
pub struct CheckoutTreeBuidler<'a> {

View File

@ -1,4 +1,5 @@
use anyhow::Context;
use std::path::PathBuf;
use super::{storage::Storage, PrivateKey};
@ -12,7 +13,7 @@ impl Controller {
Self { storage }
}
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Self {
pub fn from_path(path: impl Into<PathBuf>) -> Self {
Self::new(Storage::from_path(path))
}

View File

@ -1,42 +1,40 @@
use super::PrivateKey;
use crate::storage;
use std::path::PathBuf;
// TODO(ST): get rid of this type, it's more trouble than it's worth.
#[derive(Clone)]
pub struct Storage {
storage: storage::Storage,
inner: storage::Storage,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("IO error: {0}")]
Storage(#[from] storage::Error),
#[error(transparent)]
Storage(#[from] std::io::Error),
#[error("SSH key error: {0}")]
SSHKey(#[from] ssh_key::Error),
}
impl Storage {
pub fn new(storage: storage::Storage) -> Storage {
Storage { storage }
Storage { inner: storage }
}
pub fn from_path<P: AsRef<std::path::Path>>(path: P) -> Storage {
pub fn from_path(path: impl Into<PathBuf>) -> Storage {
Storage::new(storage::Storage::new(path))
}
pub fn get(&self) -> Result<Option<PrivateKey>, Error> {
self.storage
.read("keys/ed25519")
.map_err(Error::Storage)
.and_then(|s| s.map(|s| s.parse().map_err(Error::SSHKey)).transpose())
let key = self.inner.read("keys/ed25519")?;
key.map(|s| s.parse().map_err(Into::into)).transpose()
}
// TODO(ST): see if Key should rather deal with bytes instead for this kind of serialization.
pub fn create(&self, key: &PrivateKey) -> Result<(), Error> {
self.storage
.write("keys/ed25519", &key.to_string())
.map_err(Error::Storage)?;
self.storage
.write("keys/ed25519.pub", &key.public_key().to_string())
.map_err(Error::Storage)?;
self.inner.write("keys/ed25519", &key.to_string())?;
self.inner
.write("keys/ed25519.pub", &key.public_key().to_string())?;
Ok(())
}
}

View File

@ -30,8 +30,10 @@ pub mod project_repository;
pub mod projects;
pub mod reader;
pub mod sessions;
pub mod snapshots;
pub mod ssh;
pub mod storage;
pub mod time;
pub mod types;
pub mod users;
pub mod virtual_branches;

View File

@ -95,6 +95,8 @@ pub fn conflicting_files(repository: &Repository) -> Result<Vec<String>> {
Ok(reader.lines().map_while(Result::ok).collect())
}
/// Check if `path` is conflicting in `repository`, or if `None`, check if there is any conflict.
// TODO(ST): Should this not rather check the conflicting state in the index?
pub fn is_conflicting<P: AsRef<Path>>(repository: &Repository, path: Option<P>) -> Result<bool> {
let conflicts_path = repository.git_repository.path().join("conflicts");
if !conflicts_path.exists() {
@ -105,6 +107,7 @@ pub fn is_conflicting<P: AsRef<Path>>(repository: &Repository, path: Option<P>)
let reader = std::io::BufReader::new(file);
let mut files = reader.lines().map_ok(PathBuf::from);
if let Some(pathname) = path {
// TODO(ST): This shouldn't work on UTF8 strings.
let pathname = pathname.as_ref();
// check if pathname is one of the lines in conflicts_path file

View File

@ -61,7 +61,7 @@ impl Repository {
// XXX(qix-): We will ultimately move away from an internal repository for a variety
// XXX(qix-): of reasons, but for now, this is a simple, short-term solution that we
// XXX(qix-): can clean up later on. We're aware this isn't ideal.
if let Ok(config) = git_repository.config().as_mut(){
if let Ok(config) = git_repository.config().as_mut() {
let should_set = match config.get_bool("gitbutler.didSetPrune") {
Ok(None | Some(false)) => true,
Ok(Some(true)) => false,
@ -76,7 +76,10 @@ impl Repository {
};
if should_set {
if let Err(error) = config.set_str("gc.pruneExpire", "never").and_then(|()| config.set_bool("gitbutler.didSetPrune", true)) {
if let Err(error) = config
.set_str("gc.pruneExpire", "never")
.and_then(|()| config.set_bool("gitbutler.didSetPrune", true))
{
tracing::warn!(
"failed to set gc.auto to false for repository at {}; cannot disable gc: {}",
project.path.display(),
@ -175,17 +178,11 @@ impl Repository {
let branch = self.git_repository.find_branch(&target_branch_refname)?;
let commit_id = branch.peel_to_commit()?.id();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::from_secs(0))
.as_millis()
.to_string();
let branch_name = format!("test-push-{}", now);
let now = crate::time::now_ms();
let branch_name = format!("test-push-{now}");
let refname = git::RemoteRefname::from_str(&format!(
"refs/remotes/{}/{}",
remote_name, branch_name,
))?;
let refname =
git::RemoteRefname::from_str(&format!("refs/remotes/{remote_name}/{branch_name}",))?;
match self.push(
&commit_id,
@ -623,6 +620,8 @@ pub enum RemoteError {
Network,
#[error("authentication failed")]
Auth,
#[error("Git failed")]
Git(#[from] git::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
@ -638,6 +637,9 @@ impl ErrorWithContext for RemoteError {
Code::ProjectGitAuth,
"Project remote authentication error",
),
RemoteError::Git(_) => {
error::Context::new_static(Code::ProjectGitRemote, "Git command failed")
}
RemoteError::Other(error) => {
return error.custom_context_or_root_cause().into();
}

Some files were not shown because too many files have changed in this diff Show More