Surface warning if hunk locked to multiple branches

- now returning `HunkLock` to front end
- detect if locked to more than one branch and warn user
This commit is contained in:
Mattias Granlund 2024-04-25 22:39:44 +02:00
parent ff44a80e74
commit c5c2df1b93
6 changed files with 89 additions and 41 deletions

View File

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

View File

@ -22,10 +22,16 @@ export class Hunk {
filePath!: string; filePath!: string;
hash?: string; hash?: string;
locked!: boolean; locked!: boolean;
lockedTo!: string[] | undefined; @Type(() => HunkLock)
lockedTo!: HunkLock[];
changeType!: ChangeType; changeType!: ChangeType;
} }
export class HunkLock {
branchId!: string;
commitId!: string;
}
export type AnyFile = LocalFile | RemoteFile; export type AnyFile = LocalFile | RemoteFile;
export class LocalFile { export class LocalFile {
@ -63,7 +69,7 @@ export class LocalFile {
: false; : false;
} }
get lockedIds(): string[] { get lockedIds(): HunkLock[] {
return this.hunks return this.hunks
.flatMap((hunk) => hunk.lockedTo) .flatMap((hunk) => hunk.lockedTo)
.filter(notNull) .filter(notNull)
@ -252,7 +258,7 @@ export class RemoteFile {
return this.hunks.map((h) => h.id); return this.hunks.map((h) => h.id);
} }
get lockedIds(): string[] { get lockedIds(): HunkLock[] {
return []; return [];
} }

View File

@ -53,7 +53,7 @@ pub struct GitHunk {
#[serde(rename = "diff", serialize_with = "crate::serde::as_string_lossy")] #[serde(rename = "diff", serialize_with = "crate::serde::as_string_lossy")]
pub diff_lines: BString, pub diff_lines: BString,
pub binary: bool, pub binary: bool,
pub locked_to: Box<[git::Oid]>, pub locked_to: Box<[HunkLock]>,
pub change_type: ChangeType, pub change_type: ChangeType,
} }
@ -95,12 +95,22 @@ impl GitHunk {
self.new_start <= line && self.new_start + self.new_lines >= line self.new_start <= line && self.new_start + self.new_lines >= line
} }
pub fn with_locks(mut self, locks: &[git::Oid]) -> Self { pub fn with_locks(mut self, locks: &[HunkLock]) -> Self {
self.locked_to = locks.to_owned().into(); self.locked_to = locks.to_owned().into();
self self
} }
} }
// A hunk is locked when it depends on changes in commits that are in your
// workspace. A hunk can be locked to more than one branch if it overlaps
// with more than one committed hunk.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Copy)]
#[serde(rename_all = "camelCase")]
pub struct HunkLock {
pub branch_id: uuid::Uuid,
pub commit_id: git::Oid,
}
#[derive(Debug, PartialEq, Clone, Serialize, Default)] #[derive(Debug, PartialEq, Clone, Serialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FileDiff { pub struct FileDiff {

View File

