diff --git a/Cargo.lock b/Cargo.lock index 9469713900..f0e55a9371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -486,12 +486,14 @@ dependencies = [ "axum-extra", "bincode", "blake3", + "chbs", "chrono", "clap 4.3.0", "color-eyre", "compact_str", "console-subscriber", "criterion", + "diffy", "directories", "dunce", "either", @@ -500,6 +502,7 @@ dependencies = [ "flume", "futures", "git-version", + "git2", "gix", "histogram", "hyperpolyglot", @@ -827,6 +830,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chbs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a7298287f1443f422d3f46e8ce9f855e75f0e43c06605adb4c52a262faeabd" +dependencies = [ + "derive_builder 0.10.2", + "getrandom 0.2.9", + "rand 0.8.5", + "thiserror", +] + [[package]] name = "chrono" version = "0.4.24" @@ -1417,6 +1432,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" +dependencies = [ + "darling_core 0.12.4", + "darling_macro 0.12.4", +] + [[package]] name = "darling" version = "0.14.4" @@ -1437,6 +1462,20 @@ dependencies = [ "darling_macro 0.20.1", ] +[[package]] +name = "darling_core" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.14.4" @@ -1465,6 +1504,17 @@ dependencies = [ "syn 2.0.16", ] +[[package]] +name = "darling_macro" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" +dependencies = [ + "darling_core 0.12.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.14.4" @@ -1497,13 +1547,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "derive_builder" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" +dependencies = [ + "derive_builder_macro 0.10.2", +] + [[package]] name = "derive_builder" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" +dependencies = [ + "darling 0.12.4", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -1518,13 +1589,23 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" +dependencies = [ + "derive_builder_core 0.10.2", + "syn 1.0.109", +] + [[package]] name = "derive_builder_macro" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.12.0", "syn 1.0.109", ] @@ -1547,6 +1628,14 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "diffy" +version = "0.3.0" +source = "git+https://github.com/bloopai/diffy#ca246a24e868f208537bded1fb2d86df31d5dede" +dependencies = [ + "nu-ansi-term", +] + [[package]] name = "digest" version = "0.10.7" @@ -2301,6 +2390,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "git2" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b989d6a7ca95a362cf2cfc5ad688b3a467be1f87e480b8dad07fee8c79b0044" +dependencies = [ + "bitflags 1.3.2", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "gix" version = "0.44.1" @@ -3812,6 +3916,19 @@ version = "0.2.144" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" +[[package]] +name = "libgit2-sys" +version = "0.15.2+1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a80df2e11fb4a61f4ba2ab42dbe7f74468da143f1a75c74e11dee7c813f694fa" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libsqlite3-sys" version = "0.24.2" @@ -3823,6 +3940,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ee889ecc9568871456d42f603d6a0ce59ff328d291063a45cbdf0036baf6db" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-wrap" version = "0.1.1" @@ -5812,9 +5941,9 @@ dependencies = [ [[package]] name = "sentry" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37dd6c0cdca6b1d1ca44cde7fff289f2592a97965afec870faa7b81b9fc87745" +checksum = "234f6e133d27140ad5ea3b369a7665f7fbc060fe246f81d8168665b38c08b600" dependencies = [ "httpdate", "native-tls", @@ -5830,9 +5959,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c029fe8317cdd75cb2b52c600bab4e2ef64c552198e669ba874340447f330962" +checksum = "d89b6b53de06308dd5ac08934b597bcd72a9aae0c20bc3ab06da69cb34d468e3" dependencies = [ "backtrace", "once_cell", @@ -5842,9 +5971,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc575098d73c8b942b589ab453b06e4c43527556dd8f95532220d1b54d7c6b4b" +checksum = "0769b66763e59976cd5c0fd817dcd51ccce404de8bebac0cd0e886c55b0fffa8" dependencies = [ "hostname", "libc", @@ -5856,9 +5985,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20216140001bbf05895f013abd0dae4df58faee24e016d54cbf107f070bac56b" +checksum = "a1f954f1b89e8cd82576dc49bfab80304c9a6201343b4fe5c68c819f7a9bbed2" dependencies = [ "once_cell", "rand 0.8.5", @@ -5869,9 +5998,9 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4886e99be0a23d3f5563d74503ae97cb3443af3b0d7004e084b2ad6f7c01c678" +checksum = "a8ddb9b6d43d251b41b792079218ef2d688bd88f01df454d338771cc146bde1a" dependencies = [ "findshlibs", "once_cell", @@ -5880,9 +6009,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e45cd0a113fc06d6edba01732010518816cdc8ce3bccc70f5e41570046bf046" +checksum = "94dc2ab494362ad51308c7c19f44e9ab70e426a931621e4a05f378a1e74558c2" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5890,9 +6019,9 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef4111647923c797687094bc792b8da938c4b0d64fab331d5b7a7de41964de8" +checksum = "d0933cf65123955ddc6b95b10c73b3fdd2032a973768e072de1afd6fd2d80e3d" dependencies = [ "sentry-core", "tracing-core", @@ -5901,9 +6030,9 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7f6959d8cb3a77be27e588eef6ce9a2a469651a556d9de662e4d07e5ace4232" +checksum = "85c53caf80cb1c6fcdf4d82b7bfff8477f50841e4caad7bf8e5e57a152b564cb" dependencies = [ "debugid", "getrandom 0.2.9", @@ -7041,7 +7170,7 @@ dependencies = [ "aho-corasick 0.7.20", "cached-path", "clap 4.3.0", - "derive_builder", + "derive_builder 0.12.0", "dirs", "esaxx-rs", "getrandom 0.2.9", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e4627b912a..dc4632655a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -17,7 +17,7 @@ tauri-build = { version = "=1.2.1", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "1.2.0", features = ["dialog-open", "fs-all", "http-all", "native-tls-vendored", "os-all", "path-all", "process-all", "shell-all", "updater", "window-all"] } +tauri = { version = "1.2.5", features = ["dialog-open", "fs-all", "http-all", "native-tls-vendored", "os-all", "path-all", "process-all", "shell-all", "updater", "window-all"] } bleep = { path = "../../../server/bleep", package = "bleep" } anyhow = "1.0.71" tokio = { version = "1.28.1", features = ["rt-multi-thread"] } @@ -25,7 +25,7 @@ tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } color-eyre = "0.6.2" once_cell = "1.17.1" -sentry = "0.31.1" +sentry = "0.31.2" qdrant-client = "1.1.2" git-version = "0.3.5" diff --git a/client/src/components/Button/index.tsx b/client/src/components/Button/index.tsx index fdfb5a0ca5..8bc0c7ea57 100644 --- a/client/src/components/Button/index.tsx +++ b/client/src/components/Button/index.tsx @@ -11,7 +11,12 @@ import Tooltip from '../Tooltip'; type Props = { children: ReactNode; - variant?: 'primary' | 'secondary' | 'tertiary' | 'tertiary-outlined'; + variant?: + | 'primary' + | 'primary-outlined' + | 'secondary' + | 'tertiary' + | 'tertiary-outlined'; size?: 'tiny' | 'small' | 'medium' | 'large'; className?: string; } & (OnlyIconProps | TextBtnProps); @@ -19,17 +24,21 @@ type Props = { type OnlyIconProps = { onlyIcon: true; title: string; + tooltipText?: undefined; tooltipPlacement?: TippyProps['placement']; }; type TextBtnProps = { onlyIcon?: false; tooltipPlacement?: never; + tooltipText?: string | ReactNode; }; const variantStylesMap = { primary: 'text-label-control bg-bg-main hover:bg-bg-main-hover focus:bg-bg-main-hover active:bg-bg-main active:shadow-rings-blue disabled:bg-bg-base disabled:text-label-muted disabled:hover:border-none disabled:hover:bg-bg-base disabled:active:shadow-none disabled:border-none', + 'primary-outlined': + 'text-bg-main bg-transparent border border-bg-main hover:border-bg-main-hover focus:border-bg-main-hover active:border-bg-main active:shadow-rings-blue disabled:bg-bg-base disabled:text-label-muted disabled:hover:border-none disabled:hover:bg-bg-base disabled:active:shadow-none disabled:border-none', secondary: 'text-label-title bg-bg-base border border-bg-border hover:border-bg-border-hover hover:bg-bg-base-hover focus:border-bg-border-hover active:bg-bg-base disabled:bg-bg-base disabled:border-none disabled:text-label-muted shadow-low hover:shadow-none focus:shadow-none active:shadow-rings-gray disabled:shadow-none', tertiary: @@ -78,6 +87,7 @@ const Button = forwardRef< title, tooltipPlacement, type = 'button', + tooltipText, ...rest }, ref, @@ -93,8 +103,8 @@ const Button = forwardRef< } transition-all duration-300 ease-in-bounce select-none`, [variant, className, size, onlyIcon], ); - return onlyIcon && !rest.disabled ? ( - + return (onlyIcon && !rest.disabled) || tooltipText ? ( + diff --git a/client/src/components/Tooltip/index.tsx b/client/src/components/Tooltip/index.tsx index 838bab5bb0..8e7e700f66 100644 --- a/client/src/components/Tooltip/index.tsx +++ b/client/src/components/Tooltip/index.tsx @@ -70,12 +70,12 @@ const Tooltip = ({ text, placement, children }: PropsWithChildren) => { render={(attrs) => ( ) => { {getTail(attrs['data-placement'])} {text} diff --git a/client/src/icons/GitPod.tsx b/client/src/icons/GitPod.tsx new file mode 100644 index 0000000000..73661fad44 --- /dev/null +++ b/client/src/icons/GitPod.tsx @@ -0,0 +1,31 @@ +import IconWrapper from './Wrapper'; + +const RawIcon = ( + + + +); + +const BoxedIcon = ( + + + +); + +export default IconWrapper(RawIcon, BoxedIcon); diff --git a/client/src/icons/index.ts b/client/src/icons/index.ts index 2da79d7fe3..3c898f08b2 100644 --- a/client/src/icons/index.ts +++ b/client/src/icons/index.ts @@ -101,3 +101,4 @@ export { default as Feather } from './Feather'; export { default as FeatherSelected } from './FeatherSelected'; export { default as Info } from './Info'; export { default as Unlike } from './Unlike'; +export { default as GitPod } from './GitPod'; diff --git a/client/src/pages/ConversationResult/Diff/DiffCode.tsx b/client/src/pages/ConversationResult/Diff/DiffCode.tsx new file mode 100644 index 0000000000..bfc1e9ce66 --- /dev/null +++ b/client/src/pages/ConversationResult/Diff/DiffCode.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Code from '../../../components/CodeBlock/Code'; +import Button from '../../../components/Button'; +import { CheckIcon, Clipboard } from '../../../icons'; +import { copyToClipboard } from '../../../utils'; +import FileIcon from '../../../components/FileIcon'; +import { MessageResultModify } from '../../../types/general'; +import { FileTreeFileType } from '../../../types'; +import BreadcrumbsPath from '../../../components/BreadcrumbsPath'; +import FileModalContainer from '../../ResultModal/FileModalContainer'; +import StagedBtn from './StagedBtn'; + +type Props = { + data: MessageResultModify['Modify']; + repoName: string; + i: number; + isStaged: boolean; + isSubmitted: boolean; + isFinished: boolean; + onStage: (i: number) => void; + onUnstage: (i: number) => void; +}; + +const DiffCode = ({ + data, + repoName, + i, + isStaged, + isSubmitted, + isFinished, + onStage, + onUnstage, +}: Props) => { + const [showRaw, setShowRaw] = useState(false); + const [isModalOpen, setModalOpen] = useState(false); + const [scrollToLine, setScrollToLine] = useState( + undefined, + ); + + const rawCode = useMemo( + () => + data.diff.lines + ?.filter((l) => !l.startsWith('-')) + .map((l) => (l.startsWith('+') ? ' ' + l.slice(1) : l)) + .join('\n'), + [data.diff.lines], + ); + + const onResultClick = useCallback( + (path: string, lineNumber?: number[]) => { + setScrollToLine(lineNumber ? lineNumber.join('_') : undefined); + setModalOpen(true); + }, + [repoName], + ); + + return ( +
+
+
onResultClick(data.path)} + > +
+ + +
+ + + type === FileTreeFileType.FILE ? onResultClick(path) : {} + } + /> +
+
+ {isFinished && isSubmitted ? ( + isStaged ? ( +
+
+ +
+ Committed +
+ ) : ( + + ) + ) : isFinished ? ( +
+ {isStaged ? ( + onUnstage(i)} /> + ) : ( + + )} +
+ ) : null} +
+
+ {data.diff?.lines ? ( +
+
+ +
+
+ +
+
+ ) : null} + setModalOpen(false)} + scrollToLine={scrollToLine} + /> +
+ ); +}; + +export default DiffCode; diff --git a/client/src/pages/ConversationResult/Diff/StagedBtn.tsx b/client/src/pages/ConversationResult/Diff/StagedBtn.tsx new file mode 100644 index 0000000000..8693cbdd4c --- /dev/null +++ b/client/src/pages/ConversationResult/Diff/StagedBtn.tsx @@ -0,0 +1,21 @@ +import React, { useState } from 'react'; +import { CheckIcon } from '../../../icons'; +import Button from '../../../components/Button'; + +const StagedBtn = ({ onClick }: { onClick: () => void }) => { + const [isMouseOver, setMouseOver] = useState(false); + return ( + + ); +}; + +export default StagedBtn; diff --git a/client/src/pages/ConversationResult/Diff/index.tsx b/client/src/pages/ConversationResult/Diff/index.tsx new file mode 100644 index 0000000000..1617177a5c --- /dev/null +++ b/client/src/pages/ConversationResult/Diff/index.tsx @@ -0,0 +1,122 @@ +import { useCallback, useContext, useState } from 'react'; +import { MessageResultModify } from '../../../types/general'; +import Button from '../../../components/Button'; +import { GitPod, Info } from '../../../icons'; +import { commitChanges } from '../../../services/api'; +import { DeviceContext } from '../../../context/deviceContext'; +import ThreeDotsLoader from '../../../components/Loaders/ThreeDotsLoader'; +import DiffCode from './DiffCode'; + +type Props = { + repoName: string; + repoRef: string; + diffs: MessageResultModify['Modify'][]; + isFinished: boolean; +}; + +const Diff = ({ diffs, repoName, repoRef, isFinished }: Props) => { + const { openLink } = useContext(DeviceContext); + const [staged, setStaged] = useState([]); + const [isSubmitted, setSubmitted] = useState(false); + const [error, setError] = useState(''); + const [gitpodLink, setGitPodLink] = useState(''); + + const onStage = useCallback((i: number) => { + setStaged((prev) => [...prev, i]); + }, []); + const onUnstage = useCallback((i: number) => { + setStaged((prev) => prev.filter((p) => p !== i)); + }, []); + const onStageAll = useCallback(() => { + setStaged(diffs.map((_, i) => i)); + }, []); + + const onSubmit = useCallback(async () => { + setSubmitted(true); + try { + const { branch_name, commit_id } = await commitChanges({ + repo: repoRef, + push: true, + changes: staged.map((i) => { + return { + path: diffs[i].path, + diff: diffs[i].raw + '\n', + }; + }), + }); + setError(''); + setGitPodLink( + `https://gitpod.io/#https://${repoRef}/commit/${commit_id}`, + ); + } catch (err) { + setSubmitted(false); + setError('There was an error making this commit'); + } + }, [diffs, staged]); + + return ( +
+
+ {isSubmitted ? ( +
+ +
+ ) : isFinished ? ( +
+ + +
+ ) : null} +
+ {!!error && ( +
+
+ +
+

Committing your changes has failed. Please try again later.

+
+ )} + {diffs.map((d, i) => ( + + ))} +
+ ); +}; + +export default Diff; diff --git a/client/src/pages/ConversationResult/DiffCode.tsx b/client/src/pages/ConversationResult/DiffCode.tsx deleted file mode 100644 index c0c250653d..0000000000 --- a/client/src/pages/ConversationResult/DiffCode.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import Code from '../../components/CodeBlock/Code'; -import Button from '../../components/Button'; -import { Clipboard } from '../../icons'; -import { copyToClipboard } from '../../utils'; -import FileIcon from '../../components/FileIcon'; -import { MessageResultModify } from '../../types/general'; -import { FileTreeFileType } from '../../types'; -import BreadcrumbsPath from '../../components/BreadcrumbsPath'; -import FileModalContainer from '../ResultModal/FileModalContainer'; - -type Props = { - data: MessageResultModify['Modify']; - repoName: string; -}; - -const DiffCode = ({ data, repoName }: Props) => { - const [showRaw, setShowRaw] = useState(false); - const [isModalOpen, setModalOpen] = useState(false); - const [scrollToLine, setScrollToLine] = useState( - undefined, - ); - - const rawCode = useMemo( - () => - data.diff.lines - ?.filter((l) => !l.startsWith('-')) - .map((l) => (l.startsWith('+') ? ' ' + l.slice(1) : l)) - .join('\n'), - [data.diff.lines], - ); - - const onResultClick = useCallback( - (path: string, lineNumber?: number[]) => { - setScrollToLine(lineNumber ? lineNumber.join('_') : undefined); - setModalOpen(true); - }, - [repoName], - ); - - return ( -
-
-
onResultClick(data.path)} - > - - - type === FileTreeFileType.FILE ? onResultClick(path) : {} - } - /> -
-
- - -
-
- {data.diff?.lines ? ( -
-
- -
-
- -
-
- ) : null} - setModalOpen(false)} - scrollToLine={scrollToLine} - /> -
- ); -}; - -export default DiffCode; diff --git a/client/src/pages/ConversationResult/index.tsx b/client/src/pages/ConversationResult/index.tsx index f90451b8cb..01db2ecfbf 100644 --- a/client/src/pages/ConversationResult/index.tsx +++ b/client/src/pages/ConversationResult/index.tsx @@ -6,14 +6,15 @@ import { ChatMessageServer, MessageResultCite, MessageResultDirectory, + MessageResultModify, } from '../../types/general'; import { UIContext } from '../../context/uiContext'; import { ChevronDown } from '../../icons'; import { conversationsCache } from '../../services/cache'; import NewCode from './NewCode'; -import DiffCode from './DiffCode'; import CodeAnnotation, { Comment } from './CodeAnotation'; import DirectoryAnnotation from './DirectoryAnnotation'; +import Diff from './Diff'; type Props = { recordId: number; @@ -42,6 +43,17 @@ const ConversationResult = ({ recordId, threadId }: Props) => { threadId, ], ); + const isFinished = useMemo( + () => + conversationsCache[threadId]?.[recordId] + ? true + : !(conversation[recordId] as ChatMessageServer).isLoading, + [ + threadId, + recordId, + (conversation[recordId] as ChatMessageServer).isLoading, + ], + ); const citations = useMemo(() => { const files: Record = {}; data @@ -77,8 +89,14 @@ const ConversationResult = ({ recordId, threadId }: Props) => { }); return files; }, [data]); + const diffs = useMemo(() => { + return data + .filter((d): d is MessageResultModify => 'Modify' in d && !!d.Modify.diff) + .map((d) => d.Modify); + }, [data]); const otherBlocks = useMemo( - () => data.filter((d) => !('Cite' in d || 'Directory' in d)), + () => + data.filter((d) => !('Cite' in d || 'Directory' in d || 'Modify' in d)), [data], ); @@ -130,7 +148,7 @@ const ConversationResult = ({ recordId, threadId }: Props) => { return (
@@ -153,15 +171,29 @@ const ConversationResult = ({ recordId, threadId }: Props) => { loading={false} />
- - + {!!Object.keys(citations).length && ( + + )} + {!!Object.keys(dirCitations).length && ( + + )} + {!!diffs.length && ( + + )} {otherBlocks.map((b, i) => { if ('New' in b && b.New.code && b.New.language) { return ( ); - } else if ('Modify' in b && b.Modify.diff) { - return ; } })}
diff --git a/client/src/services/api.ts b/client/src/services/api.ts index abc6daea51..d98ddf3623 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -203,3 +203,6 @@ export const deleteConversation = ( http .delete(`/answer/conversations`, { params: { thread_id } }) .then((r) => r.data); + +export const commitChanges = (data: any) => + http.post('/repos/branch', data).then((r) => r.data); diff --git a/client/src/types/general.ts b/client/src/types/general.ts index 135e096cc9..2f512462b1 100644 --- a/client/src/types/general.ts +++ b/client/src/types/general.ts @@ -176,6 +176,7 @@ export type MessageResultModify = { }; lines: string[]; }; + raw: string; }; }; diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 699ee5d547..e0de29be45 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -17,6 +17,7 @@ module.exports = { "bg-main": "rgb(var(--bg-main))", "bg-main-hover": "rgb(var(--bg-main-hover))", "bg-danger": "rgb(var(--bg-danger))", + "bg-danger/8": "rgba(var(--bg-danger), 0.08)", "bg-danger/30": "rgba(var(--bg-danger), 0.3)", "bg-danger-hover": "rgb(var(--bg-danger-hover))", "bg-success": "rgb(var(--bg-success))", diff --git a/server/bleep/Cargo.toml b/server/bleep/Cargo.toml index b73441ca4e..7c68c0eeaf 100644 --- a/server/bleep/Cargo.toml +++ b/server/bleep/Cargo.toml @@ -111,15 +111,18 @@ tiktoken-rs = "0.4.2" semver = { version = "1", features = ["serde"] } # telemetry -sentry = "0.31.1" +sentry = "0.31.2" rudderanalytics = "1.1.2" async-stream = "0.3.5" erased-serde = "0.3.25" scc = { version= "1.8.0", features = ["serde"] } -sentry-tracing = "0.31.1" +sentry-tracing = "0.31.2" git-version = "0.3.5" gix = { version="0.44.1", features = ["blocking-http-transport-reqwest", "blocking-network-client", "pack-cache-lru-static"] } thread-priority = "0.13.1" +chbs = "0.1.1" +diffy = { git = "https://github.com/bloopai/diffy" } +git2 = { version = "0.17.1", default-features = false, features = ["https"] } [target.'cfg(windows)'.dependencies] dunce = "1.0.4" diff --git a/server/bleep/src/background.rs b/server/bleep/src/background.rs index eb62495e1d..69d24b967b 100644 --- a/server/bleep/src/background.rs +++ b/server/bleep/src/background.rs @@ -50,6 +50,7 @@ pub struct SyncQueue { #[derive(Clone)] pub struct BackgroundExecutor { sender: flume::Sender, + tokio: tokio::runtime::Handle, } pub struct BoundSyncQueue(Application, SyncQueue); @@ -84,13 +85,17 @@ impl BackgroundExecutor { .num_threads(config.max_threads) .build_global(); + let tokio_ref = tokio.clone(); thread::spawn(move || { while let Ok(task) = receiver.recv() { - tokio.spawn(task); + tokio_ref.spawn(task); } }); - Self { sender } + Self { + sender, + tokio: tokio.handle().clone(), + } } fn spawn(&self, job: impl Future + Send + Sync + 'static) { @@ -100,16 +105,6 @@ impl BackgroundExecutor { })) .unwrap(); } - - #[allow(unused)] - pub async fn wait_for( - &self, - job: impl Future + Send + Sync + 'static, - ) -> T { - let (s, r) = flume::bounded(1); - self.spawn(async move { s.send_async(job.await).await.unwrap() }); - r.recv_async().await.unwrap() - } } impl SyncQueue { @@ -200,4 +195,16 @@ impl BoundSyncQueue { Ok(()) } + + pub fn wait_for( + self, + job: impl Future + Send + Sync + 'static, + ) -> T { + let (tx, rx) = flume::bounded(1); + self.1.runner.tokio.spawn(Box::pin(async move { + _ = tx.send(job.await); + })); + + rx.recv().unwrap() + } } diff --git a/server/bleep/src/remotes.rs b/server/bleep/src/remotes.rs index 9241c9b1c9..23d6bd46b3 100644 --- a/server/bleep/src/remotes.rs +++ b/server/bleep/src/remotes.rs @@ -83,6 +83,9 @@ pub(crate) enum RemoteError { #[error("git clone fetch: {0:?}")] GitCloneFetch(#[from] gix::clone::fetch::Error), + + #[error("git push error: {0:?}")] + GitPush(#[from] git2::Error), } macro_rules! creds_callback(($auth:ident) => {{ @@ -103,6 +106,35 @@ macro_rules! creds_callback(($auth:ident) => {{ } }}); +pub(crate) async fn git_push(auth: GitCreds, target: &Path, branch: &str) -> Result<()> { + let target = target.to_owned(); + let branch = branch.to_owned(); + + tokio::task::spawn_blocking(move || { + let git = git2::Repository::open(target)?; + let mut remote = { + let remotes = git.remotes()?; + let remote_name = remotes.get(0).context("invalid repo, no remotes")?; + git.find_remote(remote_name)? + }; + let cb = { + let mut cb = git2::RemoteCallbacks::new(); + cb.credentials(|_, _, _| { + git2::Cred::userpass_plaintext(&auth.username, &auth.password) + }); + cb + }; + + remote.push( + &[&branch], + Some(git2::PushOptions::new().remote_callbacks(cb)), + )?; + + Ok(()) + }) + .await? +} + async fn git_clone(auth: GitCreds, url: &str, target: &Path) -> Result<()> { let url = url.to_owned(); let target = target.to_owned(); @@ -301,6 +333,22 @@ pub(crate) enum BackendCredential { } impl BackendCredential { + pub(crate) async fn push( + self, + app: &Application, + reporef: &RepoRef, + branch: &str, + ) -> Result<()> { + let BackendCredential::Github(gh) = self; + let repo = app + .repo_pool + .read_async(reporef, |_, r| r.clone()) + .await + .context("missing repo")?; + + gh.auth.push_repo(repo, branch).await + } + pub(crate) async fn sync(self, sync_handle: &SyncHandle) -> Result<()> { let SyncHandle { app, .. } = sync_handle; diff --git a/server/bleep/src/remotes/github.rs b/server/bleep/src/remotes/github.rs index 6c198bf53c..8f106de0bc 100644 --- a/server/bleep/src/remotes/github.rs +++ b/server/bleep/src/remotes/github.rs @@ -138,6 +138,10 @@ impl Auth { git_pull(self.git_cred(), &repo).await } + pub(crate) async fn push_repo(&self, repo: Repository, branch: &str) -> Result<()> { + git_push(self.git_cred(), &repo.disk_path, branch).await + } + pub async fn check_repo(&self, repo: &Repository) -> Result<()> { let RepoRemote::Git(GitRemote { ref address, .. diff --git a/server/bleep/src/webserver.rs b/server/bleep/src/webserver.rs index 5dce2a3b9e..89226f18b2 100644 --- a/server/bleep/src/webserver.rs +++ b/server/bleep/src/webserver.rs @@ -1,5 +1,6 @@ use crate::{env::Feature, Application}; +use axum::routing::post; use axum::{http::StatusCode, response::IntoResponse, routing::get, Extension, Json}; use std::{borrow::Cow, net::SocketAddr}; use tower::Service; @@ -12,6 +13,7 @@ pub mod answer; mod autocomplete; mod config; mod file; +mod git; mod github; mod hoverable; mod index; @@ -57,6 +59,7 @@ pub async fn start(app: Application) -> anyhow::Result<()> { get(repos::get_by_id).delete(repos::delete_by_id), ) .route("/repos/sync/*path", get(repos::sync)) + .route("/repos/branch", post(git::create_branch)) // intelligence .route("/hoverable", get(hoverable::handle)) .route("/token-info", get(intelligence::handle)) diff --git a/server/bleep/src/webserver/answer.rs b/server/bleep/src/webserver/answer.rs index 895d9747d3..a4ea5460a4 100644 --- a/server/bleep/src/webserver/answer.rs +++ b/server/bleep/src/webserver/answer.rs @@ -795,7 +795,7 @@ impl Conversation { } if !self.code_chunks.is_empty() { - s += "\n##### CODE CHUNKS #####\n\n"; + s += "\n##### ENUMERATED CODE CHUNKS #####\n\n"; } let code_chunks = if path_aliases.len() == 1 { @@ -921,6 +921,7 @@ impl Conversation { } item }) + .flat_map(|v| v.expand()) .map(|s| s.substitute_path_alias(&self.paths)) .collect::>(); diff --git a/server/bleep/src/webserver/answer/prompts.rs b/server/bleep/src/webserver/answer/prompts.rs index 74792c943b..f1e463fcb8 100644 --- a/server/bleep/src/webserver/answer/prompts.rs +++ b/server/bleep/src/webserver/answer/prompts.rs @@ -69,15 +69,9 @@ Follow these rules at all times: - Do not assume the structure of the codebase, or the existence of files or folders - Do NOT use a tool that you've used before with the same arguments - To perform multiple actions, perform just one, wait for the response, then perform the next -- When you are confident that you have enough information needed to answer the query, choose 'none' -- If you have been instructed to modify the codebase choose 'none' -- If after making a path search the query can be answered by the existance of the paths, and there are more than 5 paths, choose 'none' -- Only refer to path aliases that are under the PATHS heading above -- Use the tools to find information related to the query, until all relevant information has been found. -- If after attempting to gather information you are still unsure how to answer the query, choose 'none' -- Always respond according to the schema of the tool that you want to use -- Output a list of [name, *args] to use a tool. For example to use codeSearch, output: ["code","my search query"]. To use processFiles, output: ["proc", "how does X work", [3, 6]] -- Do NOT answer the user's query directly. You MUST use one of the tools above +- If after attempting to gather information you are still unsure how to answer the query, choose the "No tool left to take" tool +- If the user asks you to change code, respond only with the best tool to find the code that needs to be changed +- Todays date is {today} "#); s @@ -143,22 +137,43 @@ pub fn final_explanation_prompt(context: &str, query: &str, query_history: &str) example: Some(r#"The path is a relative path, with no leading slash. You must generate a trailing slash, for example: server/bleep/src/webserver/. On Windows, generate backslash separated components, for example: server\bleep\src\webserver\"#), }, Rule { - title: "Write a new code file", - description: "Write a new code file that satisfies the query. Do not use this to demonstrate updating an existing file.", - schema: "[\"new\",LANGUAGE:STRING,CODE:STRING]", + title: "Write a new code file in a unified diff format", + description: "Write a new code file that satisfies the query.", + schema: "[\"new\",null,LANGUAGE:STRING,DESCRIPTION:STRING,GIT DIFF WITHOUT LINE NUMBERS:STRING,NEW PATH:STRING]", note: "This object can occur multiple times", - example: None, + example: Some(r#"DESCRIPTION is a natural language description of the changes + PATH ALIAS: Always `null` + NEW PATH: path for the newly created file. + GIT DIFF WITHOUT LINE NUMBERS describes the unified diff for the file, including the git diff header. Do not include any line numbers + For example: + ``` +@@ -1,7 +1,6 @@ +- The Way that can be told of is not the eternal Way; +- The name that can be named is not the eternal name. + The Nameless is the origin of Heaven and Earth; +- The Named is the mother of all things. ++ The named is the mother of all things. ++ + Therefore let there always be non-being, + so we may see their subtlety, + And let there always be being, +@@ -9,3 +8,6 @@ + The two are the same, + But after they are produced, + they have different names. ++ They both may be called deep and profound. ++ Deeper and more profound, ++ The door of all subtleties! +```"#), }, Rule { title: "Update the code in an existing file", description: "Edit an existing code file by generating the diff between old and new versions. Changes should be as small as possible.", - schema: "[\"mod\",PATH ALIAS:INT,LANGUAGE:STRING,GIT DIFF:STRING]", + schema: "[\"mod\",PATH ALIAS:INT,LANGUAGE:STRING,DESCRIPTION:STRING,GIT DIFF WITHOUT LINE NUMBERS:STRING]", note: "This object can occur multiple times", - example: Some(r#"Where GIT DIFF describes the diff chunks for the file, including the git diff header. -For example: -@@ -1 +1 @@ --this is a git diff test example -+this is a diff example"#), + example: Some(r#"DESCRIPTION is a natural language description of the changes + PATH ALIAS: a number referencing the file to edit + GIT DIFF WITHOUT LINE NUMBERS describes the unified diff for the file, including the git diff header. Do not include any line numbers."#), }, Rule { title: "Cite line ranges from the file", @@ -228,6 +243,19 @@ What's the value of MAX_FILE_LEN? ["con": "None of files in the context contain the value of MAX_FILE_LEN"] ] +Remove b and c + +[ + ["mod",2,"javascript","remove the last two lines", "@@ -1,3 +1 @@\na\n-b\n-c\n"] +] + +Bump the dependency to 0.3 + +[ + ["mod",3,"javascript","remove the 2nd line, add a new 2nd line with 0.3", "@@ -1,2 +1,2 @@\n-import test from 'test';\n-import test2 from 'test2@0.2';\n+import test2 from 'test2@0.3';\n"] +] + + ##### {query_history} diff --git a/server/bleep/src/webserver/answer/response.rs b/server/bleep/src/webserver/answer/response.rs index be43e07b01..ecb8543357 100644 --- a/server/bleep/src/webserver/answer/response.rs +++ b/server/bleep/src/webserver/answer/response.rs @@ -102,7 +102,7 @@ pub enum Update { pub enum SearchResult { Cite(CiteResult), Directory(DirectoryResult), - New(NewResult), + New(ModifyResult), Modify(ModifyResult), Conclude(ConcludeResult), } @@ -114,7 +114,7 @@ impl SearchResult { match tag.as_str()? { "cite" => CiteResult::from_json_array(&v[1..]).map(Self::Cite), "dir" => DirectoryResult::from_json_array(&v[1..]).map(Self::Directory), - "new" => NewResult::from_json_array(&v[1..]).map(Self::New), + "new" => ModifyResult::from_json_array(&v[1..]).map(Self::Modify), "mod" => ModifyResult::from_json_array(&v[1..]).map(Self::Modify), "con" => ConcludeResult::from_json_array(&v[1..]).map(Self::Conclude), _ => None, @@ -139,6 +139,13 @@ impl SearchResult { s => s, } } + + pub(crate) fn expand(self) -> Vec { + match self { + SearchResult::Modify(modify) => modify.expand(), + _ => vec![self], + } + } } #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] @@ -169,7 +176,10 @@ pub struct ModifyResult { path_alias: Option, path: Option, language: Option, + description: Option, diff: Option, + raw: Option, + new_file: Option, } #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] @@ -265,44 +275,83 @@ impl ModifyResult { .get(1) .and_then(serde_json::Value::as_str) .map(ToOwned::to_owned); + let diff = v - .get(2) + .get(3) + .and_then(serde_json::Value::as_str) + .and_then(Self::parse_hunk); + + let raw = v + .get(3) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + + let new_file = v + .get(4) .and_then(serde_json::Value::as_str) - .map(|raw_hunk| { - let header = raw_hunk.lines().next().and_then(|s| s.parse().ok()); - let lines = raw_hunk - .lines() - .skip(1) - .map(ToOwned::to_owned) - .collect::>(); - ModifyResultHunk { header, lines } - }); + .map(str::to_string); Some(Self { path_alias, language, diff, + raw, + new_file, ..Default::default() }) } fn substitute_path_alias(mut self, path_aliases: &[String]) -> Self { - self.path = self - .path_alias - .as_ref() - .and_then(|alias| { - if let Some(p) = path_aliases.get(*alias as usize) { - Some(p) - } else { - tracing::warn!("no path found for alias `{alias}`"); - for (idx, p) in path_aliases.iter().enumerate() { - tracing::warn!("we have {idx}. {p}"); + self.path = self.new_file.clone().or_else(|| { + self.path_alias + .as_ref() + .and_then(|alias| { + if let Some(p) = path_aliases.get(*alias as usize) { + Some(p) + } else { + tracing::warn!("no path found for alias `{alias}`"); + for (idx, p) in path_aliases.iter().enumerate() { + tracing::warn!("we have {idx}. {p}"); + } + None } - None + }) + .map(ToOwned::to_owned) + }); + self + } + + fn parse_hunk(raw_hunk: &str) -> Option { + let mut raw = raw_hunk.lines(); + let header = raw.next().and_then(|s| s.parse().ok()); + let lines = raw.map(ToOwned::to_owned).collect::>(); + + Some(ModifyResultHunk { header, lines }) + } + + fn expand(self) -> Vec { + self.raw + .iter() + .flat_map(|raw_hunk| raw_hunk.split("\n@@")) + .map(|hunk| { + if hunk.starts_with("@@") { + hunk.to_owned() + } else { + format!("@@{hunk}") } }) - .map(ToOwned::to_owned); - self + .map(|raw| { + SearchResult::Modify(ModifyResult { + diff: Self::parse_hunk(&raw), + path_alias: self.path_alias, + path: self.path.clone(), + language: self.language.clone(), + description: self.description.clone(), + raw: Some(raw), + new_file: self.new_file.clone(), + }) + }) + .collect::>() } } diff --git a/server/bleep/src/webserver/git.rs b/server/bleep/src/webserver/git.rs new file mode 100644 index 0000000000..b7820066c9 --- /dev/null +++ b/server/bleep/src/webserver/git.rs @@ -0,0 +1,874 @@ +// The core of this has been adapted from +// https://github.com/Byron/gitoxide/blob/9e391e916402aafa7a20c704d11e21a91bda63b5/gix-traverse/src/tree/recorder.rs + +use std::{ + collections::{BTreeMap, VecDeque}, + path::{Path, PathBuf}, + rc::Rc, + sync::Mutex, +}; + +use crate::repo::RepoRef; + +use super::{middleware::User, prelude::*}; + +use axum::Json; +use chbs::scheme::ToScheme; +use gix::{ + bstr::{BStr, BString, ByteSlice, ByteVec}, + objs::{ + tree::Entry, + tree::{self, EntryMode}, + Kind, Tree, + }, + traverse::tree::{visit::Action, Visit}, +}; +use once_cell::sync::OnceCell; +use tracing::debug; + +type RepoPath = String; +type Diff = String; +type Branch = String; +type Commit = String; + +pub struct CreateNewCommit<'a> { + repo: &'a gix::Repository, + changes: ChangeSet, + path_deque: VecDeque<(Rc, BString)>, + path: BString, + root: Rc, + current: Rc, +} + +struct MirrorTree { + filename: String, + children: Mutex>>, + tree: Mutex, + changes: Mutex, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Params { + repo: RepoRef, + branch_name: Option, + changes: Vec, + push: Option, +} + +#[derive(Debug, Serialize)] +struct ApiResult { + branch_name: Branch, + commit_id: Commit, +} + +impl super::ApiResponse for ApiResult {} + +#[derive(Debug, Clone, Deserialize)] +pub struct Change { + pub path: PathBuf, + pub diff: Diff, +} + +#[derive(Debug, Default, Clone)] +struct ChangeSet { + dirs: BTreeMap, + files: BTreeMap>, +} + +pub(super) async fn create_branch( + Extension(app): Extension, + Extension(_user): Extension, + Json(params): Json, +) -> impl IntoResponse { + let Params { + repo, + changes, + branch_name, + push, + } = params; + + let branch_name = branch_name.unwrap_or_else(|| { + use chbs::probability::Probability; + let config = chbs::config::BasicConfig { + words: 3, + separator: "-".into(), + capitalize_first: Probability::Never, + capitalize_words: Probability::Never, + ..Default::default() + }; + let scheme = config.to_scheme(); + format!("refs/heads/{}", scheme.generate()) + }); + debug!(?branch_name, "creating new branch"); + + let git = app + .repo_pool + .read_async(&repo, |_k, v| gix::open(&v.disk_path)) + .await + .unwrap() + .unwrap(); + + let mut changes = CreateNewCommit::new(&git, changes); + let head = git.head_commit().unwrap(); + head.tree() + .unwrap() + .traverse() + .breadthfirst(&mut changes) + .unwrap(); + + let root_tree = changes.into_tree(); + let root_oid = git.write_object(&root_tree).unwrap(); + + let commit_id = git + .commit( + branch_name.clone(), + "Committed by bloop", + root_oid, + Some(head.id()), + ) + .unwrap() + .detach(); + + if push.unwrap_or_default() { + let backend = app.credentials.for_repo(&repo).unwrap(); + let index = app.write_index(); + let branch_name = branch_name.clone(); + index.wait_for(async move { backend.push(&app, &repo, &branch_name).await.unwrap() }); + } + + json(ApiResult { + branch_name, + commit_id: commit_id.to_string(), + }) +} + +impl From> for ChangeSet { + fn from(value: Vec) -> Self { + let (dirs, files) = value.into_iter().fold( + (BTreeMap::default(), BTreeMap::default()), + |(mut dirs, mut files), Change { path, diff }| { + let dir = match path.parent() { + Some(parent) if parent != Path::new("") => { + &mut dirs + .entry(parent.to_string_lossy().to_string()) + .or_insert_with(ChangeSet::default) + .files + } + _ => &mut files, + }; + + dir.entry(path.file_name().unwrap().to_string_lossy().to_string()) + .or_default() + .push(diff); + + (dirs, files) + }, + ); + + Self { dirs, files } + } +} + +impl ChangeSet { + fn file(&mut self, filename: &str) -> Option> { + self.files.remove(filename) + } + + fn dir(&mut self, path: &str) -> Option { + self.dirs.remove(path) + } + + fn should_descend(&self, dir: &str) -> bool { + self.dirs.contains_key(dir) + } +} + +impl MirrorTree { + fn root(changes: ChangeSet) -> Rc { + Self::new("", changes) + } + + fn new(filename: impl Into, changes: ChangeSet) -> Rc { + Rc::new(MirrorTree { + filename: filename.into(), + children: Mutex::new(vec![]), + tree: Mutex::new(Tree::empty()), + changes: Mutex::new(changes), + }) + } + + fn find_child(&self, name: &str) -> Option> { + for c in self.children.lock().unwrap().iter() { + if c.filename == name { + return Some(c.clone()); + } + } + + None + } + + fn add_child(&self, child: Rc) -> Rc { + self.children.lock().unwrap().push(child.clone()); + child + } + + fn collapse(self: Rc, repo: &gix::Repository) -> Tree { + let mut tree = { + let mut lock = self.tree.lock().unwrap(); + std::mem::replace(&mut *lock, Tree::empty()) + }; + + let changeset = std::mem::take(&mut *self.changes.lock().unwrap()); + // make sure we add any newly created files into the directory structure + for (filename, patches) in changeset.files { + tree.entries.push(Entry { + filename: filename.into(), + mode: EntryMode::Blob, + oid: write_patched_file(repo, patches, vec![]), + }); + } + + for child in self.children.lock().unwrap().drain(..) { + let filename = child.filename.clone().into(); + let child_tree = child.collapse(repo); + tree.entries.push(Entry { + filename, + mode: EntryMode::Tree, + oid: repo.write_object(&child_tree).unwrap().detach(), + }); + } + + tree.entries.sort(); + tree + } +} + +impl<'a> CreateNewCommit<'a> { + fn new(repo: &'a gix::Repository, changes: Vec) -> Self { + let root = MirrorTree::root(changes.clone().into()); + + CreateNewCommit { + path_deque: Default::default(), + path: Default::default(), + changes: changes.into(), + current: Rc::clone(&root), + root, + repo, + } + } + + fn pop_element(&mut self) { + if let Some(pos) = self.path.rfind_byte(b'/') { + self.path.resize(pos, 0); + } else { + self.path.clear(); + } + } + + fn push_element(&mut self, name: &BStr) { + if !self.path.is_empty() { + self.path.push(b'/'); + } + self.path.push_str(name); + } + + fn into_tree(self) -> Tree { + let ChangeSet { dirs, .. } = self.changes; + for (path, contents) in dirs { + let path = Path::new(&path); + path.parent() + .expect("root or empty path") + .components() + .fold(self.root.clone(), |tree, component| { + let filename = component.as_os_str().to_string_lossy(); + match tree.find_child(&filename) { + Some(subtree) => subtree, + None => tree.add_child(MirrorTree::new(filename, ChangeSet::default())), + } + }) + .add_child(MirrorTree::new( + path.file_name().unwrap().to_string_lossy(), + contents, + )); + } + + self.root.collapse(self.repo) + } +} + +impl<'a> Visit for CreateNewCommit<'a> { + fn pop_front_tracked_path_and_set_current(&mut self) { + (self.current, self.path) = self + .path_deque + .pop_front() + .expect("every call is matched with push_tracked_path_component"); + } + + fn push_back_tracked_path_component(&mut self, component: &BStr) { + self.push_element(component); + + let next = MirrorTree::new( + component.to_str_lossy(), + self.changes + .dir(&self.path.to_str_lossy()) + .unwrap_or_default(), + ); + self.current.children.lock().unwrap().push(next.clone()); + + self.path_deque.push_back((next, self.path.clone())); + } + + fn push_path_component(&mut self, component: &BStr) { + self.push_element(component); + } + + fn pop_path_component(&mut self) { + self.pop_element(); + } + + fn visit_tree(&mut self, entry: &tree::EntryRef<'_>) -> Action { + if self + .changes + .should_descend(self.path.to_str_lossy().as_ref()) + { + Action::Continue + } else { + self.current.tree.lock().unwrap().entries.push(Entry { + mode: entry.mode, + filename: entry.filename.into(), + oid: entry.oid.into(), + }); + + Action::Skip + } + } + + fn visit_nontree(&mut self, entry: &tree::EntryRef<'_>) -> Action { + let dir_changes = self + .current + .changes + .lock() + .unwrap() + .file(&entry.filename.to_str_lossy()); + + let Some(changes) = dir_changes else { + self.current.tree.lock().unwrap().entries.push(Entry { + mode: entry.mode, + filename: entry.filename.into(), + oid: entry.oid.into(), + }); + + return Action::Continue; + }; + + let oid = { + let obj = self + .repo + .try_find_object(entry.oid) + .unwrap() + .unwrap() + .detach(); + + assert_eq!(obj.kind, Kind::Blob); + + write_patched_file(self.repo, changes, obj.data) + }; + + self.current.tree.lock().unwrap().entries.push(Entry { + mode: entry.mode, + filename: entry.filename.into(), + oid, + }); + + Action::Continue + } +} + +fn write_patched_file( + repo: &gix::Repository, + changes: impl IntoIterator, + buf: Vec, +) -> gix::ObjectId { + let blob = changes.into_iter().fold(buf, |base, patch| { + let processed = process_patch(&patch); + let base = String::from_utf8_lossy(&base); + + diffy::apply(base.as_ref(), &diffy::Patch::from_str(&processed).unwrap()) + .unwrap() + .into() + }); + + repo.write_blob(&blob).unwrap().into() +} + +fn process_patch(patch: &str) -> String { + static RE: OnceCell = OnceCell::new(); + let empty_line = RE.get_or_init(|| regex::Regex::new(r#"^\w*$"#).unwrap()); + + // remove empty lines from start and end + let patch = { + let rev = patch + .split('\n') + .rev() + .skip_while(|line| empty_line.is_match(line)) + .collect::>(); + + rev.into_iter().rev() + }; + + let mut collected = vec![]; + + let mut lines = patch.peekable(); + if lines.peek().unwrap().starts_with("diff") { + collected.push(lines.next().unwrap().into()); + } + + if lines.peek().unwrap().starts_with("index") { + collected.push(lines.next().unwrap().into()); + } + + let Some(next) = lines.peek() else { + panic!("invalid diff"); + }; + if next.starts_with("---") { + collected.push(lines.next().unwrap().into()); + } + + let Some(next) = lines.peek() else { + panic!("invalid diff"); + }; + if next.starts_with("+++") { + collected.push(lines.next().unwrap().into()); + } + + let Some(first_hunk_head) = lines.next() else { + panic!("invalid diff"); + }; + + if !first_hunk_head.starts_with("@@") { + panic!("no @@"); + } + + let mut acc = vec![]; + let (mut add, mut remove, mut total) = (0, 0, 0); + let (mut old_start, mut new_start, mut head_text) = parse_head(first_hunk_head); + + for line in lines { + if line.starts_with("@@") { + let old_size = total - add; + let new_size = total - remove; + + collected.push(format!( + "@@ -{old_start},{old_size} +{new_start},{new_size} @@{head_text}" + )); + + collected.extend(acc.drain(..).map(str::to_owned)); + + (add, remove, total) = (0, 0, 0); + (old_start, new_start, head_text) = parse_head(line); + continue; + } + + total += 1; + if line.starts_with('-') { + remove += 1; + } + if line.starts_with('+') { + add += 1; + } + + acc.push(line); + } + + let old_size = total - add; + let new_size = total - remove; + collected.push(format!( + "@@ -{old_start},{old_size} +{new_start},{new_size} @@{head_text}" + )); + collected.extend(acc.drain(..).map(str::to_owned)); + + collected.join("\n") +} + +fn parse_head(hunk_head: &str) -> (usize, usize, &str) { + let start_hunk = hunk_head + .split_once(" -") + .unwrap() + .1 + .split_once(',') + .unwrap() + .0 + .parse() + .unwrap(); + + let end_hunk = hunk_head + .split_once(" +") + .unwrap() + .1 + .split_once(',') + .unwrap() + .0 + .parse() + .unwrap(); + + let mut leftover = hunk_head.split("@@"); + assert_eq!(Some(""), leftover.next()); + _ = leftover.next().unwrap(); + let text = leftover.next().unwrap(); + + (start_hunk, end_hunk, text) +} + +#[cfg(test)] +mod tests { + #[test] + fn eq() { + let patch = r#"diff --git a/Cargo.lock b/Cargo.lock +index b65a395..c012fc3 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -486,12 +486,14 @@ dependencies = [ + "axum-extra", + "bincode", + "blake3", ++ "chbs", + "chrono", + "clap 4.3.0", + "color-eyre", + "compact_str", + "console-subscriber", + "criterion", ++ "diffy", + "directories", + "dunce", + "either", +@@ -500,6 +502,7 @@ dependencies = [ + "flume", + "futures", + "git-version", ++ "git2", + "gix", + "histogram", + "hyperpolyglot", +@@ -826,6 +829,18 @@ version = "1.0.0" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + ++[[package]] ++name = "chbs" ++version = "0.1.1" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "45a7298287f1443f422d3f46e8ce9f855e75f0e43c06605adb4c52a262faeabd" ++dependencies = [ ++ "derive_builder 0.10.2", ++ "getrandom 0.2.9", ++ "rand 0.8.5", ++ "thiserror", ++] ++ + [[package]] + name = "chrono" + version = "0.4.24" +@@ -1416,6 +1431,16 @@ dependencies = [ + "cipher", + ] + ++[[package]] ++name = "darling" ++version = "0.12.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" ++dependencies = [ ++ "darling_core 0.12.4", ++ "darling_macro 0.12.4", ++] ++ + [[package]] + name = "darling" + version = "0.14.4" +@@ -1436,6 +1461,20 @@ dependencies = [ + "darling_macro 0.20.1", + ] + ++[[package]] ++name = "darling_core" ++version = "0.12.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" ++dependencies = [ ++ "fnv", ++ "ident_case", ++ "proc-macro2", ++ "quote", ++ "strsim 0.10.0", ++ "syn 1.0.109", ++] ++ + [[package]] + name = "darling_core" + version = "0.14.4" +@@ -1464,6 +1503,17 @@ dependencies = [ + "syn 2.0.16", + ] + ++[[package]] ++name = "darling_macro" ++version = "0.12.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" ++dependencies = [ ++ "darling_core 0.12.4", ++ "quote", ++ "syn 1.0.109", ++] ++ + [[package]] + name = "darling_macro" + version = "0.14.4" +@@ -1496,13 +1546,34 @@ dependencies = [ + "uuid", + ] + ++[[package]] ++name = "derive_builder" ++version = "0.10.2" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" ++dependencies = [ ++ "derive_builder_macro 0.10.2", ++] ++ + [[package]] + name = "derive_builder" + version = "0.12.0" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" + dependencies = [ +- "derive_builder_macro", ++ "derive_builder_macro 0.12.0", ++] ++ ++[[package]] ++name = "derive_builder_core" ++version = "0.10.2" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" ++dependencies = [ ++ "darling 0.12.4", ++ "proc-macro2", ++ "quote", ++ "syn 1.0.109", + ] + + [[package]] +@@ -1517,13 +1588,23 @@ dependencies = [ + "syn 1.0.109", + ] + ++[[package]] ++name = "derive_builder_macro" ++version = "0.10.2" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" ++dependencies = [ ++ "derive_builder_core 0.10.2", ++ "syn 1.0.109", ++] ++ + [[package]] + name = "derive_builder_macro" + version = "0.12.0" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" + dependencies = [ +- "derive_builder_core", ++ "derive_builder_core 0.12.0", + "syn 1.0.109", + ] + +@@ -1546,6 +1627,15 @@ version = "0.1.13" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + ++[[package]] ++name = "diffy" ++version = "0.3.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "e616e59155c92257e84970156f506287853355f58cd4a6eb167385722c32b790" ++dependencies = [ ++ "nu-ansi-term", ++] ++ + [[package]] + name = "digest" + version = "0.10.7" +@@ -2300,6 +2390,21 @@ dependencies = [ + "syn 1.0.109", + ] + ++[[package]] ++name = "git2" ++version = "0.17.1" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "8b7905cdfe33d31a88bb2e8419ddd054451f5432d1da9eaf2ac7804ee1ea12d5" ++dependencies = [ ++ "bitflags 1.3.2", ++ "libc", ++ "libgit2-sys", ++ "log", ++ "openssl-probe", ++ "openssl-sys", ++ "url", ++] ++ + [[package]] + name = "gix" + version = "0.44.1" +@@ -3811,6 +3916,19 @@ version = "0.2.144" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" + ++[[package]] ++name = "libgit2-sys" ++version = "0.15.1+1.6.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "fb4577bde8cdfc7d6a2a4bcb7b049598597de33ffd337276e9c7db6cd4a2cee7" ++dependencies = [ ++ "cc", ++ "libc", ++ "libz-sys", ++ "openssl-sys", ++ "pkg-config", ++] ++ + [[package]] + name = "libsqlite3-sys" + version = "0.24.2" +@@ -3822,6 +3940,18 @@ dependencies = [ + "vcpkg", + ] + ++[[package]] ++name = "libz-sys" ++version = "1.1.9" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "56ee889ecc9568871456d42f603d6a0ce59ff328d291063a45cbdf0036baf6db" ++dependencies = [ ++ "cc", ++ "libc", ++ "pkg-config", ++ "vcpkg", ++] ++ + [[package]] + name = "line-wrap" + version = "0.1.1" +@@ -5811,9 +5941,9 @@ dependencies = [ + + [[package]] + name = "sentry" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "37dd6c0cdca6b1d1ca44cde7fff289f2592a97965afec870faa7b81b9fc87745" ++checksum = "234f6e133d27140ad5ea3b369a7665f7fbc060fe246f81d8168665b38c08b600" + dependencies = [ + "httpdate", + "native-tls", +@@ -5829,9 +5959,9 @@ dependencies = [ + + [[package]] + name = "sentry-backtrace" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "c029fe8317cdd75cb2b52c600bab4e2ef64c552198e669ba874340447f330962" ++checksum = "d89b6b53de06308dd5ac08934b597bcd72a9aae0c20bc3ab06da69cb34d468e3" + dependencies = [ + "backtrace", + "once_cell", +@@ -5841,9 +5971,9 @@ dependencies = [ + + [[package]] + name = "sentry-contexts" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "bc575098d73c8b942b589ab453b06e4c43527556dd8f95532220d1b54d7c6b4b" ++checksum = "0769b66763e59976cd5c0fd817dcd51ccce404de8bebac0cd0e886c55b0fffa8" + dependencies = [ + "hostname", + "libc", +@@ -5855,9 +5985,9 @@ dependencies = [ + + [[package]] + name = "sentry-core" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "20216140001bbf05895f013abd0dae4df58faee24e016d54cbf107f070bac56b" ++checksum = "a1f954f1b89e8cd82576dc49bfab80304c9a6201343b4fe5c68c819f7a9bbed2" + dependencies = [ + "once_cell", + "rand 0.8.5", +@@ -5868,9 +5998,9 @@ dependencies = [ + + [[package]] + name = "sentry-debug-images" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "4886e99be0a23d3f5563d74503ae97cb3443af3b0d7004e084b2ad6f7c01c678" ++checksum = "a8ddb9b6d43d251b41b792079218ef2d688bd88f01df454d338771cc146bde1a" + dependencies = [ + "findshlibs", + "once_cell", +@@ -5879,9 +6009,9 @@ dependencies = [ + + [[package]] + name = "sentry-panic" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "4e45cd0a113fc06d6edba01732010518816cdc8ce3bccc70f5e41570046bf046" ++checksum = "94dc2ab494362ad51308c7c19f44e9ab70e426a931621e4a05f378a1e74558c2" + dependencies = [ + "sentry-backtrace", + "sentry-core", +@@ -5889,9 +6019,9 @@ dependencies = [ + + [[package]] + name = "sentry-tracing" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "0ef4111647923c797687094bc792b8da938c4b0d64fab331d5b7a7de41964de8" ++checksum = "d0933cf65123955ddc6b95b10c73b3fdd2032a973768e072de1afd6fd2d80e3d" + dependencies = [ + "sentry-core", + "tracing-core", +@@ -5900,9 +6030,9 @@ dependencies = [ + + [[package]] + name = "sentry-types" +-version = "0.31.1" ++version = "0.31.2" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "d7f6959d8cb3a77be27e588eef6ce9a2a469651a556d9de662e4d07e5ace4232" ++checksum = "85c53caf80cb1c6fcdf4d82b7bfff8477f50841e4caad7bf8e5e57a152b564cb" + dependencies = [ + "debugid", + "getrandom 0.2.9", +@@ -7026,7 +7156,7 @@ dependencies = [ + "aho-corasick 0.7.20", + "cached-path", + "clap 4.3.0", +- "derive_builder", ++ "derive_builder 0.12.0", + "dirs", + "esaxx-rs", + "getrandom 0.2.9","#; + + assert_eq!(super::process_patch(patch), patch) + } +}