commit: make commit work

This commit is contained in:
Nikita Galaiko 2023-03-30 10:19:28 +02:00
parent b9265ccdef
commit 10da421c10
5 changed files with 230 additions and 92 deletions

View File

@ -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,
]);

View File

@ -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 files = statuses
.iter()
.map(|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);
}
(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(

View File

@ -9,3 +9,9 @@ export const commit = (params: {
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);

View File

@ -1,36 +1,37 @@
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;
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[] =>
const convertToStatuses = (statusesGit: Record<string, [FileStatus, boolean]>): Status[] =>
Object.entries(statusesGit).map((status) => ({
path: status[0],
status: status[1]
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);
appWindow.listen(`project://${params.projectId}/git/index`, async () => {
log.info(`Status: Received git index event, projectId: ${params.projectId}`);
[
`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[]>;

View File

@ -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({
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"
>