Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ Every key holds its real value, so a Run reading it does exactly what it does wi

Pressing Enter takes the default shown, which is the file's current value, or else the setting's default, and for the address the suggested email above. `logs.dir` is not asked about. The answers are written like everything else below: in place, keeping your comments. Ctrl-C during the questions writes nothing. With no terminal, as from cron or `thirdshift setup </dev/null`, `setup` asks nothing. Either way it prints the file's path on stderr and exits `0` with stdout empty. Over a User config that is already there, `setup` edits it in place: its comments and key order stay, as do the values it didn't ask about, and each key it lacks is added at its default with its comment, so afterwards the file lists every setting this version knows. One that already does, down to the commented-out `email.to` line, is left byte for byte as it was. A key added to an inline table, such as `launch = { pull = true }`, gets no comment, since TOML has no place for one there. One a Run would refuse is refused the same way, exit `1`, and not touched. Any argument after `setup` is an argument error (exit `2`).

The first Run on a machine with no User config, started from a terminal, offers Setup before any work, on stderr: `No User config at <path>. Set your defaults now? [Y/n]`. Yes (or Enter) asks the questions above, writes the file, and the Run carries on using your answers; a flag in the command, such as `--no-merge`, `--email` or `--no-email`, still wins over them. No writes every setting at its default, as `setup` with no terminal does, says that `thirdshift setup` changes it, and the Run carries on; later Runs find the file and don't offer again. If the file can't be written, stderr gets a `warning:` line and the Run carries on with the defaults. Ctrl-C during the offer or the questions writes nothing and ends the command before any work, with no Run notification. A command thirdshift can't parse exits `2` before any offer. A Run with no terminal, as from cron, CI, `nohup` or an agent's shell, offers nothing, writes nothing, and runs on the defaults, so a later Run from a terminal still gets the offer.

With `merge.always = true`, `thirdshift <Issue URL>` is a Merge run, and `thirdshift --no-merge <Issue URL>` (or `no-merge`, before or after the URL) leaves that one Run's pull request ready for review.

