Skip to content

Commit cd1c432

Browse files
committed
Add credential prompts for git clone and improve workspace import
1 parent 1ebd7c1 commit cd1c432

File tree

11 files changed

+135
-85
lines changed

11 files changed

+135
-85
lines changed

crates-tauri/yaak-app/src/git_ext.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ use crate::error::Result;
66
use std::path::{Path, PathBuf};
77
use tauri::command;
88
use yaak_git::{
9-
BranchDeleteResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add,
10-
git_add_credential, git_add_remote, git_checkout_branch, git_clone, git_commit,
11-
git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all, git_init,
12-
git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch, git_rm_remote,
13-
git_status, git_unstage,
9+
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
10+
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
11+
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
12+
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch,
13+
git_rm_remote, git_status, git_unstage,
1414
};
1515

1616
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
@@ -65,7 +65,7 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
6565
}
6666

6767
#[command]
68-
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<()> {
68+
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
6969
Ok(git_clone(url, dir).await?)
7070
}
7171

@@ -107,12 +107,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
107107

108108
#[command]
109109
pub async fn cmd_git_add_credential(
110-
dir: &Path,
111110
remote_url: &str,
112111
username: &str,
113112
password: &str,
114113
) -> Result<()> {
115-
Ok(git_add_credential(dir, remote_url, username, password).await?)
114+
Ok(git_add_credential(remote_url, username, password).await?)
116115
}
117116

118117
#[command]

crates/yaak-git/bindings/gen_git.ts

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/yaak-git/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
5959
if (creds == null) throw new Error('Canceled');
6060

