fix: treat name-only git remotes as local-only
This commit is contained in:
@@ -475,7 +475,7 @@ mod tests {
|
||||
assert_eq!(pull.status, "no_remote");
|
||||
|
||||
let push = git_push(vault.clone()).await.unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
assert_eq!(push.status, "no_remote");
|
||||
|
||||
let status = git_remote_status(vault.clone()).await.unwrap();
|
||||
assert!(!status.has_remote);
|
||||
|
||||
@@ -18,10 +18,22 @@ pub struct GitPullResult {
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = git_command()
|
||||
.args(["remote"])
|
||||
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git remote: {}", e))?;
|
||||
.map_err(|e| format!("Failed to inspect git remotes: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(false);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
"Failed to inspect git remotes".to_string()
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
|
||||
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
|
||||
}
|
||||
@@ -181,7 +193,7 @@ fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -210,6 +222,10 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
);
|
||||
}
|
||||
|
||||
if is_no_remote_push_error(&lower) {
|
||||
return push_error("no_remote", "No remote configured");
|
||||
}
|
||||
|
||||
push_error(
|
||||
"error",
|
||||
format!("Push failed: {}", push_error_detail(stderr)),
|
||||
@@ -259,6 +275,18 @@ fn is_network_push_error(lower: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_no_remote_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"no configured push destination",
|
||||
"does not appear to be a git repository",
|
||||
"no such remote",
|
||||
"no upstream branch",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error_detail(stderr: &str) -> String {
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
@@ -283,6 +311,13 @@ fn push_error_detail(stderr: &str) -> String {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPushResult {
|
||||
status: "no_remote".to_string(),
|
||||
message: "No remote configured".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
@@ -331,6 +366,27 @@ mod tests {
|
||||
assert!(has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_ignores_name_only_remote_without_url() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
git_command()
|
||||
.args(["config", "remote.origin.prune", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let remote_names = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin"));
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -430,6 +486,14 @@ hint: have locally."#;
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_no_remote() {
|
||||
let stderr = "fatal: No configured push destination.";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.message.contains("No remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
@@ -469,6 +533,19 @@ hint: have locally."#;
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let result = git_push(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
|
||||
@@ -549,6 +549,40 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pullAndPush skips push for local-only vaults with no remote', async () => {
|
||||
const { result } = renderSync()
|
||||
await waitFor(() => {
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
|
||||
mockInvokeFn.mockClear()
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve([])
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
if (cmd === 'git_remote_status') return Promise.resolve({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
if (cmd === 'git_pull') {
|
||||
return Promise.resolve({
|
||||
status: 'no_remote',
|
||||
message: 'No remote configured',
|
||||
updatedFiles: [],
|
||||
conflictFiles: [],
|
||||
})
|
||||
}
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
|
||||
return Promise.resolve(upToDate())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.pullAndPush()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('git_push', { vaultPath: '/Users/luca/Laputa' })
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces pull conflicts and pull errors during recovery pushes', async () => {
|
||||
let mode: 'conflict' | 'error' = 'conflict'
|
||||
|
||||
|
||||
@@ -521,7 +521,17 @@ export function useAutoSync({
|
||||
})
|
||||
}
|
||||
|
||||
const pushOutcomes = await Promise.all(pullVaultPaths.map(async (path) => ({
|
||||
const pushVaultPaths = pullOutcomes
|
||||
.filter((outcome) => outcome.result.status !== 'no_remote')
|
||||
.map((outcome) => outcome.vaultPath)
|
||||
|
||||
if (pushVaultPaths.length === 0) {
|
||||
clearConflictState(setSyncStatus, setConflictFiles, setConflictVaultPath)
|
||||
void refreshRemoteStatus(pullVaultPaths)
|
||||
return
|
||||
}
|
||||
|
||||
const pushOutcomes = await Promise.all(pushVaultPaths.map(async (path) => ({
|
||||
result: await tauriCall<GitPushResult>('git_push', { vaultPath: path }),
|
||||
vaultPath: path,
|
||||
})))
|
||||
|
||||
@@ -141,7 +141,7 @@ export interface GitPullResult {
|
||||
}
|
||||
|
||||
export interface GitPushResult {
|
||||
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
|
||||
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'no_remote' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user