mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-09-11 12:25:42 +03:00
commit: make commit work
This commit is contained in:
parent
b9265ccdef
commit
10da421c10
@ -17,7 +17,7 @@ use anyhow::{Context, Result};
|
||||
use deltas::Delta;
|
||||
use git::activity;
|
||||
use serde::{ser::SerializeMap, Serialize};
|
||||
use std::{collections::HashMap, ops::Range, sync::Mutex};
|
||||
use std::{collections::HashMap, ops::Range, path::Path, sync::Mutex};
|
||||
use storage::Storage;
|
||||
use tauri::{generate_context, Manager};
|
||||
use tauri_plugin_log::{
|
||||
@ -394,7 +394,7 @@ async fn git_activity(
|
||||
async fn git_status(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: &str,
|
||||
) -> Result<HashMap<String, repositories::FileStatus>, Error> {
|
||||
) -> Result<HashMap<String, (repositories::FileStatus, bool)>, Error> {
|
||||
let repo = repo_for_project(handle, project_id)?;
|
||||
let files = repo.status().with_context(|| "Failed to get git status")?;
|
||||
Ok(files)
|
||||
@ -478,21 +478,45 @@ async fn git_switch_branch(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[timed(duration(printer = "debug!"))]
|
||||
#[tauri::command(async)]
|
||||
async fn git_stage(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: &str,
|
||||
paths: Vec<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let repo = repo_for_project(handle, project_id)?;
|
||||
repo.stage_files(paths.iter().map(|p| Path::new(p)).collect())
|
||||
.with_context(|| "failed to stage file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[timed(duration(printer = "debug!"))]
|
||||
#[tauri::command(async)]
|
||||
async fn git_unstage(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: &str,
|
||||
paths: Vec<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let repo = repo_for_project(handle, project_id)?;
|
||||
repo.unstage_files(paths.iter().map(|p| Path::new(p)).collect())
|
||||
.with_context(|| "failed to unstage file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[timed(duration(printer = "debug!"))]
|
||||
#[tauri::command(async)]
|
||||
async fn git_commit(
|
||||
handle: tauri::AppHandle,
|
||||
project_id: &str,
|
||||
message: &str,
|
||||
files: Vec<&str>,
|
||||
push: bool,
|
||||
) -> Result<(), Error> {
|
||||
let repo = repo_for_project(handle, project_id)?;
|
||||
let success = repo
|
||||
.commit(message, files, push)
|
||||
repo.commit(message, push)
|
||||
.with_context(|| "Failed to commit")?;
|
||||
|
||||
Ok(success)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@ -619,6 +643,8 @@ fn main() {
|
||||
git_head,
|
||||
git_switch_branch,
|
||||
git_commit,
|
||||
git_stage,
|
||||
git_unstage,
|
||||
git_wd_diff,
|
||||
]);
|
||||
|
||||
|
@ -11,7 +11,7 @@ use std::{
|
||||
use tauri::regex::Regex;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Copy, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FileStatus {
|
||||
Added,
|
||||
@ -22,6 +22,24 @@ pub enum FileStatus {
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<git2::Status> for FileStatus {
|
||||
fn from(status: git2::Status) -> Self {
|
||||
if status.is_index_new() || status.is_wt_new() {
|
||||
FileStatus::Added
|
||||
} else if status.is_index_modified() || status.is_wt_modified() {
|
||||
FileStatus::Modified
|
||||
} else if status.is_index_deleted() || status.is_wt_deleted() {
|
||||
FileStatus::Deleted
|
||||
} else if status.is_index_renamed() || status.is_wt_renamed() {
|
||||
FileStatus::Renamed
|
||||
} else if status.is_index_typechange() || status.is_wt_typechange() {
|
||||
FileStatus::TypeChange
|
||||
} else {
|
||||
FileStatus::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Repository {
|
||||
pub project: projects::Project,
|
||||
@ -237,12 +255,13 @@ impl Repository {
|
||||
Ok(activity)
|
||||
}
|
||||
|
||||
// get file status from git
|
||||
pub fn status(&self) -> Result<HashMap<String, FileStatus>> {
|
||||
// returns statuses of the unstaged files in the repository
|
||||
fn unstaged_statuses(&self) -> Result<HashMap<String, FileStatus>> {
|
||||
let mut options = git2::StatusOptions::new();
|
||||
options.include_untracked(true);
|
||||
options.include_ignored(false);
|
||||
options.recurse_untracked_dirs(true);
|
||||
options.include_ignored(false);
|
||||
options.show(git2::StatusShow::Workdir);
|
||||
|
||||
let git_repository = self.git_repository.lock().unwrap();
|
||||
// get the status of the repository
|
||||
@ -250,34 +269,58 @@ impl Repository {
|
||||
.statuses(Some(&mut options))
|
||||
.with_context(|| "failed to get repository status")?;
|
||||
|
||||
let mut files = HashMap::new();
|
||||
|
||||
// iterate over the statuses
|
||||
for entry in statuses.iter() {
|
||||
// get the path of the entry
|
||||
let path = entry.path().unwrap();
|
||||
// get the status as a string
|
||||
let istatus = match entry.status() {
|
||||
git2::Status::WT_NEW => FileStatus::Added,
|
||||
git2::Status::WT_MODIFIED => FileStatus::Modified,
|
||||
git2::Status::WT_DELETED => FileStatus::Deleted,
|
||||
git2::Status::WT_RENAMED => FileStatus::Renamed,
|
||||
git2::Status::WT_TYPECHANGE => FileStatus::TypeChange,
|
||||
git2::Status::INDEX_NEW => FileStatus::Added,
|
||||
git2::Status::INDEX_MODIFIED => FileStatus::Modified,
|
||||
git2::Status::INDEX_DELETED => FileStatus::Deleted,
|
||||
git2::Status::INDEX_RENAMED => FileStatus::Renamed,
|
||||
git2::Status::INDEX_TYPECHANGE => FileStatus::TypeChange,
|
||||
_ => FileStatus::Other,
|
||||
};
|
||||
files.insert(path.to_string(), istatus);
|
||||
}
|
||||
let files = statuses
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let path = entry.path().unwrap();
|
||||
(path.to_string(), FileStatus::from(entry.status()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
// returns statuses of the staged files in the repository
|
||||
fn staged_statuses(&self) -> Result<HashMap<String, FileStatus>> {
|
||||
let mut options = git2::StatusOptions::new();
|
||||
options.include_untracked(true);
|
||||
options.include_ignored(false);
|
||||
options.recurse_untracked_dirs(true);
|
||||
options.show(git2::StatusShow::Index);
|
||||
|
||||
let git_repository = self.git_repository.lock().unwrap();
|
||||
// get the status of the repository
|
||||
let statuses = git_repository
|
||||
.statuses(Some(&mut options))
|
||||
.with_context(|| "failed to get repository status")?;
|
||||
|
||||
let files = statuses
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let path = entry.path().unwrap();
|
||||
(path.to_string(), FileStatus::from(entry.status()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
// get file status from git
|
||||
pub fn status(&self) -> Result<HashMap<String, (FileStatus, bool)>> {
|
||||
let staged_statuses = self.staged_statuses()?;
|
||||
let unstaged_statuses = self.unstaged_statuses()?;
|
||||
let mut statuses = HashMap::new();
|
||||
unstaged_statuses.iter().for_each(|(path, status)| {
|
||||
statuses.insert(path.clone(), (*status, false));
|
||||
});
|
||||
staged_statuses.iter().for_each(|(path, status)| {
|
||||
statuses.insert(path.clone(), (*status, true));
|
||||
});
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
// commit method
|
||||
pub fn commit(&self, message: &str, files: Vec<&str>, push: bool) -> Result<()> {
|
||||
pub fn commit(&self, message: &str, push: bool) -> Result<()> {
|
||||
let repo = self.git_repository.lock().unwrap();
|
||||
|
||||
let config = repo.config().with_context(|| "failed to get config")?;
|
||||
@ -288,25 +331,11 @@ impl Repository {
|
||||
.get_string("user.email")
|
||||
.with_context(|| "failed to get user.email")?;
|
||||
|
||||
// Get the repository's index
|
||||
let mut index = repo.index()?;
|
||||
|
||||
// Add the specified files to the index
|
||||
for path_str in files {
|
||||
let path = Path::new(path_str);
|
||||
index
|
||||
.add_path(path)
|
||||
.with_context(|| format!("failed to add path {} to index", path_str.to_string()))?;
|
||||
}
|
||||
|
||||
// Write the updated index to disk
|
||||
index.write().with_context(|| "failed to write index")?;
|
||||
|
||||
// Get the default signature for the repository
|
||||
let signature = Signature::now(&name, &email).with_context(|| "failed to get signature")?;
|
||||
|
||||
// Create the commit with the updated index
|
||||
let tree_id = index.write_tree()?;
|
||||
// Create the commit with current index
|
||||
let tree_id = repo.index()?.write_tree()?;
|
||||
let tree = repo.find_tree(tree_id)?;
|
||||
let parent_commit = repo.head()?.peel_to_commit()?;
|
||||
let commit = repo.commit(
|
||||
@ -385,6 +414,55 @@ impl Repository {
|
||||
.with_context(|| format!("{}: failed to flush session", &self.project.id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stage_files(&self, paths: Vec<&Path>) -> Result<()> {
|
||||
let repo = self.git_repository.lock().unwrap();
|
||||
let mut index = repo.index()?;
|
||||
for path in paths {
|
||||
// to "stage" a file means to:
|
||||
// - remove it from the index if file is deleted
|
||||
// - overwrite it in the index otherwise
|
||||
if !Path::new(&self.project.path).join(path).exists() {
|
||||
index.remove_path(path).with_context(|| {
|
||||
format!("failed to remove path {} from index", path.display())
|
||||
})?;
|
||||
} else {
|
||||
index
|
||||
.add_path(path)
|
||||
.with_context(|| format!("failed to add path {} to index", path.display()))?;
|
||||
}
|
||||
}
|
||||
index.write().with_context(|| "failed to write index")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unstage_files(&self, paths: Vec<&Path>) -> Result<()> {
|
||||
let repo = self.git_repository.lock().unwrap();
|
||||
let head_tree = repo.head()?.peel_to_tree()?;
|
||||
let mut head_index = git2::Index::new()?;
|
||||
head_index.read_tree(&head_tree)?;
|
||||
let mut index = repo.index()?;
|
||||
for path in paths {
|
||||
// to "unstage" a file means to:
|
||||
// - put head version of the file in the index if it exists
|
||||
// - remove it from the index otherwise
|
||||
let head_index_entry = head_index.iter().find(|entry| {
|
||||
let entry_path = String::from_utf8(entry.path.clone());
|
||||
entry_path.as_ref().unwrap() == path.to_str().unwrap()
|
||||
});
|
||||
if let Some(entry) = head_index_entry {
|
||||
index
|
||||
.add(&entry)
|
||||
.with_context(|| format!("failed to add path {} to index", path.display()))?;
|
||||
} else {
|
||||
index.remove_path(path).with_context(|| {
|
||||
format!("failed to remove path {} from index", path.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
index.write().with_context(|| "failed to write index")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn init(
|
||||
|
@ -4,8 +4,14 @@ export { default as statuses } from './statuses';
|
||||
export { default as activity } from './activity';
|
||||
|
||||
export const commit = (params: {
|
||||
projectId: string;
|
||||
message: string;
|
||||
files: Array<string>;
|
||||
push: boolean;
|
||||
projectId: string;
|
||||
message: string;
|
||||
files: Array<string>;
|
||||
push: boolean;
|
||||
}) => invoke<boolean>('git_commit', params);
|
||||
|
||||
export const stage = (params: { projectId: string; paths: Array<string> }) =>
|
||||
invoke<void>('git_stage', params);
|
||||
|
||||
export const unstage = (params: { projectId: string; paths: Array<string> }) =>
|
||||
invoke<void>('git_unstage', params);
|
||||
|
@ -1,37 +1,38 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import { writable, type Readable } from 'svelte/store';
|
||||
import { log } from '$lib';
|
||||
|
||||
export type Status = {
|
||||
path: string;
|
||||
status: FileStatus;
|
||||
path: string;
|
||||
status: FileStatus;
|
||||
staged: boolean;
|
||||
};
|
||||
|
||||
type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'typeChange' | 'other';
|
||||
|
||||
const list = (params: { projectId: string }) =>
|
||||
invoke<Record<string, FileStatus>>('git_status', params);
|
||||
invoke<Record<string, [FileStatus, boolean]>>('git_status', params);
|
||||
|
||||
const convertToStatuses = (statusesGit: Record<string, FileStatus>): Status[] =>
|
||||
Object.entries(statusesGit).map((status) => ({
|
||||
path: status[0],
|
||||
status: status[1]
|
||||
}));
|
||||
const convertToStatuses = (statusesGit: Record<string, [FileStatus, boolean]>): Status[] =>
|
||||
Object.entries(statusesGit).map((status) => ({
|
||||
path: status[0],
|
||||
status: status[1][0],
|
||||
staged: status[1][1]
|
||||
}));
|
||||
|
||||
export default async (params: { projectId: string }) => {
|
||||
const statuses = await list(params).then(convertToStatuses);
|
||||
const store = writable(statuses);
|
||||
const statuses = await list(params).then(convertToStatuses);
|
||||
const store = writable(statuses);
|
||||
|
||||
appWindow.listen(`project://${params.projectId}/git/index`, async () => {
|
||||
log.info(`Status: Received git index event, projectId: ${params.projectId}`);
|
||||
store.set(await list(params).then(convertToStatuses));
|
||||
});
|
||||
[
|
||||
`project://${params.projectId}/git/index`,
|
||||
`project://${params.projectId}/git/activity`,
|
||||
`project://${params.projectId}/sessions`
|
||||
].forEach((eventName) => {
|
||||
appWindow.listen(eventName, async () => {
|
||||
store.set(await list(params).then(convertToStatuses));
|
||||
});
|
||||
});
|
||||
|
||||
appWindow.listen(`project://${params.projectId}/sessions`, async () => {
|
||||
log.info(`Status: Received sessions event, projectId: ${params.projectId}`);
|
||||
store.set(await list(params).then(convertToStatuses));
|
||||
});
|
||||
|
||||
return store as Readable<Status[]>;
|
||||
return store as Readable<Status[]>;
|
||||
};
|
||||
|
@ -2,7 +2,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { collapsable } from '$lib/paths';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { commit } from '$lib/git';
|
||||
import * as git from '$lib/git';
|
||||
import DiffViewer from '$lib/components/DiffViewer.svelte';
|
||||
import { error, success } from '$lib/toasts';
|
||||
import { IconRotateClockwise } from '$lib/components/icons';
|
||||
@ -13,7 +13,6 @@
|
||||
let summary = '';
|
||||
let description = '';
|
||||
|
||||
let selectedFiles = $statuses.map(({ path }) => path);
|
||||
const selectedDiffPath = writable($statuses.at(0)?.path);
|
||||
const selectedDiff = derived(
|
||||
[diffs, selectedDiffPath],
|
||||
@ -36,14 +35,15 @@
|
||||
const paths = formData.getAll('path') as string[];
|
||||
|
||||
isCommitting = true;
|
||||
commit({
|
||||
projectId,
|
||||
message: description.length > 0 ? `${summary}\n\n${description}` : summary,
|
||||
files: paths,
|
||||
push: false
|
||||
})
|
||||
git
|
||||
.commit({
|
||||
projectId,
|
||||
message: description.length > 0 ? `${summary}\n\n${description}` : summary,
|
||||
files: paths,
|
||||
push: false
|
||||
})
|
||||
.then(() => {
|
||||
success('Commit successfull!');
|
||||
success('Commit created');
|
||||
reset();
|
||||
})
|
||||
.catch(() => {
|
||||
@ -58,7 +58,9 @@
|
||||
if ($user === undefined) return;
|
||||
|
||||
const partialDiff = Object.fromEntries(
|
||||
Object.entries($diffs).filter(([key]) => selectedFiles.includes(key))
|
||||
Object.entries($diffs).filter(([key]) =>
|
||||
$statuses.some((status) => status.path === key && status.staged)
|
||||
)
|
||||
);
|
||||
const diff = Object.values(partialDiff).join('\n').slice(0, 5000);
|
||||
|
||||
@ -86,13 +88,27 @@
|
||||
const onGroupCheckboxClick = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.checked) {
|
||||
selectedFiles = $statuses.map(({ path }) => path);
|
||||
git
|
||||
.stage({
|
||||
projectId,
|
||||
paths: $statuses.filter(({ staged }) => !staged).map(({ path }) => path)
|
||||
})
|
||||
.catch(() => {
|
||||
error('Failed to stage files');
|
||||
});
|
||||
} else {
|
||||
selectedFiles = [];
|
||||
git
|
||||
.unstage({
|
||||
projectId,
|
||||
paths: $statuses.filter(({ staged }) => staged).map(({ path }) => path)
|
||||
})
|
||||
.catch(() => {
|
||||
error('Failed to unstage files');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$: isEnabled = summary.length > 0 && selectedFiles.length > 0;
|
||||
$: isCommitEnabled = summary.length > 0 && $statuses.filter(({ staged }) => staged).length > 0;
|
||||
</script>
|
||||
|
||||
<div id="commit-page" class="flex h-full w-full gap-2 p-2">
|
||||
@ -106,8 +122,10 @@
|
||||
type="checkbox"
|
||||
class="cursor-pointer disabled:opacity-50"
|
||||
on:click={onGroupCheckboxClick}
|
||||
checked={$statuses.length === selectedFiles.length}
|
||||
indeterminate={$statuses.length > selectedFiles.length && selectedFiles.length > 0}
|
||||
checked={$statuses.every(({ staged }) => staged)}
|
||||
indeterminate={$statuses.some(({ staged }) => staged) &&
|
||||
$statuses.some(({ staged }) => !staged) &&
|
||||
$statuses.length > 0}
|
||||
disabled={isCommitting || isGeneratingCommitMessage}
|
||||
/>
|
||||
<h1 class="m-auto flex">
|
||||
@ -115,7 +133,7 @@
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{#each $statuses as { path }, i}
|
||||
{#each $statuses as { path, staged }, i}
|
||||
<li
|
||||
class:bg-gb-700={$selectedDiffPath === path}
|
||||
class:hover:bg-divider={$selectedDiffPath !== path}
|
||||
@ -125,9 +143,18 @@
|
||||
<input
|
||||
class="ml-4 cursor-pointer py-2 disabled:opacity-50"
|
||||
disabled={isCommitting || isGeneratingCommitMessage}
|
||||
on:click|preventDefault={() => {
|
||||
staged
|
||||
? git.unstage({ projectId, paths: [path] }).catch(() => {
|
||||
error('Failed to unstage file');
|
||||
})
|
||||
: git.stage({ projectId, paths: [path] }).catch(() => {
|
||||
error('Failed to stage file');
|
||||
});
|
||||
}}
|
||||
name="path"
|
||||
type="checkbox"
|
||||
bind:group={selectedFiles}
|
||||
checked={staged}
|
||||
value={path}
|
||||
/>
|
||||
<label class="flex w-full overflow-auto" for="path">
|
||||
@ -172,7 +199,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
disabled={!isEnabled || isGeneratingCommitMessage}
|
||||
disabled={!isCommitEnabled || isGeneratingCommitMessage}
|
||||
type="submit"
|
||||
class="rounded bg-[#2563EB] py-2 px-4 text-lg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
|
Loading…
Reference in New Issue
Block a user