6161
await invoke('cmd_git_add_credential', {
62-
dir,
6362
remoteUrl: result.url,
6463
username: creds.username,
6564
password: creds.password,
@@ -154,7 +153,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
154153
if (creds == null) throw new Error('Canceled');
155154

156155
await invoke('cmd_git_add_credential', {
157-
dir,
158156
remoteUrl: result.url,
159157
username: creds.username,
160158
password: creds.password,

crates/yaak-git/src/binary.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ use std::process::Stdio;
55
use tokio::process::Command;
66
use yaak_common::command::new_xplatform_command;
77

8+
/// Create a git command that runs in the specified directory
89
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
10+
let mut cmd = new_binary_command_global().await?;
11+
cmd.arg("-C").arg(dir);
12+
Ok(cmd)
13+
}
14+
15+
/// Create a git command without a specific directory (for global operations)
16+
pub(crate) async fn new_binary_command_global() -> Result<Command> {
917
// 1. Probe that `git` exists and is runnable
1018
let mut probe = new_xplatform_command("git");
1119
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
@@ -17,8 +25,6 @@ pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
1725
}
1826

1927
// 2. Build the reusable git command
20-
let mut cmd = new_xplatform_command("git");
21-
cmd.arg("-C").arg(dir);
22-
28+
let cmd = new_xplatform_command("git");
2329
Ok(cmd)
2430
}

crates/yaak-git/src/clone.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@ use crate::binary::new_binary_command;
22
use crate::error::Error::GenericError;
33
use crate::error::Result;
44
use log::info;
5+
use serde::{Deserialize, Serialize};
6+
use std::fs;
57
use std::path::Path;
8+
use ts_rs::TS;
69

7-
pub async fn git_clone(url: &str, dir: &Path) -> Result<()> {
10+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
11+
#[serde(rename_all = "snake_case", tag = "type")]
12+
#[ts(export, export_to = "gen_git.ts")]
13+
pub enum CloneResult {
14+
Success,
15+
NeedsCredentials { url: String, error: Option<String> },
16+
}
17+
18+
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
819
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
20+
fs::create_dir_all(parent)
21+
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
922
let mut cmd = new_binary_command(parent).await?;
1023
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
1124

@@ -15,12 +28,25 @@ pub async fn git_clone(url: &str, dir: &Path) -> Result<()> {
1528
let stdout = String::from_utf8_lossy(&out.stdout);
1629
let stderr = String::from_utf8_lossy(&out.stderr);
1730
let combined = format!("{}{}", stdout, stderr);
31+
let combined_lower = combined.to_lowercase();
1832

1933
info!("Cloned status={}: {combined}", out.status);
2034

2135
if !out.status.success() {
36+
// Check for credentials error
37+
if combined_lower.contains("could not read") {
38+
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
39+
}
40+
if combined_lower.contains("unable to access")
41+
|| combined_lower.contains("authentication failed")
42+
{
43+
return Ok(CloneResult::NeedsCredentials {
44+
url: url.to_string(),
45+
error: Some(combined.to_string()),
46+
});
47+
}
2248
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
2349
}
2450

25-
Ok(())
51+
Ok(CloneResult::Success)
2652
}

crates/yaak-git/src/credential.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
1-
use crate::binary::new_binary_command;
1+
use crate::binary::new_binary_command_global;
22
use crate::error::Error::GenericError;
33
use crate::error::Result;
4-
use std::path::Path;
54
use std::process::Stdio;
65
use tokio::io::AsyncWriteExt;
76
use url::Url;
87

9-
pub async fn git_add_credential(
10-
dir: &Path,
11-
remote_url: &str,
12-
username: &str,
13-
password: &str,
14-
) -> Result<()> {
8+
pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {
159
let url = Url::parse(remote_url)
1610
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
1711
let protocol = url.scheme();
1812
let host = url.host_str().unwrap();
1913
let path = Some(url.path());
2014

21-
let mut child = new_binary_command(dir)
15+
let mut child = new_binary_command_global()
2216
.await?
2317
.args(["credential", "approve"])
2418
.stdin(Stdio::piped())

crates/yaak-git/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub use branch::{
2323
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
2424
git_delete_remote_branch, git_merge_branch, git_rename_branch,
2525
};
26-
pub use clone::git_clone;
26+
pub use clone::{CloneResult, git_clone};
2727
pub use commit::git_commit;
2828
pub use credential::git_add_credential;
2929
pub use fetch::git_fetch_all;

src-web/components/CloneGitRepositoryDialog.tsx

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { open } from '@tauri-apps/plugin-dialog';
2-
import type { WorkspaceMeta } from '@yaakapp-internal/models';
3-
import { createGlobalModel, updateModel } from '@yaakapp-internal/models';
2+
import type { CloneResult } from '@yaakapp-internal/git';
43
import { useState } from 'react';
4+
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
55
import { appInfo } from '../lib/appInfo';
6-
import { router } from '../lib/router';
76
import { invokeCmd } from '../lib/tauri';
87
import { showErrorToast } from '../lib/toast';
98
import { Banner } from './core/Banner';
109
import { Button } from './core/Button';
1110
import { IconButton } from './core/IconButton';
1211
import { PlainInput } from './core/PlainInput';
1312
import { VStack } from './core/Stacks';
13+
import { promptCredentials } from './git/credentials';
1414

1515
interface Props {
1616
hide: () => void;
@@ -45,6 +45,24 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
4545
}
4646
};
4747

48+
const doClone = async (): Promise<CloneResult> => {
49+
const result = await invokeCmd<CloneResult>('cmd_git_clone', { url, dir: directory });
50+
if (result.type !== 'needs_credentials') return result;
51+
52+
// Prompt for credentials
53+
const creds = await promptCredentials({ url: result.url, error: result.error });
54+
if (creds == null) throw new Error('Cancelled');
55+
56+
// Store credentials and retry
57+
await invokeCmd('cmd_git_add_credential', {
58+
remoteUrl: result.url,
59+
username: creds.username,
60+
password: creds.password,
61+
});
62+
63+
return invokeCmd<CloneResult>('cmd_git_clone', { url, dir: directory });
64+
};
65+
4866
const handleClone = async (e: React.FormEvent) => {
4967
e.preventDefault();
5068
if (!url || !directory) return;
@@ -53,29 +71,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
5371
setError(null);
5472

5573
try {
56-
// Clone the repository
57-
await invokeCmd('cmd_git_clone', { url, dir: directory });
74+
const result = await doClone();
5875

59-
// Create a new workspace
60-
const workspaceId = await createGlobalModel({ model: 'workspace', name: repoName });
61-
if (workspaceId == null) {
62-
throw new Error('Failed to create workspace');
76+
if (result.type === 'needs_credentials') {
77+
setError(
78+
result.error ?? 'Authentication failed. Please check your credentials and try again.',
79+
);
80+
return;
6381
}
6482

65-
// Get and update workspace meta to set the sync directory
66-
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', {
67-
workspaceId,
68-
});
69-
await updateModel({
70-
...workspaceMeta,
71-
settingSyncDir: directory,
72-
});
73-
74-
// Navigate to the new workspace
75-
await router.navigate({
76-
to: '/workspaces/$workspaceId',
77-
params: { workspaceId },
78-
});
83+
// Open the workspace from the cloned directory
84+
await openWorkspaceFromSyncDir.mutateAsync(directory);
7985

8086
hide();
8187
} catch (err) {
Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,16 @@
11
import type { GitCallbacks } from '@yaakapp-internal/git';
2-
import { showPromptForm } from '../../lib/prompt-form';
3-
import { Banner } from '../core/Banner';
4-
import { InlineCode } from '../core/InlineCode';
2+
import { promptCredentials } from './credentials';
53
import { addGitRemote } from './showAddRemoteDialog';
64

75
export function gitCallbacks(dir: string): GitCallbacks {
86
return {
97
addRemote: async () => {
108
return addGitRemote(dir);
119
},
12-
promptCredentials: async ({ url: remoteUrl, error }) => {
13-
const isGitHub = /github\.com/i.test(remoteUrl);
14-
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
15-
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
16-
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
17-
const passDescription = isGitHub
18-
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
19-
: 'Enter your password or access token for this Git server.';
20-
const r = await showPromptForm({
21-
id: 'git-credentials',
22-
title: 'Credentials Required',
23-
description: error ? (
24-
<Banner color="danger">{error}</Banner>
25-
) : (
26-
<>
27-
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
28-
</>
29-
),
30-
inputs: [
31-
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
32-
{
33-
type: 'text',
34-
name: 'password',
35-
label: passLabel,
36-
description: passDescription,
37-
password: true,
38-
},
39-
],
40-
});
41-
if (r == null) throw new Error('Cancelled credentials prompt');
42-
43-
const username = String(r.username || '');
44-
const password = String(r.password || '');
45-
return { username, password };
10+
promptCredentials: async ({ url, error }) => {
11+
const creds = await promptCredentials({ url, error });
12+
if (creds == null) throw new Error('Cancelled credentials prompt');
13+
return creds;
4614
},
4715
};
4816
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { showPromptForm } from '../../lib/prompt-form';
2+
import { Banner } from '../core/Banner';
3+
import { InlineCode } from '../core/InlineCode';
4+
5+
export interface GitCredentials {
6+
username: string;
7+
password: string;
8+
}
9+
10+
export async function promptCredentials({
11+
url: remoteUrl,
12+
error,
13+
}: {
14+
url: string;
15+
error: string | null;
16+
}): Promise<GitCredentials | null> {
17+
const isGitHub = /github\.com/i.test(remoteUrl);
18+
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
19+
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
20+
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
21+
const passDescription = isGitHub
22+
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
23+
: 'Enter your password or access token for this Git server.';
24+
const r = await showPromptForm({
25+
id: 'git-credentials',
26+
title: 'Credentials Required',
27+
description: error ? (
28+
<Banner color="danger">{error}</Banner>
29+
) : (
30+
<>
31+
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
32+
</>
33+
),
34+
inputs: [
35+
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
36+
{
37+
type: 'text',
38+
name: 'password',
39+
label: passLabel,
40+
description: passDescription,
41+
password: true,
42+
},
43+
],
44+
});
45+
if (r == null) return null;
46+
47+
const username = String(r.username || '');
48+
const password = String(r.password || '');
49+
return { username, password };
50+
}

0 commit comments

Comments
 (0)