@ -3,7 +3,7 @@ use std::{fmt::Display, ops::RangeInclusive, str::FromStr};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use bstr::{BStr, ByteSlice}; use bstr::{BStr, ByteSlice};
use crate::git::{self, diff}; use crate::git::diff;
pub type HunkHash = md5::Digest; pub type HunkHash = md5::Digest;
@ -13,7 +13,7 @@ pub struct Hunk {
pub timestamp_ms: Option<u128>, pub timestamp_ms: Option<u128>,
pub start: u32, pub start: u32,
pub end: u32, pub end: u32,
pub locked_to: Vec<git::Oid>, pub locked_to: Vec<diff::HunkLock>,
} }
impl From<&diff::GitHunk> for Hunk { impl From<&diff::GitHunk> for Hunk {

View File

@ -24,6 +24,7 @@ use super::{
branch_to_remote_branch, errors, target, RemoteBranch, VirtualBranchesHandle, branch_to_remote_branch, errors, target, RemoteBranch, VirtualBranchesHandle,
}; };
use crate::git::diff::{diff_files_into_hunks, trees, DiffByPathMap}; use crate::git::diff::{diff_files_into_hunks, trees, DiffByPathMap};
use crate::id::Id;
use crate::virtual_branches::branch::HunkHash; use crate::virtual_branches::branch::HunkHash;
use crate::{ use crate::{
askpass::AskpassBroker, askpass::AskpassBroker,
@ -143,7 +144,7 @@ pub struct VirtualBranchHunk {
pub end: u32, pub end: u32,
pub binary: bool, pub binary: bool,
pub locked: bool, pub locked: bool,
pub locked_to: Option<Box<[git::Oid]>>, pub locked_to: Option<Box<[diff::HunkLock]>>,
pub change_type: diff::ChangeType, pub change_type: diff::ChangeType,
} }
@ -1734,13 +1735,20 @@ fn get_applied_status(
]; ];
} }
let mut commit_to_branch = HashMap::new();
for branch in &mut virtual_branches {
for commit in project_repository.log(branch.head, LogUntil::Commit(*target_sha))? {
commit_to_branch.insert(commit.id(), branch.id);
}
}
let mut diffs_by_branch: HashMap<BranchId, BranchStatus> = virtual_branches let mut diffs_by_branch: HashMap<BranchId, BranchStatus> = virtual_branches
.iter() .iter()
.map(|branch| (branch.id, HashMap::new())) .map(|branch| (branch.id, HashMap::new()))
.collect(); .collect();
let mut mtimes = MTimeCache::default(); let mut mtimes = MTimeCache::default();
let mut locked_hunk_map = HashMap::<HunkHash, Vec<git::Oid>>::new(); let mut locked_hunk_map = HashMap::<HunkHash, Vec<diff::HunkLock>>::new();
let merge_base = project_repository let merge_base = project_repository
.git_repository .git_repository
@ -1783,15 +1791,22 @@ fn get_applied_status(
if let Ok(blame) = blame { if let Ok(blame) = blame {
for blame_hunk in blame.iter() { for blame_hunk in blame.iter() {
let commit = git::Oid::from(blame_hunk.orig_commit_id()); let commit_id = git::Oid::from(blame_hunk.orig_commit_id());
if commit != *target_sha && commit != *integration_commit { if commit_id != *target_sha && commit_id != *integration_commit {
let hash = Hunk::hash_diff(hunk.diff_lines.as_ref()); let hash = Hunk::hash_diff(hunk.diff_lines.as_ref());
let branch_id = uuid::Uuid::parse_str(
&commit_to_branch.get(&commit_id).unwrap().to_string(),
)?;
let hunk_lock = diff::HunkLock {
branch_id,
commit_id,
};
locked_hunk_map locked_hunk_map
.entry(hash) .entry(hash)
.and_modify(|commits| { .and_modify(|locks| {
commits.push(commit); locks.push(hunk_lock);
}) })
.or_insert(vec![commit]); .or_insert(vec![hunk_lock]);
} }
} }
} }
@ -1799,13 +1814,6 @@ fn get_applied_status(
} }
} }
let mut commit_to_branch = HashMap::new();
for branch in &mut virtual_branches {
for commit in project_repository.log(branch.head, LogUntil::Commit(*target_sha))? {
commit_to_branch.insert(commit.id(), branch.id);
}
}
for branch in &mut virtual_branches { for branch in &mut virtual_branches {
if !branch.applied { if !branch.applied {
bail!("branch {} is not applied", branch.name); bail!("branch {} is not applied", branch.name);
@ -1902,9 +1910,11 @@ fn get_applied_status(
let hash = Hunk::hash_diff(hunk.diff_lines.as_ref()); let hash = Hunk::hash_diff(hunk.diff_lines.as_ref());
let locked_to = locked_hunk_map.get(&hash); let locked_to = locked_hunk_map.get(&hash);
let vbranch_pos = if let Some(locked_to) = locked_to { let vbranch_pos = if let Some(locks) = locked_to {
let branch_id = commit_to_branch.get(&locked_to[0]).unwrap(); let first_lock = &locks[0];
let p = virtual_branches.iter().position(|vb| vb.id == *branch_id); let p = virtual_branches
.iter()
.position(|vb| vb.id == Id::<Branch>::from(first_lock.branch_id));
match p { match p {
Some(p) => p, Some(p) => p,
_ => default_vbranch_pos, _ => default_vbranch_pos,

View File

@ -2,6 +2,7 @@ use gitbutler_core::{
id::Id, id::Id,
virtual_branches::{Branch, VirtualBranch}, virtual_branches::{Branch, VirtualBranch},
}; };
use uuid::Uuid;
use super::*; use super::*;
@ -275,8 +276,8 @@ async fn should_double_lock() {
let locks = &branch.files[0].hunks[0].locked_to.clone().unwrap(); let locks = &branch.files[0].hunks[0].locked_to.clone().unwrap();
assert_eq!(locks.len(), 2); assert_eq!(locks.len(), 2);
assert_eq!(locks[0], commit_1); assert_eq!(locks[0].commit_id, commit_1);
assert_eq!(locks[1], commit_2); assert_eq!(locks[1].commit_id, commit_2);
} }
// This test only validates that locks are detected across virtual branches, it does // This test only validates that locks are detected across virtual branches, it does
@ -311,7 +312,11 @@ async fn should_double_lock_across_branches() {
.await .await
.unwrap(); .unwrap();
controller // TODO(mg): Make `create_commit` clean up ownership of committed hunks.
// TODO(mg): Needed because next hunk overlaps with previous ownership.
get_virtual_branch(controller, project_id, branch_1_id).await;
let branch_2_id = controller
.create_virtual_branch( .create_virtual_branch(
project_id, project_id,
&branch::BranchCreateRequest { &branch::BranchCreateRequest {
@ -326,19 +331,26 @@ async fn should_double_lock_across_branches() {
write_file(repository, "file.txt", &lines); write_file(repository, "file.txt", &lines);
let commit_2 = controller let commit_2 = controller
.create_commit(project_id, &branch_1_id, "commit 2", None, false) .create_commit(project_id, &branch_2_id, "commit 2", None, false)
.await .await
.unwrap(); .unwrap();
lines[3] = "change3".to_string(); lines[3] = "change3".to_string();
write_file(repository, "file.txt", &lines); write_file(repository, "file.txt", &lines);
let branch = get_virtual_branch(controller, project_id, branch_1_id).await; let branch_1 = get_virtual_branch(controller, project_id, branch_1_id).await;
let locks = &branch.files[0].hunks[0].locked_to.clone().unwrap(); let locks = &branch_1.files[0].hunks[0].locked_to.clone().unwrap();
assert_eq!(locks.len(), 2); assert_eq!(locks.len(), 2);
assert_eq!(locks[0], commit_1); assert_eq!(locks[0].commit_id, commit_1);
assert_eq!(locks[1], commit_2); assert_eq!(locks[1].commit_id, commit_2);
assert_eq!(
locks[0].branch_id,
Uuid::parse_str(&branch_1_id.to_string()).unwrap()
);
assert_eq!(
locks[1].branch_id,
Uuid::parse_str(&branch_2_id.to_string()).unwrap()
);
} }
fn write_file(repository: &TestProject, path: &str, lines: &[String]) { fn write_file(repository: &TestProject, path: &str, lines: &[String]) {