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
+18 -3
View File
@@ -63,14 +63,21 @@ Terminal=false
or click **📂** to pick a folder in a native file browser — and press **Open**.
The directory is created if it does not exist.
2. Press **Init git** once to make the workspace a git repository. To sync with
another machine, add a remote yourself:
another machine, point it at a remote through **Settings ▸ Git remote…**:
paste the repository's clone URL into the **origin** box and press **Set
origin**. The repository has to exist on the server already — this attaches
to it, it does not create it. Clearing the box and pressing Set detaches
again. The equivalent from a terminal, if you prefer:
```sh
cd ~/Manuscript
git remote add origin <url>
git push -u origin main
```
Either way the URL lives in the repository's own `.git/config`, not in this
app's settings, so every workspace keeps its own.
After that, the **⟳ Sync (git)** button commits all changes, `pull --rebase`s,
and pushes.
and pushes (setting the upstream on the first push). Until origin is set it
commits locally and says so in the log.
If you open a folder that is *not* itself a repository but sits inside one
(a parent directory is a git work tree), the app asks whether to **use the
@@ -509,6 +516,14 @@ push command alone and is not written into the project's `.git/config`.
The token is stored in `config.json` in plain text, the same as the Mistral API
key.
Note that this is the *only* place the app creates a remote repository, and it
runs entirely inside the template's hook — the app itself never talks to Gitea.
So it applies to projects made through **New project** and nothing else, and it
does nothing if **Run the template's hooks** is off, if any of the three Gitea
settings is blank, or if the template has no such hook. For any workspace that
did not come from the template, attach the remote with **Settings ▸ Git
remote…** instead.
## New files from a template
The file list has two create buttons:
+12
View File
@@ -203,6 +203,12 @@ pub struct App {
rename_focus: bool,
/// Whether the delete confirmation opened from that menu is up.
confirm_delete: bool,
/// Editable copy of the workspace repository's `origin` URL. Read back from
/// git whenever the repository changes, never persisted to the config —
/// the remote belongs to the checkout, not to the app.
remote_input: String,
/// Whether the git-remote dialog is up.
show_git_remote: bool,
status: String,
show_log: bool,
git_log: String,
@@ -371,6 +377,8 @@ impl App {
show_rename: false,
rename_focus: false,
confirm_delete: false,
remote_input: String::new(),
show_git_remote: false,
status: String::new(),
show_log: false,
git_log: String::new(),
@@ -534,6 +542,10 @@ impl eframe::App for App {
self.confirm_delete_window(ctx);
}
if self.show_git_remote {
self.git_remote_window(ctx);
}
if self.show_new_project {
self.new_project_window(ctx);
}
+4
View File
@@ -424,6 +424,10 @@ impl App {
ui.close_menu();
self.show_project_settings = true;
}
if ui.button("Git remote…").clicked() {
ui.close_menu();
self.open_git_remote();
}
if ui.button("Manuscript details…").clicked() {
ui.close_menu();
self.show_manuscript_settings = true;
+110
View File
@@ -137,6 +137,7 @@ impl App {
if ws.join(".git").exists() {
self.is_repo = true;
self.repo_root = Some(ws.to_path_buf());
self.remote_input = gitsync::origin_url(ws).unwrap_or_default();
return;
}
match gitsync::repo_root(ws) {
@@ -151,10 +152,12 @@ impl App {
Some(root) => {
self.is_repo = true;
self.repo_root = Some(root);
self.remote_input = gitsync::origin_url(ws).unwrap_or_default();
}
None => {
self.is_repo = false;
self.repo_root = None;
self.remote_input.clear();
}
}
}
@@ -166,6 +169,7 @@ impl App {
self.status = format!("Using git repository at {}", root.display());
self.is_repo = true;
self.repo_root = Some(root);
self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default();
}
}
@@ -660,6 +664,7 @@ impl App {
self.git_log = outcome.log;
self.show_log = true;
self.is_repo = gitsync::is_repo(&ws);
self.remote_input = gitsync::origin_url(&ws).unwrap_or_default();
self.repo_root = self.is_repo.then_some(ws);
self.status = if self.is_repo {
"Initialised git repository".to_string()
@@ -1025,6 +1030,111 @@ impl App {
}
/// Settings dialog for the markdown seeded into template-backed new files.
/// Open the git-remote dialog, reading the current `origin` fresh so the
/// box shows what the repository actually holds.
pub(super) fn open_git_remote(&mut self) {
self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default();
self.show_git_remote = true;
}
/// Point the workspace's repository at the typed remote.
///
/// Nothing about the URL is kept in this app's config: it is written to the
/// repository and read straight back, so what the box shows afterwards is
/// what git holds, and a different project shows its own.
pub(super) fn set_git_remote(&mut self) {
let ws = self.workspace().to_path_buf();
if !self.is_repo {
self.status = "Not a git repository yet \u{2014} use \u{201c}Init git\u{201d} first"
.to_string();
return;
}
let outcome = gitsync::set_origin(&ws, &self.remote_input);
self.git_log = outcome.log;
self.show_log = true;
self.remote_input = gitsync::origin_url(&ws).unwrap_or_default();
self.status = if !outcome.ok {
"Could not set the remote (see log)".to_string()
} else if self.remote_input.is_empty() {
"Removed the origin remote".to_string()
} else {
format!("origin is now {}", self.remote_input)
};
}
/// The dialog for attaching a workspace to an existing remote repository.
///
/// Creating the repository on the server is a template hook's job, and only
/// happens for projects made through New project. Anything else — a
/// workspace that predates the app, or one whose hook did not run — needs
/// the remote set by hand, which otherwise meant leaving for a terminal.
pub(super) fn git_remote_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_git_remote;
let mut apply = false;
let mut close = false;
let is_repo = self.is_repo;
let root = self
.repo_root
.as_ref()
.map(|r| r.display().to_string())
.unwrap_or_default();
egui::Window::new("Git remote")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(460.0)
.show(ctx, |ui| {
if !is_repo {
ui.label(
egui::RichText::new(
"This workspace is not a git repository yet. Use \u{201c}Init \
git\u{201d} in the toolbar first, then set the remote here.",
)
.small()
.weak(),
);
ui.separator();
}
ui.label(egui::RichText::new(format!("Repository: {root}")).small().weak());
ui.add_space(4.0);
ui.label("origin:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.remote_input)
.desired_width(f32::INFINITY)
.hint_text("ssh://git@host:port/user/repo.git"),
);
apply |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.label(
egui::RichText::new(
"The repository has to exist on the server already \u{2014} this \
attaches to it, it does not create it. Until origin is set, \
\u{201c}\u{27f3} Sync (git)\u{201d} commits locally and says so in \
the log. Clear the box and press Set to detach.",
)
.small()
.weak(),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(
"Stored in the repository, not in this app's settings, so each \
project keeps its own.",
)
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
apply |= ui.add_enabled(is_repo, egui::Button::new("Set origin")).clicked();
close |= ui.button("Close").clicked();
});
});
if apply {
self.set_git_remote();
}
self.show_git_remote = open && !close;
}
/// Carry out what a file row's right-click menu picked.
///
/// Right-clicking acts on the row you clicked, so that row becomes the
+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);
}
}