Let the git remote be set from Settings

Attaching a workspace to an existing repository meant leaving for a
terminal: gitsync could init, commit and push, and could tell whether an
'origin' existed, but had no way to create one, and nothing in the
settings held a remote URL. Sync just logged "no 'origin' remote
configured" and committed locally.

Settings > Git remote... now shows the workspace repository's origin and
lets it be set, repointed or cleared. The URL is read from and written
to the repository, never to config.json, because a remote belongs to the
checkout — so each workspace shows its own, and the box is refreshed
whenever the repository in play changes.

This is separate from the Gitea publishing in New project, which is a
cookiecutter post-gen hook and only ever applies to generated projects.

Also factors out first_output_line, which repo_root and the new
origin_url were both open-coding, and origin_args, so the add-vs-set-url
decision can be tested without a repository on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
landon
2026-09-09 17:54:27 -05:00
parent aa050cf411
commit 91718f7487
5 changed files with 246 additions and 9 deletions
+102 -6
View File
@@ -27,6 +27,17 @@ fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) {
}
}
/// The first real output line of a `run_git` result.
///
/// `run_git` prefixes a `$ git …` echo for the log window, so a caller after a
/// single value — a path, a URL — has to step over it.
fn first_output_line(out: &str) -> Option<&str> {
out.lines()
.find(|l| !l.starts_with("$ git") && !l.trim().is_empty())
.map(str::trim)
.filter(|l| !l.is_empty())
}
/// True if the workspace directory is inside a git working tree.
pub fn is_repo(workspace: &Path) -> bool {
let (ok, out) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]);
@@ -43,12 +54,7 @@ pub fn repo_root(workspace: &Path) -> Option<PathBuf> {
if !ok {
return None;
}
// run_git prepends a `$ git …` echo line; the toplevel path is the first
// real output line.
let line = out
.lines()
.find(|l| !l.starts_with("$ git") && !l.trim().is_empty())?;
let path = PathBuf::from(line.trim());
let path = PathBuf::from(first_output_line(&out)?);
(!path.as_os_str().is_empty()).then_some(path)
}
@@ -58,6 +64,49 @@ fn has_origin(workspace: &Path) -> bool {
ok && out.lines().any(|l| l.trim() == "origin")
}
/// The URL `origin` points at, if the remote exists.
///
/// Read from the repository rather than from this app's settings: a remote
/// belongs to the checkout, so the workspace is the source of truth and opening
/// a different project shows that project's remote.
pub fn origin_url(workspace: &Path) -> Option<String> {
let (ok, out) = run_git(workspace, &["remote", "get-url", "origin"]);
if !ok {
return None;
}
first_output_line(&out).map(str::to_string)
}
/// Point `origin` at `url`, adding the remote when it is not there yet.
///
/// A blank `url` removes the remote instead, which is how a workspace is
/// disconnected from a server without touching its history.
pub fn set_origin(workspace: &Path, url: &str) -> GitOutcome {
let Some(args) = origin_args(url, has_origin(workspace)) else {
return GitOutcome {
ok: true,
log: "(there was no 'origin' to remove)\n".to_string(),
};
};
let (ok, log) = run_git(workspace, &args);
GitOutcome { ok, log }
}
/// The git command that points `origin` at `url`, or `None` when there is
/// nothing to do. Kept separate from running it, as with the cookiecutter
/// argument builder, so the decision table can be asserted in tests without a
/// repository on disk — picking `add` where `set-url` was needed (or the other
/// way round) fails at the git level, not here.
pub fn origin_args(url: &str, has_origin: bool) -> Option<Vec<&str>> {
let url = url.trim();
match (url.is_empty(), has_origin) {
(true, true) => Some(vec!["remote", "remove", "origin"]),
(true, false) => None,
(false, true) => Some(vec!["remote", "set-url", "origin", url]),
(false, false) => Some(vec!["remote", "add", "origin", url]),
}
}
/// Initialise a new git repository in the workspace with a starter .gitignore.
pub fn init(workspace: &Path) -> GitOutcome {
let mut log = String::new();
@@ -154,3 +203,50 @@ pub fn has_commits(workspace: &Path) -> bool {
fn strip_command_echo(text: &str) -> String {
text.split_once('\n').map_or(text, |(_, rest)| rest).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setting_a_remote_adds_it_only_when_it_is_missing() {
// Fresh repository: the remote has to be created.
assert_eq!(
origin_args("ssh://git@host:2228/me/book.git", false),
Some(vec!["remote", "add", "origin", "ssh://git@host:2228/me/book.git"])
);
// Already pointed somewhere: `add` would fail, so repoint instead.
assert_eq!(
origin_args("ssh://git@host:2228/me/book.git", true),
Some(vec!["remote", "set-url", "origin", "ssh://git@host:2228/me/book.git"])
);
}
#[test]
fn clearing_the_box_detaches_the_remote() {
assert_eq!(origin_args("", true), Some(vec!["remote", "remove", "origin"]));
assert_eq!(origin_args(" ", true), Some(vec!["remote", "remove", "origin"]));
// Nothing to remove, so nothing to run — and no error to report.
assert_eq!(origin_args("", false), None);
}
#[test]
fn a_pasted_url_is_trimmed_before_it_reaches_git() {
// Copying a clone URL out of a web page brings whitespace with it, and
// git would take it literally.
assert_eq!(
origin_args(" ssh://git@host/me/book.git\n", false),
Some(vec!["remote", "add", "origin", "ssh://git@host/me/book.git"])
);
}
#[test]
fn the_command_echo_is_skipped_when_reading_a_single_value() {
// What `run_git` actually hands back: its own echo line, then output.
let out = "$ git remote get-url origin\nssh://git@host/me/book.git\n";
assert_eq!(first_output_line(out), Some("ssh://git@host/me/book.git"));
// No remote configured: git says nothing, so neither do we.
assert_eq!(first_output_line("$ git remote get-url origin\n"), None);
assert_eq!(first_output_line(""), None);
}
}