Add basic committing to the client (#484)

* move commit to virtual_branches code, add bridge for JS
* actually run commit() from the client
* simple remote branches list, for later
* better remote branches
* adds more data to remote branches
* test file movement
* added move_files test and remove path from all branches that match
* fix move with duplicate entries
This commit is contained in:
Scott Chacon 2023-06-22 09:29:17 +02:00 committed by GitHub
parent e839000a0b
commit 971059e179
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 462 additions and 115 deletions

View File

@ -351,6 +351,20 @@ impl App {
Ok(())
}
pub fn commit_virtual_branch(
&self,
project_id: &str,
branch: &str,
message: &str,
) -> Result<()> {
let gb_repository = self.gb_repository(project_id)?;
let project = self.gb_project(project_id)?;
let project_repository = project_repository::Repository::open(&project)
.context("failed to open project repository")?;
virtual_branches::commit(&gb_repository, &project_repository, branch, message)?;
Ok(())
}
pub fn upsert_bookmark(&self, bookmark: &bookmarks::Bookmark) -> Result<()> {
let gb_repository = self.gb_repository(&bookmark.project_id)?;
let writer = bookmarks::Writer::new(&gb_repository).context("failed to open writer")?;
@ -441,6 +455,17 @@ impl App {
project_repository.git_remote_branches()
}
pub fn git_remote_branches_data(
&self,
project_id: &str,
) -> Result<Vec<virtual_branches::RemoteBranch>> {
let gb_repository = self.gb_repository(project_id)?;
let project = self.gb_project(project_id)?;
let project_repository = project_repository::Repository::open(&project)
.context("failed to open project repository")?;
virtual_branches::remote_branches(&gb_repository, &project_repository)
}
pub fn git_head(&self, project_id: &str) -> Result<String> {
let project = self.gb_project(project_id)?;
let project_repository = project_repository::Repository::open(&project)

View File

@ -3,7 +3,7 @@ use colored::Colorize;
use dialoguer::{console::Term, theme::ColorfulTheme, Input, MultiSelect, Select};
use git2::Repository;
use std::time;
use std::{collections::HashMap, time};
use uuid::Uuid;
use git_butler_tauri::{
@ -79,11 +79,21 @@ fn main() {
"setup" => run_setup(butler), // sets target sha from remote branch
"commit" => run_commit(butler), // creates trees from the virtual branch content and creates a commit
"branches" => run_branches(butler),
"remotes" => run_remotes(butler),
"flush" => run_flush(butler), // artificially forces a session flush
_ => println!("Unknown command: {}", args.command),
}
}
fn run_remotes(butler: ButlerCli) {
let branches =
virtual_branches::remote_branches(&butler.gb_repository, &butler.project_repository())
.unwrap();
for branch in branches {
println!("{:?}", branch);
}
}
fn run_flush(butler: ButlerCli) {
println!("Flushing sessions");
butler.gb_repository.flush().unwrap();
@ -108,7 +118,6 @@ fn run_branches(butler: ButlerCli) {
fn run_commit(butler: ButlerCli) {
// get the branch to commit
let current_session = butler
.gb_repository
.get_or_create_current_session()
@ -123,17 +132,18 @@ fn run_commit(butler: ButlerCli) {
.into_iter()
.collect::<Vec<_>>();
let branch_names = virtual_branches
.iter()
.map(|b| b.name.clone())
.collect::<Vec<_>>();
let mut ids = Vec::new();
let mut names = Vec::new();
for branch in virtual_branches {
ids.push(branch.id);
names.push(branch.name);
}
let selection = Select::with_theme(&ColorfulTheme::default())
.items(&branch_names)
.items(&names)
.default(0)
.interact_on_opt(&Term::stderr())
.unwrap();
let commit_branch = branch_names[selection.unwrap()].clone();
let commit_branch = ids[selection.unwrap()].clone();
println!("Committing virtual branch {}", commit_branch.red());
// get the commit message
@ -141,74 +151,12 @@ fn run_commit(butler: ButlerCli) {
.with_prompt("Commit message")
.interact_text()
.unwrap();
let target_reader = virtual_branches::target::Reader::new(&current_session_reader);
let default_target = match target_reader.read_default() {
Ok(target) => target,
Err(reader::Error::NotFound) => return,
Err(e) => panic!("failed to read default target: {}", e),
};
// get the files to commit
let statuses =
virtual_branches::get_status_by_branch(&butler.gb_repository, &butler.project_repository())
.expect("failed to get status by branch");
for (mut branch, files) in statuses {
if branch.name == commit_branch {
println!(" branch: {}", branch.id.blue());
println!(" base: {}", default_target.sha.to_string().blue());
// read the base sha into an index
let git_repository = butler.git_repository();
let base_commit = git_repository.find_commit(default_target.sha).unwrap();
let base_tree = base_commit.tree().unwrap();
let parent_commit = git_repository.find_commit(branch.head).unwrap();
let mut index = git_repository.index().unwrap();
index.read_tree(&base_tree).unwrap();
// now update the index with content in the working directory for each file
for file in files {
println!("{}", file.path);
// convert this string to a Path
let file = std::path::Path::new(&file.path);
// TODO: deal with removals too
index.add_path(file).unwrap();
}
// now write out the tree
let tree_oid = index.write_tree().unwrap();
// only commit if it's a new tree
if tree_oid != branch.tree {
let tree = git_repository.find_tree(tree_oid).unwrap();
// now write a commit
let (author, committer) = butler.gb_repository.git_signatures().unwrap();
let commit_oid = git_repository
.commit(
None,
&author,
&committer,
&message,
&tree,
&[&parent_commit],
)
.unwrap();
// write this new commit to the virtual branch
println!(" commit: {}", commit_oid.to_string().blue());
// update the virtual branch head
branch.tree = tree_oid;
branch.head = commit_oid;
let writer = virtual_branches::branch::Writer::new(&butler.gb_repository);
writer.write(&branch).unwrap();
}
}
}
// create the tree
// create the commit
virtual_branches::commit(
&butler.gb_repository,
&butler.project_repository(),
&commit_branch,
&message,
);
}
fn run_new(butler: ButlerCli) {
@ -268,6 +216,7 @@ fn run_move(butler: ButlerCli) {
.iter()
.map(|i| files[*i].clone().into())
.collect::<Vec<_>>();
println!("Selected files: {:?}", selected_files);
let current_session = butler
.gb_repository
@ -283,30 +232,20 @@ fn run_move(butler: ButlerCli) {
.into_iter()
.collect::<Vec<_>>();
// get the branch to move to
let branch_names = virtual_branches
.iter()
.map(|b| b.name.clone())
.collect::<Vec<_>>();
let mut ids = Vec::new();
let mut names = Vec::new();
for branch in virtual_branches {
ids.push(branch.id);
names.push(branch.name);
}
let selection = Select::with_theme(&ColorfulTheme::default())
.items(&branch_names)
.items(&names)
.default(0)
.interact_on_opt(&Term::stderr())
.unwrap();
let new_branch = branch_names[selection.unwrap()].clone();
let target_branch_id = ids[selection.unwrap()].clone();
let target_branch_id = virtual_branches
.iter()
.find(|b| b.name == new_branch)
.unwrap()
.id
.clone();
println!(
"Moving {} files to {}",
selected_files.len(),
new_branch.red()
);
println!("Moving {} files", selected_files.len());
virtual_branches::move_files(&butler.gb_repository, &target_branch_id, &selected_files)
.expect("failed to move files");
}

View File

@ -441,6 +441,19 @@ async fn git_remote_branches(
Ok(branches)
}
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn git_remote_branches_data(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<Vec<virtual_branches::RemoteBranch>, Error> {
let app = handle.state::<app::App>();
let branches = app
.git_remote_branches_data(project_id)
.with_context(|| format!("failed to get git branches for project {}", project_id))?;
Ok(branches)
}
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn git_head(handle: tauri::AppHandle, project_id: &str) -> Result<String, Error> {
@ -594,6 +607,19 @@ async fn move_virtual_branch_files(
Ok(target)
}
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn commit_virtual_branch(
handle: tauri::AppHandle,
project_id: &str,
branch: &str,
message: &str,
) -> Result<(), Error> {
let app = handle.state::<app::App>();
let target = app.commit_virtual_branch(project_id, branch, message)?;
Ok(target)
}
#[timed(duration(printer = "debug!"))]
#[tauri::command(async)]
async fn get_target_data(
@ -745,6 +771,7 @@ fn main() {
git_match_paths,
git_branches,
git_remote_branches,
git_remote_branches_data,
git_head,
git_switch_branch,
git_commit,
@ -760,6 +787,7 @@ fn main() {
list_virtual_branches,
create_virtual_branch,
move_virtual_branch_files,
commit_virtual_branch,
get_target_data,
set_target_branch,
])

View File

@ -2,7 +2,10 @@ pub mod branch;
mod iterator;
pub mod target;
use std::{collections::HashMap, path, time, vec};
use std::{
collections::{HashMap, HashSet},
path, time, vec,
};
use anyhow::{Context, Result};
use filetime::FileTime;
@ -41,6 +44,153 @@ pub struct VirtualBranchHunk {
pub file_path: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteBranch {
sha: String,
branch: String,
name: String,
description: String,
last_commit_ts: u128,
first_commit_ts: u128,
ahead: u32,
behind: u32,
upstream: String,
authors: Vec<String>,
}
pub fn remote_branches(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
) -> Result<Vec<RemoteBranch>> {
// get the current target
let current_session = gb_repository
.get_or_create_current_session()
.context("failed to get or create currnt session")?;
let current_session_reader = sessions::Reader::open(gb_repository, &current_session)
.context("failed to open current session")?;
let target_reader = target::Reader::new(&current_session_reader);
let default_target = match target_reader.read_default() {
Ok(target) => Ok(target),
Err(reader::Error::NotFound) => return Ok(vec![]),
Err(e) => Err(e),
}
.context("failed to read default target")?;
let main_oid = default_target.sha;
let current_time = time::SystemTime::now();
let too_old = time::Duration::from_secs(86_400 * 180); // 180 days (6 months) is too old
let repo = &project_repository.git_repository;
let mut branches: Vec<RemoteBranch> = Vec::new();
for branch in repo.branches(Some(git2::BranchType::Remote))? {
let (branch, _) = branch?;
let branch_name = branch.get().name().unwrap();
let upstream_branch = branch.upstream();
match branch.get().target() {
Some(branch_oid) => {
// get the branch ref
let branch_commit = repo.find_commit(branch_oid).ok().unwrap();
// figure out if the last commit on this branch is too old to consider
let branch_time = branch_commit.time();
// convert git::Time to SystemTime
let branch_time = time::UNIX_EPOCH
+ time::Duration::from_secs(branch_time.seconds().try_into().unwrap());
let duration = current_time.duration_since(branch_time).unwrap();
if duration > too_old {
continue;
}
let mut revwalk = repo.revwalk().unwrap();
revwalk.set_sorting(git2::Sort::TOPOLOGICAL).unwrap();
revwalk.push(main_oid).unwrap();
revwalk.hide(branch_oid).unwrap();
let mut count_behind = 0;
for oid in revwalk {
if oid.unwrap() == branch_oid {
break;
}
count_behind += 1;
if count_behind > 100 {
break;
}
}
let mut revwalk2 = repo.revwalk().unwrap();
revwalk2.set_sorting(git2::Sort::TOPOLOGICAL).unwrap();
revwalk2.push(branch_oid).unwrap();
revwalk2.hide(main_oid).unwrap();
let mut min_time = None;
let mut max_time = None;
let mut count_ahead = 0;
let mut authors = HashSet::new();
for oid in revwalk2 {
let oid = oid.unwrap();
if oid == main_oid {
break;
}
let commit = repo.find_commit(oid).ok().unwrap();
let timestamp = commit.time().seconds() as u128;
if min_time.is_none() || timestamp < min_time.unwrap() {
min_time = Some(timestamp);
}
if max_time.is_none() || timestamp > max_time.unwrap() {
max_time = Some(timestamp);
}
// find the signature for this commit
let commit = repo.find_commit(oid).ok().unwrap();
let signature = commit.author();
authors.insert(signature.email().unwrap().to_string());
count_ahead += 1;
}
let upstream_branch_name = match upstream_branch {
Ok(upstream_branch) => upstream_branch.get().name().unwrap_or("").to_string(),
Err(e) => "".to_string(),
};
branches.push(RemoteBranch {
sha: branch_oid.to_string(),
branch: branch_name.to_string(),
name: branch_name.to_string(),
description: "".to_string(),
last_commit_ts: max_time.unwrap_or(0),
first_commit_ts: min_time.unwrap_or(0),
ahead: count_ahead,
behind: count_behind,
upstream: upstream_branch_name,
authors: authors.into_iter().collect(),
});
}
None => {
// this is a detached head
branches.push(RemoteBranch {
sha: "".to_string(),
branch: branch_name.to_string(),
name: branch_name.to_string(),
description: "".to_string(),
last_commit_ts: 0,
first_commit_ts: 0,
ahead: 0,
behind: 0,
upstream: "".to_string(),
authors: vec![],
});
}
}
}
Ok(branches)
}
pub fn list_virtual_branches(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
@ -135,19 +285,20 @@ pub fn move_files(
.clone();
for ownership in to_move {
let mut source_branch = virtual_branches
// take the file out of all branches (in case of accidental duplication)
let source_branches = virtual_branches
.iter()
.find(|b| b.ownership.contains(ownership))
.context(format!("failed to find source branch for {}", ownership))?
.clone();
.filter(|b| b.ownership.contains(ownership));
source_branch.ownership.retain(|o| !o.eq(ownership));
source_branch.ownership.sort();
source_branch.ownership.dedup();
writer
.write(&source_branch)
.context(format!("failed to write source branch for {}", ownership))?;
for source_branch in source_branches {
let mut source_branch = source_branch.clone();
source_branch.ownership.retain(|o| !o.eq(ownership));
source_branch.ownership.sort();
source_branch.ownership.dedup();
writer
.write(&source_branch)
.context(format!("failed to find source branch for {}", ownership))?
}
target_branch.ownership.push(ownership.clone());
target_branch.ownership.sort();
@ -449,6 +600,76 @@ pub fn get_status_by_branch(
Ok(statuses)
}
pub fn commit(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
branch_id: &str,
message: &str,
) -> Result<()> {
let current_session = gb_repository
.get_or_create_current_session()
.expect("failed to get or create currnt session");
let current_session_reader = sessions::Reader::open(&gb_repository, &current_session)
.expect("failed to open current session reader");
let target_reader = target::Reader::new(&current_session_reader);
let default_target = match target_reader.read_default() {
Ok(target) => target,
Err(e) => panic!("failed to read default target: {}", e),
};
// get the files to commit
let statuses = get_status_by_branch(&gb_repository, &project_repository)
.expect("failed to get status by branch");
for (mut branch, files) in statuses {
if branch.id == branch_id {
// read the base sha into an index
let git_repository = &project_repository.git_repository;
let base_commit = git_repository.find_commit(default_target.sha).unwrap();
let base_tree = base_commit.tree().unwrap();
let parent_commit = git_repository.find_commit(branch.head).unwrap();
let mut index = git_repository.index().unwrap();
index.read_tree(&base_tree).unwrap();
// now update the index with content in the working directory for each file
for file in files {
// convert this string to a Path
let file = std::path::Path::new(&file.path);
// TODO: deal with removals too
index.add_path(file).unwrap();
}
// now write out the tree
let tree_oid = index.write_tree().unwrap();
// only commit if it's a new tree
if tree_oid != branch.tree {
let tree = git_repository.find_tree(tree_oid).unwrap();
// now write a commit
let (author, committer) = gb_repository.git_signatures().unwrap();
let commit_oid = git_repository
.commit(
None,
&author,
&committer,
&message,
&tree,
&[&parent_commit],
)
.unwrap();
// update the virtual branch head
branch.tree = tree_oid;
branch.head = commit_oid;
let writer = branch::Writer::new(&gb_repository);
writer.write(&branch).unwrap();
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
@ -630,4 +851,74 @@ mod tests {
Ok(())
}
#[test]
fn test_move_files_scott() -> Result<()> {
let repository = test_repository()?;
let project = projects::Project::try_from(&repository)?;
let gb_repo_path = tempdir()?.path().to_str().unwrap().to_string();
let storage = storage::Storage::from_path(tempdir()?.path());
let user_store = users::Storage::new(storage.clone());
let project_store = projects::Storage::new(storage);
project_store.add_project(&project)?;
let gb_repo = gb_repository::Repository::open(
gb_repo_path,
project.id.clone(),
project_store,
user_store,
)?;
let project_repository = project_repository::Repository::open(&project)?;
target::Writer::new(&gb_repo).write_default(&target::Target {
name: "origin".to_string(),
remote: "origin".to_string(),
sha: repository.head().unwrap().target().unwrap(),
})?;
let file_path = std::path::Path::new("test.txt");
std::fs::write(
std::path::Path::new(&project.path).join(file_path),
"line1\nline2\n",
)?;
let file_path2 = std::path::Path::new("test2.txt");
std::fs::write(
std::path::Path::new(&project.path).join(file_path2),
"line1\nline2\n",
)?;
let branch1_id = create_virtual_branch(&gb_repo, "test_branch")
.expect("failed to create virtual branch");
let branch2_id = create_virtual_branch(&gb_repo, "test_branch2")
.expect("failed to create virtual branch");
let session = gb_repo.get_or_create_current_session().unwrap();
let session_reader = sessions::Reader::open(&gb_repo, &session).unwrap();
// this should automatically move the file to branch2
let status =
get_status_by_branch(&gb_repo, &project_repository).expect("failed to get status");
let vbranch_reader = branch::Reader::new(&session_reader);
move_files(&gb_repo, &branch1_id, &vec!["test.txt".to_string().into()]).unwrap();
move_files(&gb_repo, &branch2_id, &vec!["test2.txt".to_string().into()]).unwrap();
let branch1 = vbranch_reader.read(&branch1_id).unwrap();
let branch2 = vbranch_reader.read(&branch2_id).unwrap();
assert_eq!(branch1.ownership.len(), 1);
assert_eq!(
branch2
.ownership
.first()
.unwrap()
.file_path
.to_str()
.unwrap(),
"test2.txt"
);
Ok(())
}
}

View File

@ -6,8 +6,7 @@
import { invoke } from '@tauri-apps/api';
export let data: PageData;
let { projectId, target, branches, remoteBranches } = data;
let { projectId, target, branches, remoteBranches, remoteBranchesData } = data;
let targetChoice = 'origin/master'; // prob should check if it exists
async function setTarget() {
@ -31,7 +30,7 @@
{#if target}
<div class="flex w-full max-w-full">
<Tray bind:branches />
<Tray bind:branches remoteBranches={remoteBranchesData} />
<Board {projectId} bind:branches on:newBranch={handleNewBranch} />
</div>
{:else}

View File

@ -1,5 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { Branch } from './types';
import { Branch, type BranchData } from './types';
import { invoke } from '$lib/ipc';
import type { PageLoadEvent } from './$types';
@ -15,6 +15,10 @@ async function getTargetData(params: { projectId: string }) {
return invoke<object>('get_target_data', params);
}
async function getRemoteBranchesData(params: { projectId: string }) {
return invoke<Array<BranchData>>('git_remote_branches_data', params);
}
function sortBranchHunks(branches: Branch[]): Branch[] {
for (const branch of branches) {
for (const file of branch.files) {
@ -24,12 +28,18 @@ function sortBranchHunks(branches: Branch[]): Branch[] {
return branches;
}
function sortBranchData(branchData: BranchData[]): BranchData[] {
// sort remote_branches_data by date
return branchData.sort((a, b) => b.lastCommitTs - a.lastCommitTs);
}
export async function load(e: PageLoadEvent) {
const projectId = e.params.projectId;
const target = await getTargetData({ projectId });
const remoteBranches = await getRemoteBranches({ projectId });
const remoteBranchesData = sortBranchData(await getRemoteBranchesData({ projectId }));
const branches: Branch[] = sortBranchHunks(
plainToInstance(Branch, await getVirtualBranches({ projectId }))
);
return { projectId, target, remoteBranches, branches };
return { projectId, target, remoteBranches, remoteBranchesData, branches };
}

View File

@ -9,6 +9,7 @@
import { IconBranch } from '$lib/icons';
import { IconTriangleUp, IconTriangleDown } from '$lib/icons';
import { Button } from '$lib/components';
import { message } from '@tauri-apps/api/dialog';
export let branchId: string;
export let name: string;
@ -24,6 +25,9 @@
const move_files = async (params: { projectId: string; branch: string; paths: Array<string> }) =>
invoke<object>('move_virtual_branch_files', params);
const commit_branch = async (params: { projectId: string; branch: string; message: string }) =>
invoke<object>('commit_virtual_branch', params);
function handleDndEvent(e: CustomEvent<DndEvent<File | Hunk>>) {
const newItems = e.detail.items;
const fileItems = newItems.filter((item) => item instanceof File) as File[];
@ -70,6 +74,17 @@
descriptionHeight = textArea.scrollHeight;
}
function commit() {
console.log('commit', textArea.value, projectId, branchId);
commit_branch({
projectId: projectId,
branch: branchId,
message: textArea.value
}).then((res) => {
console.log(res);
});
}
onMount(() => {
updateTextArea();
});
@ -99,7 +114,7 @@
width="full-width"
color="purple"
on:click={() => {
console.log("i'd like to commit some day but im not sure i'm ready for it");
commit();
}}>Commit</Button
>
</div>

View File

@ -1,8 +1,10 @@
<script lang="ts">
import { Checkbox } from '$lib/components';
import type { Branch } from './types';
import type { Branch, BranchData } from './types';
import { formatDistanceToNow } from 'date-fns';
export let branches: Branch[];
export let remoteBranches: BranchData[];
</script>
<div class="gb-text-2 w-80 shrink-0 px-2">
@ -17,4 +19,29 @@
</div>
{/each}
</div>
{#if remoteBranches}
<div class="flex flex-col">
<div class="py-4 text-lg font-bold">Remote Branches</div>
{#each remoteBranches as branch}
<div class="flex flex-col justify-between rounded-lg p-2" title={branch.branch}>
<div class="flex flex-row justify-between">
<div class="cursor-pointer">
{branch.branch.replace('refs/remotes/', '')}
</div>
<div>{branch.ahead}/{branch.behind}</div>
</div>
{#if branch.lastCommitTs > 0}
<div class="flex flex-row justify-between">
<div class="text-sm">{formatDistanceToNow(branch.lastCommitTs * 1000)}</div>
<div>
{#each branch.authors as author}
{author[0]}
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@ -26,3 +26,16 @@ export class Branch extends DndItem {
files!: File[];
description!: string;
}
export type BranchData = {
sha: string;
branch: string;
name: string;
description: string;
lastCommitTs: number;
firstCommitTs: number;
ahead: number;
behind: number;
upstream: string;
authors: string[];
};