With `launch.pull = true`, every Run brings the Base branch checked out in the directory you start it from (the **Launch directory**) up to date with `origin`, so you no longer `git pull` by hand before each Run. It happens after the pre-flight checks pass and before the worktree is created, as `git merge --ff-only origin/<Base branch>`: fast-forward only, never a merge commit or a rebase, always from `origin`, whatever the branch's upstream or your `pull.*` settings. A progress line on stderr says when it updates the branch; an already up-to-date branch is left quietly as it is. It is skipped when the checked-out branch isn't the Base branch, as in a Continuation whose open pull request targets another base, or on a detached HEAD. If the update can't happen, for example because uncommitted changes are in the way, stderr gets a `warning:` line with git's error and the command to run by hand, your changes are left as they were, and the Run carries on with the same outcome and exit code. The setting only affects your checkout: the Run's worktree starts from `origin/<Base branch>` either way.
Expand All @@ -195,7 +197,7 @@ With `email.always = true`, every Run sends a [Run notification](#run-notificati

`logs.dir` sets the directory [session logs](#logs) are written to, created if missing. It must be an absolute path, `~` or a path starting with `~/`, where `~` stands for `$HOME`. A relative path stops the Run before any work, since the directory a Run is launched from is no base for a setting that holds for every Run.

A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as `alway` for `always`, or a value of the wrong type, such as anything but `true` or `false` for `always`, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. `email-test` and `setup` read it the same way. `update`, `version` and `help` never read it, so a broken User config can't block them.
A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as `alway` for `always`, or a value of the wrong type, such as anything but `true` or `false` for `always`, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. `email-test` and `setup` read it the same way. `update`, `version` and `help` never read it, so a broken User config can't block them, and they never offer Setup; nor does `email-test`.

### Email

Expand Down
78 changes: 71 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! The User config: `~/.thirdshift/config.toml`, this machine's defaults for
//! every Run. Only a Run, `email-test` and `setup` read it, so a broken one
//! can't block `update`, `version` or `help`.
//! can't block `update`, `version` or `help`, and only a Run offers Setup
//! when there is none.

use std::io::Write;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -166,16 +167,13 @@ pub fn setup() -> Result<String> {
let asked = questions::has_terminal();
let mut send_test = false;
if asked {
let current = UserConfig::parse(&text, &path, &home)?;
let answers = questions::ask(&current, || suggested_address(&home))?;
let answers;
(text, answers) = answered(&text, &path, &home)?;
send_test = answers.send_test();
text = with_answers(&text, &answers)?;
}
let written = match &existing {
None => {
std::fs::create_dir_all(home.join(".thirdshift"))
.and_then(|()| std::fs::write(&path, &text))
.with_context(|| format!("can't write {}", path.display()))?;
write_new(&home, &path, &text)?;
format!("wrote the User config {}", path.display())
}
Some(existing) if *existing == text && asked => {
Expand Down Expand Up @@ -208,6 +206,72 @@ pub fn setup() -> Result<String> {
Ok(email::send_test(None, &config.email)?.to_string())
}

/// The first Run's offer of Setup, made only from a terminal and only when
/// there is no User config. Yes asks the Setup questions and writes the
/// answers; no writes every setting at its default, as `setup` with no
/// terminal does. Either way the Run then loads what was written. A User
/// config that can't be written, or a test email that can't go, is a
/// warning, so the Run carries on with the defaults; stdin closing before
/// the last answer ends the command before any work, with nothing written.
pub fn offer_setup() -> Result<()> {
if !questions::has_terminal() {
return Ok(());
}
let (home, path) = home_and_path()?;
match std::fs::symlink_metadata(&path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
_ => return Ok(()),
}
let accepted = questions::offer(&path)?;
let mut text = with_email_to(suggested_address(&home));
let mut send_test = false;
if accepted {
let answers;
(text, answers) = answered(&text, &path, &home)?;
send_test = answers.send_test();
}
if let Err(error) = write_new(&home, &path, &text) {
progress::step(format_args!(
"warning: {error:#}; carrying on with the defaults"
));
return Ok(());
}
if !accepted {
progress::step(format_args!(
"wrote the User config {} with every setting at its default; \
thirdshift setup changes it",
path.display()
));
return Ok(());
}
progress::step(format_args!("wrote the User config {}", path.display()));
if send_test {
let sent = UserConfig::load().and_then(|config| email::send_test(None, &config.email));
match sent {
Ok(sent) => progress::step(sent),
Err(error) => progress::step(format_args!("warning: {error:#}")),
}
}
Ok(())
}

/// Ask the Setup questions, with the settings in `text`, the User config at
/// `path`, as the default answers. Returns `text` with the answers, and the
/// answers.
fn answered(text: &str, path: &Path, home: &Path) -> Result<(String, Answers)> {
let current = UserConfig::parse(text, path, home)?;
let answers = questions::ask(&current, || suggested_address(home))?;
Ok((with_answers(text, &answers)?, answers))
}

/// Write `text` as the User config at `path`, where there is none yet,
/// creating `~/.thirdshift` under `home` if it is missing.
fn write_new(home: &Path, path: &Path, text: &str) -> Result<()> {
std::fs::create_dir_all(home.join(".thirdshift"))
.and_then(|()| std::fs::write(path, text))
.with_context(|| format!("can't write {}", path.display()))
}

/// `text`, a User config with every key, or a commented-out `email.to`, with
/// the values `answers` gives. Only those values change: the spacing and
/// comments around each stay, as does everything else in `text`.
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ fn main() -> ExitCode {
Ok(Command::Run(run_args)) => run_args,
Err(error) => return argument_error(format_args!("{error:#}")),
};
let config = match UserConfig::load() {
let config = match config::offer_setup().and_then(|()| UserConfig::load()) {
Ok(config) => config,
Err(error) => {
progress::step(format_args!("{error:#}"));
Expand Down
13 changes: 13 additions & 0 deletions src/questions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! by pressing Enter, is the current value.

use std::io::{BufRead, IsTerminal, Write};
use std::path::Path;

use anyhow::{Result, bail};

Expand Down Expand Up @@ -44,6 +45,18 @@ pub fn has_terminal() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}

/// The first Run's offer of Setup, with no User config at `path`: whether
/// the user wants to set their defaults now, yes unless they say no.
pub fn offer(path: &Path) -> Result<bool> {
yes_or_no(
&format!(
"No User config at {}. Set your defaults now?",
path.display()
),
true,
)
}

/// Ask the Setup questions, with the settings in `current` as the default
/// answers, and `suggested_address` for `email.to` when `current` has none.
/// The address is re-asked until it has an `@`. Ends in an error if stdin
Expand Down
Loading
Loading