* Excellent! All tasks completed successfully. Here's a summary of the changes:
I've successfully added typed error handling to the push method following the same pattern as `CreatePrError`. Here's what was implemented:
1. **Created `PushError` enum** in `crates/server/src/routes/task_attempts.rs:608-613`
- Single variant: `ForcePushRequired`
- Uses proper derives for TypeScript generation
2. **Updated push handler signature** in `crates/server/src/routes/task_attempts.rs:596`
- Changed return type to `Result<ResponseJson<ApiResponse<(), PushError>>, ApiError>`
3. **Registered error type** in `crates/server/src/bin/generate_types.rs:140`
- Added `PushError::decl()` to the type declarations
4. **Generated TypeScript type** in `shared/types.ts:301`
- Type: `export type PushError = { "type": "force_push_required" }`
5. **Updated frontend API** in `frontend/src/lib/api.ts`
- Imported `PushError` type
- Changed `push` method to return `Result<void, PushError>`
- Uses `handleApiResponseAsResult` for proper error handling
6. **Updated `usePush` hook** in `frontend/src/hooks/usePush.ts`
- Created custom `PushErrorWithData` class to properly pass error data
- Updated signature to pass `errorData` to `onError` callback
7. **Updated component error handling** in `frontend/src/hooks/useGitOperations.ts:47-52`
- Added handling for `force_push_required` error type
- Displays user-friendly message: "Force push required. The remote branch has diverged."
All checks pass successfully! The implementation is ready for future force push functionality.
Cleanup script changes for task attempt 982f21cc-f0a3-455d-8b30-3a9911c2889f
Excellent! All tasks completed successfully. Here's a summary of the force push implementation:
I've successfully implemented a complete force push feature with user confirmation. Here's what was done:
1. **Updated Git CLI** (`crates/services/src/services/git/cli.rs:318`)
- Added `force: bool` parameter to `push()` method
- Uses `+refs/heads/{branch}` refspec when force is true
2. **Updated Git Service** (`crates/services/src/services/git.rs:1615`)
- Added `force: bool` parameter to `push_to_github()` method
- Passes force flag to Git CLI
3. **Added Force Push Endpoint** (`crates/server/src/routes/task_attempts.rs:608`)
- New handler: `force_push_task_attempt_branch`
- Calls `push_to_github()` with `force: true`
- Regular push endpoint now passes `force: false`
4. **Added Route** (`crates/server/src/routes/task_attempts.rs:1581`)
- Route: `POST /api/task-attempts/:id/push/force`
5. **API Client** (`frontend/src/lib/api.ts:561`)
- Added `forcePush()` method
- Returns `Result<void, PushError>`
6. **Created useForcePush Hook** (`frontend/src/hooks/useForcePush.ts`)
- React Query mutation for force push
- Handles typed errors
- Invalidates branch status on success
7. **Created ForcePushDialog** (`frontend/src/components/dialogs/git/ForcePushDialog.tsx`)
- Destructive variant with warning icon
- Clear warning about overwriting remote changes
- Buttons: "Cancel" and "Force Push"
8. **Integrated Dialog Flow** (`frontend/src/hooks/useGitOperations.ts:58-85`)
- When regular push fails with `force_push_required` error:
- Shows ForcePushDialog to confirm
- If user confirms, executes force push
- Handles errors from force push operation
1. User attempts to push
2. If remote has diverged, backend returns `force_push_required` error
3. Frontend shows confirmation dialog explaining the consequences
4. User can cancel or confirm force push
5. If confirmed, force push is executed
6. Success/error feedback is shown
All checks pass successfully! The feature is ready to use.
Cleanup script changes for task attempt 982f21cc-f0a3-455d-8b30-3a9911c2889f
Perfect! The fix is complete and all checks pass. Here's what was changed:
**Problem:** The `push_task_attempt_branch` endpoint was using the `?` operator which converted `GitServiceError::GitCLI(GitCliError::PushRejected(_))` into a generic `ApiError`, preventing the frontend from receiving the typed `PushError::ForcePushRequired` error.
**Solution:** Updated the endpoint in `crates/server/src/routes/task_attempts.rs:602-613` to use a `match` statement that:
1. Returns success on `Ok(_)`
2. Detects `GitServiceError::GitCLI(GitCliError::PushRejected(_))` and returns the typed error `ApiResponse::error_with_data(PushError::ForcePushRequired)`
3. Returns generic `ApiError::GitService(e)` for other errors
Now when a push is rejected due to non-fast-forward, the backend will return:
```json
{
"success": false,
"error_data": {
"type": "force_push_required"
}
}
```
This will trigger the force push confirmation dialog in the frontend, allowing the user to choose whether to force push or cancel.
Cleanup script changes for task attempt 982f21cc-f0a3-455d-8b30-3a9911c2889f
Perfect! The fix is complete and all checks pass. Here's what was changed:
**Problem:** The ForcePushDialog was not closing after the user clicked "Force Push" or "Cancel" because it only called `modal.resolve()` without calling `modal.hide()`.
**Solution:** Added `modal.hide()` calls to both handlers in `frontend/src/components/dialogs/git/ForcePushDialog.tsx`:
- **Line 24:** Added `modal.hide()` after `modal.resolve('confirmed')` in `handleConfirm`
- **Line 29:** Added `modal.hide()` after `modal.resolve('canceled')` in `handleCancel`
Now the dialog will properly close when the user clicks either button. This follows the correct NiceModal pattern where you need both:
1. `modal.resolve()` - to resolve the promise with a result
2. `modal.hide()` - to actually hide the dialog
The force push feature should now work end-to-end: push rejection → confirmation dialog → force push → dialog closes.
Perfect! All checks pass. Here's what was fixed:
The force push dialog now follows the same pattern as all other async dialogs in the codebase.
**Added:**
- `attemptId` prop (required) to identify which task attempt to force push
- `useForcePush` hook usage internally
- Loading state with `forcePush.isPending`
- Error state displayed in the dialog
- Loader spinner and "Force Pushing..." button text during operation
- Disabled buttons during operation
- Dialog stays open until operation completes
**Behavior:**
- User clicks "Force Push" → button shows spinner and "Force Pushing..."
- On success → dialog closes automatically
- On error → error shown in dialog, stays open, user can retry or cancel
**Changed:**
- Removed the manual force push call after dialog confirmation
- Now passes `attemptId` to the dialog: `ForcePushDialog.show({ attemptId })`
- Dialog handles the entire force push operation internally
1. User attempts push
2. Push rejected with `force_push_required` error
3. Dialog appears with warning
4. User clicks "Force Push"
5. **Button shows spinner and "Force Pushing..."** ← New!
6. **Buttons disabled during operation** ← New!
7. On success: dialog closes
8. On error: error displayed in dialog, user can retry
This matches the pattern used by CreatePRDialog, GhCliSetupDialog, and other async dialogs in the codebase.
* Force push i18n (vibe-kanban 5519a7db)
Run @scripts/check-i18n.sh until it passes. Make sure to check the script and set GITHUB_BASE_REF to vk/607c-add-pre-flight-c
Force push i18n (vibe-kanban 5519a7db)
Run @scripts/check-i18n.sh until it passes. Make sure to check the script and set GITHUB_BASE_REF to vk/607c-add-pre-flight-c
* fix tests
1409 lines
49 KiB
Rust
1409 lines
49 KiB
Rust
use std::{
|
|
fs,
|
|
io::Write,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use git2::{PushOptions, Repository, build::CheckoutBuilder};
|
|
use services::services::git::{GitCli, GitCliError, GitService};
|
|
use tempfile::TempDir;
|
|
// Avoid direct git CLI usage in tests; exercise GitService instead.
|
|
|
|
fn write_file<P: AsRef<Path>>(base: P, rel: &str, content: &str) {
|
|
let path = base.as_ref().join(rel);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).unwrap();
|
|
}
|
|
let mut f = fs::File::create(&path).unwrap();
|
|
f.write_all(content.as_bytes()).unwrap();
|
|
}
|
|
|
|
fn commit_all(repo: &Repository, message: &str) {
|
|
let mut index = repo.index().unwrap();
|
|
index
|
|
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
|
|
.unwrap();
|
|
index.write().unwrap();
|
|
let tree_id = index.write_tree().unwrap();
|
|
let tree = repo.find_tree(tree_id).unwrap();
|
|
let sig = repo.signature().unwrap();
|
|
let parents: Vec<git2::Commit> = match repo.head() {
|
|
Ok(h) => vec![h.peel_to_commit().unwrap()],
|
|
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => vec![],
|
|
Err(e) => panic!("failed to read HEAD: {e}"),
|
|
};
|
|
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
|
|
let update_ref = if repo.head().is_ok() {
|
|
Some("HEAD")
|
|
} else {
|
|
None
|
|
};
|
|
repo.commit(update_ref, &sig, &sig, message, &tree, &parent_refs)
|
|
.unwrap();
|
|
}
|
|
|
|
fn checkout_branch(repo: &Repository, name: &str) {
|
|
repo.set_head(&format!("refs/heads/{name}")).unwrap();
|
|
let mut co = CheckoutBuilder::new();
|
|
co.force();
|
|
repo.checkout_head(Some(&mut co)).unwrap();
|
|
}
|
|
|
|
fn create_branch_from_head(repo: &Repository, name: &str) {
|
|
let head = repo.head().unwrap().peel_to_commit().unwrap();
|
|
let _ = repo.branch(name, &head, true).unwrap();
|
|
}
|
|
|
|
fn configure_user(repo: &Repository) {
|
|
let mut cfg = repo.config().unwrap();
|
|
cfg.set_str("user.name", "Test User").unwrap();
|
|
cfg.set_str("user.email", "test@example.com").unwrap();
|
|
}
|
|
|
|
fn push_ref(repo: &Repository, local: &str, remote: &str) {
|
|
let mut remote_handle = repo.find_remote("origin").unwrap();
|
|
let mut opts = PushOptions::new();
|
|
let spec = format!("+{local}:{remote}");
|
|
remote_handle
|
|
.push(&[spec.as_str()], Some(&mut opts))
|
|
.unwrap();
|
|
}
|
|
|
|
fn add_path(repo_path: &Path, path: &str) {
|
|
let git = GitCli::new();
|
|
git.git(repo_path, ["add", path]).unwrap();
|
|
}
|
|
|
|
use services::services::git::DiffTarget;
|
|
|
|
// Non-conflicting setup used by several tests
|
|
fn setup_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) {
|
|
let repo_path = root.path().join("repo");
|
|
let worktree_path = root.path().join("wt-feature");
|
|
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&repo_path)
|
|
.expect("init repo");
|
|
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
|
|
write_file(&repo_path, "common.txt", "base\n");
|
|
commit_all(&repo, "initial main commit");
|
|
|
|
create_branch_from_head(&repo, "old-base");
|
|
checkout_branch(&repo, "old-base");
|
|
write_file(&repo_path, "base.txt", "from old-base\n");
|
|
commit_all(&repo, "old-base commit");
|
|
|
|
checkout_branch(&repo, "main");
|
|
create_branch_from_head(&repo, "new-base");
|
|
checkout_branch(&repo, "new-base");
|
|
write_file(&repo_path, "base.txt", "from new-base\n");
|
|
commit_all(&repo, "new-base commit");
|
|
|
|
checkout_branch(&repo, "old-base");
|
|
create_branch_from_head(&repo, "feature");
|
|
|
|
let svc = GitService::new();
|
|
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.expect("create worktree");
|
|
|
|
write_file(&worktree_path, "feat.txt", "feat change\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature commit");
|
|
|
|
(repo_path, worktree_path)
|
|
}
|
|
|
|
// Conflicting setup to simulate interactive rebase interruption
|
|
fn setup_conflict_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) {
|
|
let repo_path = root.path().join("repo");
|
|
let worktree_path = root.path().join("wt-feature");
|
|
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&repo_path)
|
|
.expect("init repo");
|
|
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
|
|
write_file(&repo_path, "conflict.txt", "base\n");
|
|
commit_all(&repo, "initial main commit");
|
|
|
|
// old-base modifies conflict.txt one way
|
|
create_branch_from_head(&repo, "old-base");
|
|
checkout_branch(&repo, "old-base");
|
|
write_file(&repo_path, "conflict.txt", "old-base version\n");
|
|
commit_all(&repo, "old-base change");
|
|
|
|
// feature builds on old-base and modifies same lines differently
|
|
create_branch_from_head(&repo, "feature");
|
|
|
|
// new-base modifies in a conflicting way
|
|
checkout_branch(&repo, "main");
|
|
create_branch_from_head(&repo, "new-base");
|
|
checkout_branch(&repo, "new-base");
|
|
write_file(&repo_path, "conflict.txt", "new-base version\n");
|
|
commit_all(&repo, "new-base change");
|
|
|
|
// add a worktree for feature and create the conflicting commit
|
|
let svc = GitService::new();
|
|
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.expect("create worktree");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
write_file(&worktree_path, "conflict.txt", "feature version\n");
|
|
commit_all(&wt_repo, "feature conflicting change");
|
|
|
|
(repo_path, worktree_path)
|
|
}
|
|
|
|
// Setup where feature has no unique commits (feature == old-base)
|
|
fn setup_no_unique_feature_repo(root: &TempDir) -> (PathBuf, PathBuf) {
|
|
let repo_path = root.path().join("repo");
|
|
let worktree_path = root.path().join("wt-feature");
|
|
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&repo_path)
|
|
.expect("init repo");
|
|
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
|
|
write_file(&repo_path, "base.txt", "main base\n");
|
|
commit_all(&repo, "initial main commit");
|
|
|
|
// Create old-base at this point
|
|
create_branch_from_head(&repo, "old-base");
|
|
// Create new-base diverging
|
|
checkout_branch(&repo, "main");
|
|
create_branch_from_head(&repo, "new-base");
|
|
checkout_branch(&repo, "new-base");
|
|
write_file(&repo_path, "advance.txt", "new base\n");
|
|
commit_all(&repo, "advance new-base");
|
|
|
|
// Create feature equal to old-base (no unique commits)
|
|
checkout_branch(&repo, "old-base");
|
|
create_branch_from_head(&repo, "feature");
|
|
let svc = GitService::new();
|
|
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.expect("create worktree");
|
|
|
|
(repo_path, worktree_path)
|
|
}
|
|
|
|
// Simple two-way conflict between main and feature on the same file
|
|
fn setup_direct_conflict_repo(root: &TempDir) -> (PathBuf, PathBuf) {
|
|
let repo_path = root.path().join("repo");
|
|
let worktree_path = root.path().join("wt-feature");
|
|
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&repo_path)
|
|
.expect("init repo");
|
|
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
|
|
write_file(&repo_path, "conflict.txt", "base\n");
|
|
commit_all(&repo, "initial main commit");
|
|
|
|
// Create feature and commit conflicting change
|
|
create_branch_from_head(&repo, "feature");
|
|
let svc = GitService::new();
|
|
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.expect("create worktree");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
write_file(&worktree_path, "conflict.txt", "feature change\n");
|
|
commit_all(&wt_repo, "feature change");
|
|
|
|
// Change main in a conflicting way
|
|
checkout_branch(&repo, "main");
|
|
write_file(&repo_path, "conflict.txt", "main change\n");
|
|
commit_all(&repo, "main change");
|
|
|
|
(repo_path, worktree_path)
|
|
}
|
|
|
|
#[test]
|
|
fn push_reports_non_fast_forward() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let remote_path = temp_dir.path().join("remote.git");
|
|
Repository::init_bare(&remote_path).expect("init bare remote");
|
|
let remote_url = remote_path.to_str().expect("remote path str");
|
|
|
|
// Seed the bare repo with an initial main branch commit
|
|
let seed_path = temp_dir.path().join("seed");
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&seed_path)
|
|
.expect("init seed repo");
|
|
let seed_repo = Repository::open(&seed_path).expect("open seed repo");
|
|
configure_user(&seed_repo);
|
|
seed_repo.remote("origin", remote_url).expect("add remote");
|
|
push_ref(&seed_repo, "refs/heads/main", "refs/heads/main");
|
|
Repository::open_bare(&remote_path)
|
|
.expect("open bare remote")
|
|
.set_head("refs/heads/main")
|
|
.expect("set remote HEAD");
|
|
|
|
// Local clone that will attempt the push later
|
|
let local_path = temp_dir.path().join("local");
|
|
let local_repo = Repository::clone(remote_url, &local_path).expect("clone local");
|
|
configure_user(&local_repo);
|
|
checkout_branch(&local_repo, "main");
|
|
write_file(&local_path, "file.txt", "initial local\n");
|
|
commit_all(&local_repo, "initial local commit");
|
|
push_ref(&local_repo, "refs/heads/main", "refs/heads/main");
|
|
|
|
// Separate clone simulates someone else pushing first
|
|
let updater_path = temp_dir.path().join("updater");
|
|
let updater_repo = Repository::clone(remote_url, &updater_path).expect("clone updater");
|
|
configure_user(&updater_repo);
|
|
checkout_branch(&updater_repo, "main");
|
|
write_file(&updater_path, "file.txt", "upstream change\n");
|
|
commit_all(&updater_repo, "upstream commit");
|
|
push_ref(&updater_repo, "refs/heads/main", "refs/heads/main");
|
|
|
|
// Local branch diverges but has not fetched the updater's commit
|
|
write_file(&local_path, "file.txt", "local change\n");
|
|
commit_all(&local_repo, "local commit");
|
|
let remote = local_repo.find_remote("origin").expect("origin remote");
|
|
let remote_url_string = remote.url().expect("origin url").to_string();
|
|
|
|
let git_cli = GitCli::new();
|
|
let result = git_cli.push(&local_path, &remote_url_string, "main", false);
|
|
match result {
|
|
Err(GitCliError::PushRejected(msg)) => {
|
|
let lower = msg.to_ascii_lowercase();
|
|
assert!(
|
|
lower.contains("failed to push some refs") || lower.contains("fetch first"),
|
|
"unexpected stderr: {msg}"
|
|
);
|
|
}
|
|
Err(other) => panic!("expected push rejected, got {other:?}"),
|
|
Ok(_) => panic!("push unexpectedly succeeded"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_with_missing_ref_returns_error() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let remote_path = temp_dir.path().join("remote.git");
|
|
Repository::init_bare(&remote_path).expect("init bare remote");
|
|
let remote_url = remote_path.to_str().expect("remote path str");
|
|
|
|
let seed_path = temp_dir.path().join("seed");
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&seed_path)
|
|
.expect("init seed repo");
|
|
let seed_repo = Repository::open(&seed_path).expect("open seed repo");
|
|
configure_user(&seed_repo);
|
|
seed_repo.remote("origin", remote_url).expect("add remote");
|
|
push_ref(&seed_repo, "refs/heads/main", "refs/heads/main");
|
|
Repository::open_bare(&remote_path)
|
|
.expect("open bare remote")
|
|
.set_head("refs/heads/main")
|
|
.expect("set remote HEAD");
|
|
|
|
let local_path = temp_dir.path().join("local");
|
|
Repository::clone(remote_url, &local_path).expect("clone local");
|
|
|
|
let git_cli = GitCli::new();
|
|
let refspec = "+refs/heads/missing:refs/remotes/origin/missing";
|
|
let result = git_cli.fetch_with_refspec(&local_path, remote_url, refspec);
|
|
match result {
|
|
Err(GitCliError::CommandFailed(msg)) => {
|
|
assert!(
|
|
msg.to_ascii_lowercase()
|
|
.contains("couldn't find remote ref"),
|
|
"unexpected stderr: {msg}"
|
|
);
|
|
}
|
|
Err(other) => panic!("expected command failed, got {other:?}"),
|
|
Ok(_) => panic!("fetch unexpectedly succeeded"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn push_and_fetch_roundtrip_updates_tracking_branch() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let remote_path = temp_dir.path().join("remote.git");
|
|
Repository::init_bare(&remote_path).expect("init bare remote");
|
|
let remote_url = remote_path.to_str().expect("remote path str");
|
|
|
|
let seed_path = temp_dir.path().join("seed");
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&seed_path)
|
|
.expect("init seed repo");
|
|
let seed_repo = Repository::open(&seed_path).expect("open seed repo");
|
|
configure_user(&seed_repo);
|
|
seed_repo.remote("origin", remote_url).expect("add remote");
|
|
push_ref(&seed_repo, "refs/heads/main", "refs/heads/main");
|
|
Repository::open_bare(&remote_path)
|
|
.expect("open bare remote")
|
|
.set_head("refs/heads/main")
|
|
.expect("set remote HEAD");
|
|
|
|
let producer_path = temp_dir.path().join("producer");
|
|
let producer_repo = Repository::clone(remote_url, &producer_path).expect("clone producer");
|
|
configure_user(&producer_repo);
|
|
checkout_branch(&producer_repo, "main");
|
|
|
|
let consumer_path = temp_dir.path().join("consumer");
|
|
let consumer_repo = Repository::clone(remote_url, &consumer_path).expect("clone consumer");
|
|
configure_user(&consumer_repo);
|
|
checkout_branch(&consumer_repo, "main");
|
|
let old_oid = consumer_repo
|
|
.find_reference("refs/remotes/origin/main")
|
|
.expect("consumer tracking ref")
|
|
.target()
|
|
.expect("consumer tracking ref");
|
|
|
|
write_file(&producer_path, "file.txt", "new work\n");
|
|
commit_all(&producer_repo, "producer commit");
|
|
|
|
let remote = producer_repo.find_remote("origin").expect("origin remote");
|
|
let remote_url_string = remote.url().expect("origin url").to_string();
|
|
|
|
let git_cli = GitCli::new();
|
|
git_cli
|
|
.push(&producer_path, &remote_url_string, "main", false)
|
|
.expect("push succeeded");
|
|
|
|
let new_oid = producer_repo
|
|
.head()
|
|
.expect("producer head")
|
|
.target()
|
|
.expect("producer head oid");
|
|
assert_ne!(old_oid, new_oid, "producer created new commit");
|
|
|
|
git_cli
|
|
.fetch_with_refspec(
|
|
&consumer_path,
|
|
&remote_url_string,
|
|
"+refs/heads/main:refs/remotes/origin/main",
|
|
)
|
|
.expect("fetch succeeded");
|
|
|
|
let updated_oid = consumer_repo
|
|
.find_reference("refs/remotes/origin/main")
|
|
.expect("updated tracking ref")
|
|
.target()
|
|
.expect("updated tracking ref");
|
|
assert_eq!(
|
|
updated_oid, new_oid,
|
|
"tracking branch advanced to remote head"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_preserves_untracked_files() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
write_file(&worktree_path, "scratch/untracked.txt", "temporary note\n");
|
|
|
|
let service = GitService::new();
|
|
let res = service.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
);
|
|
assert!(res.is_ok(), "rebase should succeed: {res:?}");
|
|
|
|
let scratch = worktree_path.join("scratch/untracked.txt");
|
|
let content = fs::read_to_string(&scratch).expect("untracked file exists");
|
|
assert_eq!(content, "temporary note\n");
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_aborts_on_uncommitted_tracked_changes() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
write_file(&worktree_path, "feat.txt", "feat change (edited)\n");
|
|
|
|
let service = GitService::new();
|
|
let res = service.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
);
|
|
assert!(res.is_err(), "rebase should fail on dirty worktree");
|
|
|
|
let edited = fs::read_to_string(worktree_path.join("feat.txt")).unwrap();
|
|
assert_eq!(edited, "feat change (edited)\n");
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_aborts_if_untracked_would_be_overwritten_by_base() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
write_file(&worktree_path, "base.txt", "my scratch note\n");
|
|
|
|
let service = GitService::new();
|
|
let res = service.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
);
|
|
assert!(
|
|
res.is_err(),
|
|
"rebase should fail due to untracked overwrite risk"
|
|
);
|
|
|
|
let content = std::fs::read_to_string(worktree_path.join("base.txt")).unwrap();
|
|
assert_eq!(content, "my scratch note\n");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_does_not_overwrite_main_repo_untracked_files() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
write_file(&worktree_path, "danger.txt", "tracked from feature\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "add danger.txt in feature");
|
|
|
|
write_file(&repo_path, "danger.txt", "my untracked data\n");
|
|
let main_repo = Repository::open(&repo_path).unwrap();
|
|
checkout_branch(&main_repo, "main");
|
|
|
|
let service = GitService::new();
|
|
let res = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"squash merge",
|
|
);
|
|
assert!(
|
|
res.is_err(),
|
|
"merge should refuse due to untracked conflict"
|
|
);
|
|
|
|
// Untracked file remains untouched
|
|
let content = std::fs::read_to_string(repo_path.join("danger.txt")).unwrap();
|
|
assert_eq!(content, "my untracked data\n");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_does_not_touch_tracked_uncommitted_changes_in_base_worktree() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
// Prepare: modify a tracked file in the base worktree (main) without committing
|
|
let _main_repo = Repository::open(&repo_path).unwrap();
|
|
// Base branch commits will be advanced by the merge operation; record before via service
|
|
let g = GitService::new();
|
|
let before_oid = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
|
|
// Create a tracked file that will also be added by feature branch to simulate overlap
|
|
write_file(&repo_path, "danger2.txt", "my staged change\n");
|
|
{
|
|
// stage and then unstage to leave WT_MODIFIED? Simpler: just modify an existing tracked file
|
|
// Use common.txt which is tracked
|
|
write_file(&repo_path, "common.txt", "edited locally\n");
|
|
}
|
|
|
|
// Feature adds a change and is committed in worktree
|
|
write_file(&worktree_path, "danger2.txt", "feature tracked\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature adds danger2.txt");
|
|
|
|
// Merge via service (squash into main) should not modify files in the main worktree
|
|
let service = GitService::new();
|
|
let res = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"squash merge",
|
|
);
|
|
assert!(
|
|
res.is_ok(),
|
|
"merge should succeed without touching worktree"
|
|
);
|
|
|
|
// Confirm the local edit to tracked file remains
|
|
let content = std::fs::read_to_string(repo_path.join("common.txt")).unwrap();
|
|
assert_eq!(content, "edited locally\n");
|
|
|
|
// Confirm the main branch ref advanced
|
|
let after_oid = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_ne!(before_oid, after_oid, "main ref should be updated by merge");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_refuses_with_staged_changes_on_base() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let s = GitService::new();
|
|
// ensure main is checked out
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
checkout_branch(&repo, "main");
|
|
// feature adds change and commits
|
|
write_file(&worktree_path, "m.txt", "feature\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feat change");
|
|
// main has staged change
|
|
write_file(&repo_path, "staged.txt", "staged\n");
|
|
add_path(&repo_path, "staged.txt");
|
|
let res = s.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash");
|
|
assert!(res.is_err(), "should refuse merge due to staged changes");
|
|
// staged file remains
|
|
let content = std::fs::read_to_string(repo_path.join("staged.txt")).unwrap();
|
|
assert_eq!(content, "staged\n");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_preserves_unstaged_changes_on_base() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let s = GitService::new();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
checkout_branch(&repo, "main");
|
|
// modify unstaged
|
|
write_file(&repo_path, "common.txt", "local edited\n");
|
|
// feature modifies a different file
|
|
write_file(&worktree_path, "merged.txt", "merged content\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature merged");
|
|
|
|
let _sha = s
|
|
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
|
|
.unwrap();
|
|
// local edit preserved
|
|
let loc = std::fs::read_to_string(repo_path.join("common.txt")).unwrap();
|
|
assert_eq!(loc, "local edited\n");
|
|
// merged file updated
|
|
let m = std::fs::read_to_string(repo_path.join("merged.txt")).unwrap();
|
|
assert_eq!(m, "merged content\n");
|
|
}
|
|
|
|
#[test]
|
|
fn update_ref_does_not_destroy_feature_worktree_dirty_state() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let s = GitService::new();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
// ensure main is checked out
|
|
checkout_branch(&repo, "main");
|
|
// feature makes an initial change and commits
|
|
write_file(&worktree_path, "f.txt", "feat\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feat commit");
|
|
// dirty change in feature worktree (uncommitted)
|
|
write_file(&worktree_path, "dirty.txt", "unstaged\n");
|
|
// merge from feature into main (CLI path updates task ref via update-ref)
|
|
let sha = s
|
|
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
|
|
.unwrap();
|
|
// uncommitted change in feature worktree preserved
|
|
let dirty = std::fs::read_to_string(worktree_path.join("dirty.txt")).unwrap();
|
|
assert_eq!(dirty, "unstaged\n");
|
|
// feature branch ref updated to the squash commit in main repo
|
|
let feature_oid = s.get_branch_oid(&repo_path, "feature").unwrap();
|
|
assert_eq!(feature_oid, sha);
|
|
// and the feature worktree HEAD now points to that commit
|
|
let head = s.get_head_info(&worktree_path).unwrap();
|
|
assert_eq!(head.branch, "feature");
|
|
assert_eq!(head.oid, sha);
|
|
}
|
|
|
|
#[test]
|
|
fn libgit2_merge_updates_base_ref_in_both_repos() {
|
|
// Ensure we hit the libgit2 path by NOT checking out the base branch in main repo
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let s = GitService::new();
|
|
|
|
// Record current main OID from both main repo and worktree repo; they should match pre-merge
|
|
let before_main_repo = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
let before_main_wt = s.get_branch_oid(&worktree_path, "main").unwrap();
|
|
assert_eq!(before_main_repo, before_main_wt);
|
|
|
|
// Perform merge (squash) while main repo is NOT on base branch (libgit2 path)
|
|
let sha = s
|
|
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
|
|
.expect("merge should succeed via libgit2 path");
|
|
|
|
// Base branch ref advanced in both main and worktree repositories
|
|
let after_main_repo = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
let after_main_wt = s.get_branch_oid(&worktree_path, "main").unwrap();
|
|
assert_eq!(after_main_repo, sha);
|
|
assert_eq!(after_main_wt, sha);
|
|
}
|
|
|
|
#[test]
|
|
fn libgit2_merge_updates_task_ref_and_feature_head_preserves_dirty() {
|
|
// Hit libgit2 path (main repo not on base) and verify task ref + HEAD update safely
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let s = GitService::new();
|
|
|
|
// Make an uncommitted change in the feature worktree to ensure it's preserved
|
|
write_file(&worktree_path, "dirty2.txt", "keep me\n");
|
|
|
|
// Perform merge (squash) from feature into main; this path uses libgit2
|
|
let sha = s
|
|
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
|
|
.expect("merge should succeed via libgit2 path");
|
|
|
|
// Dirty file preserved in worktree
|
|
let dirty = std::fs::read_to_string(worktree_path.join("dirty2.txt")).unwrap();
|
|
assert_eq!(dirty, "keep me\n");
|
|
|
|
// Task branch (feature) updated to squash commit in both repos
|
|
let feat_main_repo = s.get_branch_oid(&repo_path, "feature").unwrap();
|
|
let feat_worktree = s.get_branch_oid(&worktree_path, "feature").unwrap();
|
|
assert_eq!(feat_main_repo, sha);
|
|
assert_eq!(feat_worktree, sha);
|
|
|
|
// Feature worktree HEAD points to the new squash commit
|
|
let head = s.get_head_info(&worktree_path).unwrap();
|
|
assert_eq!(head.branch, "feature");
|
|
assert_eq!(head.oid, sha);
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_refuses_to_abort_existing_rebase() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_conflict_repo_with_worktree(&td);
|
|
|
|
// Start a rebase via GitService that will pause/conflict
|
|
let svc = GitService::new();
|
|
let _ = svc
|
|
.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
)
|
|
.expect_err("first rebase should error and leave in-progress state");
|
|
|
|
// Our service should refuse to proceed and not abort the user's rebase
|
|
let service = GitService::new();
|
|
let res = service.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
);
|
|
assert!(res.is_err(), "should error because rebase is in progress");
|
|
// Note: We do not auto-abort; user should resolve or abort explicitly
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_fast_forwards_when_no_unique_commits() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_no_unique_feature_repo(&td);
|
|
let g = GitService::new();
|
|
let before = g.get_head_info(&worktree_path).unwrap().oid;
|
|
let new_base_oid = g.get_branch_oid(&repo_path, "new-base").unwrap();
|
|
|
|
let _res = g
|
|
.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
)
|
|
.expect("rebase should succeed");
|
|
let after_oid = g.get_head_info(&worktree_path).unwrap().oid;
|
|
assert_ne!(before, after_oid, "HEAD should move after rebase");
|
|
assert_eq!(after_oid, new_base_oid, "fast-forward onto new-base");
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_applies_multiple_commits_onto_ahead_base() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
// Advance new-base further
|
|
checkout_branch(&repo, "new-base");
|
|
write_file(&repo_path, "base_more.txt", "nb more\n");
|
|
commit_all(&repo, "advance new-base more");
|
|
|
|
// Add another commit to feature
|
|
write_file(&worktree_path, "feat2.txt", "second change\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature second commit");
|
|
|
|
// Rebase feature onto new-base
|
|
let service = GitService::new();
|
|
let _ = service
|
|
.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
)
|
|
.expect("rebase should succeed");
|
|
|
|
// Verify both files exist with expected content in the rebased worktree
|
|
let feat = std::fs::read_to_string(worktree_path.join("feat.txt")).unwrap();
|
|
let feat2 = std::fs::read_to_string(worktree_path.join("feat2.txt")).unwrap();
|
|
assert_eq!(feat, "feat change\n");
|
|
assert_eq!(feat2, "second change\n");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_when_base_ahead_and_feature_ahead_fails() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
// Advance base (main) after feature was created
|
|
checkout_branch(&repo, "main");
|
|
write_file(&repo_path, "base_ahead.txt", "base ahead\n");
|
|
commit_all(&repo, "base ahead commit");
|
|
|
|
// Feature adds its own file (already has feat.txt from setup) and commit another
|
|
write_file(&worktree_path, "another.txt", "feature ahead\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature ahead extra");
|
|
|
|
let g = GitService::new();
|
|
let before_main = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
|
|
// Attempt to merge (squash) into main - should fail because base is ahead
|
|
let service = GitService::new();
|
|
let res = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"squash merge",
|
|
);
|
|
|
|
assert!(
|
|
res.is_err(),
|
|
"merge should fail when base branch is ahead of task branch"
|
|
);
|
|
|
|
// Verify main branch was not modified
|
|
let after_main = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(
|
|
before_main, after_main,
|
|
"main ref should remain unchanged when merge fails"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_conflict_does_not_move_base_ref() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_direct_conflict_repo(&td);
|
|
|
|
// Record main ref before
|
|
let _repo = Repository::open(&repo_path).unwrap();
|
|
let g = GitService::new();
|
|
let before = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
|
|
let service = GitService::new();
|
|
let res = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"squash merge",
|
|
);
|
|
|
|
assert!(res.is_err(), "conflicting merge should fail");
|
|
|
|
let after = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(before, after, "main ref must remain unchanged on conflict");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_delete_vs_modify_conflict_behaves_safely() {
|
|
// main modifies file, feature deletes it -> but now blocked by branch ahead check
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
|
|
// start from main with a file
|
|
checkout_branch(&repo, "main");
|
|
write_file(&repo_path, "conflict_dm.txt", "base\n");
|
|
commit_all(&repo, "add conflict file");
|
|
|
|
// feature deletes it and commits
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
let path = worktree_path.join("conflict_dm.txt");
|
|
if path.exists() {
|
|
std::fs::remove_file(&path).unwrap();
|
|
}
|
|
commit_all(&wt_repo, "delete in feature");
|
|
|
|
// main modifies same file (this puts main ahead of feature)
|
|
write_file(&repo_path, "conflict_dm.txt", "main modify\n");
|
|
commit_all(&repo, "modify in main");
|
|
|
|
// Capture main state AFTER all setup commits
|
|
let g = GitService::new();
|
|
let before = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
|
|
let service = GitService::new();
|
|
let res = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"squash merge",
|
|
);
|
|
|
|
// Should now fail due to base branch being ahead, not due to merge conflicts
|
|
assert!(res.is_err(), "merge should fail when base branch is ahead");
|
|
|
|
// Ensure base ref unchanged on failure
|
|
let after = g.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(before, after, "main ref must remain unchanged on failure");
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_preserves_rename_changes() {
|
|
// feature renames a file; rebase onto new-base preserves rename
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
// feature: rename feat.txt -> feat_renamed.txt
|
|
std::fs::rename(
|
|
worktree_path.join("feat.txt"),
|
|
worktree_path.join("feat_renamed.txt"),
|
|
)
|
|
.unwrap();
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "rename feat");
|
|
|
|
// rebase onto new-base
|
|
let service = GitService::new();
|
|
let _ = service
|
|
.rebase_branch(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"new-base",
|
|
"old-base",
|
|
"feature",
|
|
)
|
|
.expect("rebase should succeed");
|
|
// after rebase, renamed file present; original absent
|
|
assert!(worktree_path.join("feat_renamed.txt").exists());
|
|
assert!(!worktree_path.join("feat.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_refreshes_main_worktree_when_on_base() {
|
|
let td = TempDir::new().unwrap();
|
|
// Initialize repo and ensure main is checked out
|
|
let repo_path = td.path().join("repo_refresh");
|
|
let s = GitService::new();
|
|
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
// Baseline file
|
|
write_file(&repo_path, "file.txt", "base\n");
|
|
let _ = s.commit(&repo_path, "add base").unwrap();
|
|
|
|
// Create feature branch and worktree
|
|
create_branch_from_head(&repo, "feature");
|
|
let wt = td.path().join("wt_refresh");
|
|
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
|
|
// Modify file in worktree and commit
|
|
write_file(&wt, "file.txt", "feature change\n");
|
|
let _ = s.commit(&wt, "feature change").unwrap();
|
|
|
|
// Merge into main (squash) and ensure main worktree is updated since it is on base
|
|
let merge_sha = s
|
|
.merge_changes(&repo_path, &wt, "feature", "main", "squash")
|
|
.unwrap();
|
|
// Since main is on base branch and we use safe CLI merge, both working tree
|
|
// and ref should reflect the merged content.
|
|
let content = std::fs::read_to_string(repo_path.join("file.txt")).unwrap();
|
|
assert_eq!(content, "feature change\n");
|
|
let oid = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(oid, merge_sha);
|
|
}
|
|
|
|
#[test]
|
|
fn sparse_checkout_respected_in_worktree_diffs_and_commit() {
|
|
let td = TempDir::new().unwrap();
|
|
let repo_path = td.path().join("repo_sparse");
|
|
let s = GitService::new();
|
|
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
// baseline content
|
|
write_file(&repo_path, "included/a.txt", "A\n");
|
|
write_file(&repo_path, "excluded/b.txt", "B\n");
|
|
let _ = s.commit(&repo_path, "baseline").unwrap();
|
|
|
|
// enable sparse-checkout for 'included' only
|
|
let cli = GitCli::new();
|
|
cli.git(&repo_path, ["sparse-checkout", "init", "--cone"])
|
|
.unwrap();
|
|
cli.git(&repo_path, ["sparse-checkout", "set", "included"])
|
|
.unwrap();
|
|
|
|
// create feature branch and worktree
|
|
create_branch_from_head(&repo, "feature");
|
|
let wt = td.path().join("wt_sparse");
|
|
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
|
|
|
|
// materialization check: included exists, excluded does not
|
|
assert!(wt.join("included/a.txt").exists());
|
|
assert!(!wt.join("excluded/b.txt").exists());
|
|
|
|
// modify included file
|
|
write_file(&wt, "included/a.txt", "A-mod\n");
|
|
let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap();
|
|
// get worktree diffs vs main, ensure excluded/b.txt is NOT reported deleted
|
|
let diffs = s
|
|
.get_diffs(
|
|
DiffTarget::Worktree {
|
|
worktree_path: Path::new(&wt),
|
|
base_commit: &base_commit,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
diffs
|
|
.iter()
|
|
.any(|d| d.new_path.as_deref() == Some("included/a.txt"))
|
|
);
|
|
assert!(
|
|
!diffs
|
|
.iter()
|
|
.any(|d| d.old_path.as_deref() == Some("excluded/b.txt")
|
|
|| d.new_path.as_deref() == Some("excluded/b.txt"))
|
|
);
|
|
|
|
// commit and verify commit diffs also only include included/ changes
|
|
let _ = s.commit(&wt, "modify included").unwrap();
|
|
let head_sha = s.get_head_info(&wt).unwrap().oid;
|
|
let commit_diffs = s
|
|
.get_diffs(
|
|
DiffTarget::Commit {
|
|
repo_path: Path::new(&wt),
|
|
commit_sha: &head_sha,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
commit_diffs
|
|
.iter()
|
|
.any(|d| d.new_path.as_deref() == Some("included/a.txt"))
|
|
);
|
|
assert!(
|
|
commit_diffs
|
|
.iter()
|
|
.all(|d| d.new_path.as_deref() != Some("excluded/b.txt")
|
|
&& d.old_path.as_deref() != Some("excluded/b.txt"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_diff_ignores_commits_where_base_branch_is_ahead() {
|
|
let td = TempDir::new().unwrap();
|
|
let repo_path = td.path().join("repo_base_ahead");
|
|
let s = GitService::new();
|
|
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
|
|
write_file(&repo_path, "shared.txt", "base\n");
|
|
let _ = s.commit(&repo_path, "add shared").unwrap();
|
|
|
|
create_branch_from_head(&repo, "feature");
|
|
let wt = td.path().join("wt_base_ahead");
|
|
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
|
|
|
|
write_file(&repo_path, "base_only.txt", "main ahead\n");
|
|
let _ = s.commit(&repo_path, "main ahead").unwrap();
|
|
|
|
write_file(&wt, "feature.txt", "feature change\n");
|
|
let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap();
|
|
|
|
let diffs = s
|
|
.get_diffs(
|
|
DiffTarget::Worktree {
|
|
worktree_path: Path::new(&wt),
|
|
base_commit: &base_commit,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
diffs
|
|
.iter()
|
|
.any(|d| d.new_path.as_deref() == Some("feature.txt"))
|
|
);
|
|
assert!(diffs.iter().all(|d| {
|
|
d.new_path.as_deref() != Some("base_only.txt")
|
|
&& d.old_path.as_deref() != Some("base_only.txt")
|
|
}));
|
|
}
|
|
|
|
// Helper: initialize a repo with main, configure user via service
|
|
fn init_repo_only_service(root: &TempDir) -> PathBuf {
|
|
let repo_path = root.path().join("repo_svc");
|
|
let s = GitService::new();
|
|
s.initialize_repo_with_main_branch(&repo_path).unwrap();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
checkout_branch(&repo, "main");
|
|
repo_path
|
|
}
|
|
|
|
#[test]
|
|
fn merge_binary_conflict_does_not_move_ref() {
|
|
let td = TempDir::new().unwrap();
|
|
let repo_path = init_repo_only_service(&td);
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
let s = GitService::new();
|
|
// seed
|
|
let _ = s.commit(&repo_path, "seed").unwrap();
|
|
// create feature branch and worktree
|
|
create_branch_from_head(&repo, "feature");
|
|
let worktree_path = td.path().join("wt_bin");
|
|
s.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.unwrap();
|
|
|
|
// feature adds/commits binary file
|
|
let mut f = fs::File::create(worktree_path.join("bin.dat")).unwrap();
|
|
f.write_all(&[0, 1, 2, 3]).unwrap();
|
|
let _ = s.commit(&worktree_path, "feature bin").unwrap();
|
|
|
|
// main adds conflicting binary content
|
|
let mut f2 = fs::File::create(repo_path.join("bin.dat")).unwrap();
|
|
f2.write_all(&[9, 8, 7, 6]).unwrap();
|
|
let _ = s.commit(&repo_path, "main bin").unwrap();
|
|
|
|
let before = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
let res = s.merge_changes(&repo_path, &worktree_path, "feature", "main", "merge bin");
|
|
assert!(res.is_err(), "binary conflict should fail");
|
|
let after = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(before, after, "main ref unchanged on conflict");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_rename_vs_modify_conflict_does_not_move_ref() {
|
|
let td = TempDir::new().unwrap();
|
|
let repo_path = init_repo_only_service(&td);
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
let s = GitService::new();
|
|
// base file
|
|
fs::write(repo_path.join("conflict.txt"), b"base\n").unwrap();
|
|
let _ = s.commit(&repo_path, "base").unwrap();
|
|
create_branch_from_head(&repo, "feature");
|
|
let worktree_path = td.path().join("wt_ren");
|
|
s.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.unwrap();
|
|
|
|
// feature renames file
|
|
std::fs::rename(
|
|
worktree_path.join("conflict.txt"),
|
|
worktree_path.join("conflict_renamed.txt"),
|
|
)
|
|
.unwrap();
|
|
let _ = s.commit(&worktree_path, "rename").unwrap();
|
|
|
|
// main modifies original path
|
|
fs::write(repo_path.join("conflict.txt"), b"main change\n").unwrap();
|
|
let _ = s.commit(&repo_path, "modify main").unwrap();
|
|
|
|
let before = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
let res = s.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"merge rename",
|
|
);
|
|
match res {
|
|
Err(_) => {
|
|
let after = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(before, after, "main unchanged on conflict");
|
|
}
|
|
Ok(sha) => {
|
|
// ensure main advanced and result contains either renamed or modified content
|
|
let after = s.get_branch_oid(&repo_path, "main").unwrap();
|
|
assert_eq!(after, sha);
|
|
let diffs = s
|
|
.get_diffs(
|
|
DiffTarget::Commit {
|
|
repo_path: Path::new(&repo_path),
|
|
commit_sha: &after,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let has_renamed = diffs
|
|
.iter()
|
|
.any(|d| d.new_path.as_deref() == Some("conflict_renamed.txt"));
|
|
let has_modified = diffs.iter().any(|d| {
|
|
d.new_path.as_deref() == Some("conflict.txt")
|
|
&& d.new_content.as_deref() == Some("main change\n")
|
|
});
|
|
assert!(has_renamed || has_modified);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn merge_leaves_no_staged_changes_on_target_branch() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
// Ensure main repo is on the base branch (triggers CLI merge path)
|
|
let s = GitService::new();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
checkout_branch(&repo, "main");
|
|
|
|
// Feature branch makes some changes
|
|
write_file(&worktree_path, "feature_file.txt", "feature content\n");
|
|
write_file(&worktree_path, "common.txt", "modified by feature\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature changes");
|
|
|
|
// Perform the merge
|
|
let _merge_sha = s
|
|
.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"merge feature",
|
|
)
|
|
.expect("merge should succeed");
|
|
|
|
// THE KEY CHECK: Verify no staged changes remain on target branch
|
|
let git_cli = GitCli::new();
|
|
let has_staged = git_cli
|
|
.has_staged_changes(&repo_path)
|
|
.expect("should be able to check staged changes");
|
|
|
|
assert!(
|
|
!has_staged,
|
|
"Target branch should have no staged changes after merge"
|
|
);
|
|
|
|
// Debug info if test fails
|
|
if has_staged {
|
|
let status_output = git_cli.git(&repo_path, ["status", "--porcelain"]).unwrap();
|
|
panic!("Found staged changes after merge:\n{status_output}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_to_worktree_merge_leaves_no_staged_changes() {
|
|
let td = TempDir::new().unwrap();
|
|
let repo_path = td.path().join("repo");
|
|
let worktree_a_path = td.path().join("wt-feature-a");
|
|
let worktree_b_path = td.path().join("wt-feature-b");
|
|
|
|
// Setup: Initialize repo with main branch
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&repo_path)
|
|
.expect("init repo");
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
|
|
write_file(&repo_path, "base.txt", "base content\n");
|
|
commit_all(&repo, "initial commit");
|
|
|
|
// Create two feature branches
|
|
create_branch_from_head(&repo, "feature-a");
|
|
create_branch_from_head(&repo, "feature-b");
|
|
|
|
// Create worktrees for both feature branches
|
|
service
|
|
.add_worktree(&repo_path, &worktree_a_path, "feature-a", false)
|
|
.expect("create worktree A");
|
|
service
|
|
.add_worktree(&repo_path, &worktree_b_path, "feature-b", false)
|
|
.expect("create worktree B");
|
|
|
|
// Make changes in worktree A
|
|
write_file(
|
|
&worktree_a_path,
|
|
"feature_a.txt",
|
|
"content from feature A\n",
|
|
);
|
|
write_file(&worktree_a_path, "base.txt", "modified by feature A\n");
|
|
let wt_a_repo = Repository::open(&worktree_a_path).unwrap();
|
|
commit_all(&wt_a_repo, "feature A changes");
|
|
|
|
// Ensure main repo is on different branch (neither feature-a nor feature-b)
|
|
checkout_branch(&repo, "main");
|
|
|
|
let _sha = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_a_path,
|
|
"feature-a",
|
|
"feature-b",
|
|
"merge feature-a into feature-b",
|
|
);
|
|
|
|
// Verify no staged changes were introduced
|
|
let git_cli = GitCli::new();
|
|
let has_staged_main = git_cli
|
|
.has_staged_changes(&repo_path)
|
|
.expect("should be able to check staged changes in main repo");
|
|
let has_staged_target = git_cli
|
|
.has_staged_changes(&worktree_b_path)
|
|
.expect("should be able to check staged changes in target worktree");
|
|
|
|
assert!(
|
|
!has_staged_main,
|
|
"Main repo should have no staged changes after failed merge"
|
|
);
|
|
assert!(
|
|
!has_staged_target,
|
|
"Target worktree should have no staged changes after failed merge"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_into_orphaned_branch_uses_libgit2_fallback() {
|
|
let td = TempDir::new().unwrap();
|
|
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
|
|
|
|
// Create an "orphaned" target branch that exists as ref but isn't checked out anywhere
|
|
let service = GitService::new();
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
|
|
// Create orphaned-feature branch from current main HEAD but don't check it out
|
|
let main_commit = repo.head().unwrap().peel_to_commit().unwrap();
|
|
repo.branch("orphaned-feature", &main_commit, false)
|
|
.unwrap();
|
|
|
|
// Ensure main repo is on different branch and no worktree has orphaned-feature
|
|
checkout_branch(&repo, "main");
|
|
|
|
// Make changes in source worktree
|
|
write_file(
|
|
&worktree_path,
|
|
"feature_content.txt",
|
|
"content from feature\n",
|
|
);
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature changes");
|
|
|
|
// orphaned-feature is not checked out anywhere, so should trigger libgit2 path
|
|
|
|
// Perform merge into orphaned branch (should use libgit2 fallback)
|
|
let merge_sha = service
|
|
.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"orphaned-feature",
|
|
"merge into orphaned branch",
|
|
)
|
|
.expect("libgit2 merge into orphaned branch should succeed");
|
|
|
|
// Verify merge worked - orphaned-feature branch should now point to merge commit
|
|
let orphaned_branch_oid = service
|
|
.get_branch_oid(&repo_path, "orphaned-feature")
|
|
.unwrap();
|
|
assert_eq!(
|
|
orphaned_branch_oid, merge_sha,
|
|
"orphaned-feature branch should point to merge commit"
|
|
);
|
|
|
|
// Verify no working tree was affected (since branch wasn't checked out anywhere)
|
|
let main_git_cli = GitCli::new();
|
|
let main_has_staged = main_git_cli.has_staged_changes(&repo_path).unwrap();
|
|
let worktree_has_staged = main_git_cli.has_staged_changes(&worktree_path).unwrap();
|
|
|
|
assert!(
|
|
!main_has_staged,
|
|
"Main repo should remain clean after libgit2 merge"
|
|
);
|
|
assert!(
|
|
!worktree_has_staged,
|
|
"Source worktree should remain clean after libgit2 merge"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_base_ahead_of_task_should_error() {
|
|
let td = TempDir::new().unwrap();
|
|
let repo_path = td.path().join("repo");
|
|
let worktree_path = td.path().join("wt-feature");
|
|
|
|
// Setup: Initialize repo with main branch
|
|
let service = GitService::new();
|
|
service
|
|
.initialize_repo_with_main_branch(&repo_path)
|
|
.expect("init repo");
|
|
let repo = Repository::open(&repo_path).unwrap();
|
|
configure_user(&repo);
|
|
|
|
// Initial commit on main
|
|
write_file(&repo_path, "base.txt", "initial content\n");
|
|
commit_all(&repo, "initial commit");
|
|
|
|
// Create feature branch from this point
|
|
create_branch_from_head(&repo, "feature");
|
|
service
|
|
.add_worktree(&repo_path, &worktree_path, "feature", false)
|
|
.expect("create worktree");
|
|
|
|
// Feature makes a change and commits
|
|
write_file(&worktree_path, "feature.txt", "feature content\n");
|
|
let wt_repo = Repository::open(&worktree_path).unwrap();
|
|
commit_all(&wt_repo, "feature change");
|
|
|
|
// Main branch advances ahead of feature (this is the key scenario)
|
|
checkout_branch(&repo, "main");
|
|
write_file(&repo_path, "main_advance.txt", "main advanced\n");
|
|
commit_all(&repo, "main advances ahead");
|
|
write_file(&repo_path, "main_advance2.txt", "main advanced more\n");
|
|
commit_all(&repo, "main advances further");
|
|
|
|
// Attempt to merge feature into main when main is ahead
|
|
// This should error because base branch has moved ahead of task branch
|
|
let res = service.merge_changes(
|
|
&repo_path,
|
|
&worktree_path,
|
|
"feature",
|
|
"main",
|
|
"attempt merge when base ahead",
|
|
);
|
|
|
|
// TDD: This test will initially fail because merge currently succeeds
|
|
// Later we'll fix the merge logic to detect this scenario and error
|
|
assert!(
|
|
res.is_err(),
|
|
"Merge should error when base branch is ahead of task branch"
|
|
);
|
|
}
|