diff --git a/README.md b/README.md index 63f9ece..cb876b8 100644 --- a/README.md +++ b/README.md @@ -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 - 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: diff --git a/src/app/mod.rs b/src/app/mod.rs index fbaa9eb..f1f4aaa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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); } diff --git a/src/app/ui.rs b/src/app/ui.rs index 0e5fb06..94091b5 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -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; diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 67bed00..19c1844 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -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 diff --git a/src/gitsync.rs b/src/gitsync.rs index 6719463..01b6072 100644 --- a/src/gitsync.rs +++ b/src/gitsync.rs @@ -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 { 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 { + 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> { + 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); + } +}