chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless commits as git does on your machine, and a runner as knotless
.changeset/commits-as-git.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# knotless commits as git does on your machine | ||
| ||
`knotless repo commit` with no `--author`, `knotless pull merge`, `pull update-branch` and `pull revert`, and the same tools over MCP on stdio, now author and commit as git would there (`user.name` and `user.email`), rather than as `<your did>@knotless.invalid`, which only knotless can tie to you. A history made with knotless reads on GitHub or any mirror as one made with git. With no git identity set, they commit as your account, as before. A runner landing the merge queue or writing back a workflow's files commits as `knotless`, whichever account it runs as. | ||
crates/knotless/src/branch_update.rs+1−1
| @@ -110,7 +110,7 @@ pub async fn update_branch(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<Up | ||
"\n\nBoth sides only added lines at the same place in {paths}: {into}'s come first." | ||
)); | ||
} | ||
let git = forge::committer(git, &me); | ||
let git = forge::committer(ctx, git, &me).await; | ||
let args = ["commit-tree", &tree, "-p", head, "-p", base, "-m", &message]; | ||
let commit = git.text(&args, None).await?; | ||
let update = Update { | ||
crates/knotless/src/cli.rs+4−2
| @@ -505,11 +505,13 @@ URLs look like knotless://<handle or did>/<name>. Pulls and issues on public | ||
repositories come from an appview: https://knotless.dev, or the one | ||
KNOTLESS_APPVIEW names (set empty, none)."; | ||
| ||
/// `knotless ...`. | ||
/// `knotless ...`, committing as git does here. | ||
pub fn main(args: Vec<String>) -> i32 { | ||
let mut env = Env::from_process(); | ||
env.signer = crate::env::Signer::Git; | ||
main_with( | ||
args, | ||
Env::from_process(), | ||
env, | ||
&mut std::io::stdin().lock(), | ||
&mut std::io::stdout(), | ||
) | ||
crates/knotless/src/commit.rs+9−6
| @@ -95,11 +95,14 @@ pub fn start(snap: &Snapshot, spec: &Spec) -> Option<String> { | ||
}) | ||
} | ||
| ||
/// The commit's author: as given, or whoever is committing. | ||
fn signed(spec: &Spec, me: &Session) -> Spec { | ||
/// The commit's author: as given, or whoever is committing, as they commit | ||
/// here ([`crate::emails::signature`]). | ||
async fn signed(ctx: &Ctx, spec: &Spec, me: &Session) -> Spec { | ||
let mut spec = spec.clone(); | ||
spec.author | ||
.get_or_insert_with(|| (me.handle.clone(), crate::emails::own(&me.did))); | ||
if spec.author.is_none() { | ||
let by = crate::emails::signature(ctx, "GIT_AUTHOR_IDENT", &me.did, &me.handle); | ||
spec.author = Some(by.await); | ||
} | ||
spec | ||
} | ||
| ||
| @@ -108,10 +111,10 @@ pub async fn commit(ctx: &Ctx, url: &RepoUrl, spec: &Spec) -> Result<Committed> | ||
if let Some(me) = session::reader(ctx, &remote.identity).await | ||
&& (me.did != remote.did() || spec.via_gateway) | ||
{ | ||
return gateway::commit(ctx, &remote, &me, &signed(spec, &me)).await; | ||
return gateway::commit(ctx, &remote, &me, &signed(ctx, spec, &me).await).await; | ||
} | ||
let session = session::ensure(ctx, &remote.identity).await?; | ||
let spec = &signed(spec, &session); | ||
let spec = &signed(ctx, spec, &session).await; | ||
let refname = format!("refs/heads/{}", spec.branch); | ||
let mut attempts = 0; | ||
loop { | ||
crates/knotless/src/emails.rs+38−7
| @@ -4,7 +4,7 @@ | ||
//! and one address claimed by two accounts hashes apart in each. Pages | ||
//! believe a claim only from a repository's owner and collaborators. | ||
| ||
use crate::env::Ctx; | ||
use crate::env::{Ctx, Signer}; | ||
use crate::git::Git; | ||
use crate::records::{self, EmailsRecord, nsid}; | ||
use crate::remote::{self, Remote}; | ||
| @@ -33,6 +33,37 @@ pub fn own(did: &str) -> String { | ||
format!("{did}{OWN}") | ||
} | ||
| ||
/// Who a commit knotless makes for `did`, known as `handle`, says made it, | ||
/// as [`Signer`] says: `which` is `GIT_AUTHOR_IDENT` for its author, | ||
/// `GIT_COMMITTER_IDENT` for its committer. | ||
pub async fn signature(ctx: &Ctx, which: &str, did: &str, handle: &str) -> (String, String) { | ||
let here = match ctx.env.signer { | ||
Signer::Git => git_ident(which).await, | ||
Signer::Account | Signer::Runner => None, | ||
}; | ||
let name = match ctx.env.signer { | ||
Signer::Runner => "knotless", | ||
Signer::Account | Signer::Git => handle, | ||
}; | ||
here.unwrap_or_else(|| (name.to_string(), own(did))) | ||
} | ||
| ||
/// Whom git commits as here, from its environment or config, its own | ||
/// repository's here included: none where git would guess an address from | ||
/// the machine's name. | ||
async fn git_ident(which: &str) -> Option<(String, String)> { | ||
let args = ["-c", "user.useConfigOnly=true", "var", which]; | ||
let said = Git::from_env().text(&args, None).await.ok()?; | ||
ident(&said) | ||
} | ||
| ||
/// The name and address of one of git's identities, `Name <email> date`. | ||
fn ident(said: &str) -> Option<(String, String)> { | ||
let (name, rest) = said.split_once('<')?; | ||
let (email, _) = rest.split_once('>')?; | ||
Some((name.trim().to_string(), email.trim().to_string())) | ||
} | ||
| ||
/// Whether `email` is `did`'s: the address knotless commits as for it, or | ||
/// one whose hash it claims. | ||
pub fn is(did: &str, email: &str, hashes: &[String]) -> bool { | ||
| @@ -107,12 +138,12 @@ pub async fn claim_first(ctx: &Ctx, git: &Git, me: &Session) -> Result<Option<St | ||
return Ok(None); | ||
} | ||
// As git would author a commit here: from its environment or config. | ||
let ident = git.text(&["var", "GIT_AUTHOR_IDENT"], None).await; | ||
let ident = ident.unwrap_or_default(); | ||
let email = ident | ||
.split_once('<') | ||
.and_then(|(_, rest)| rest.split_once('>')); | ||
let email = email.map_or("", |(email, _)| email.trim()).to_string(); | ||
let said = git.text(&["var", "GIT_AUTHOR_IDENT"], None).await; | ||
let email = said | ||
.ok() | ||
.and_then(|said| ident(&said)) | ||
.map(|(_, email)| email); | ||
let email = email.unwrap_or_default(); | ||
let first = email.contains('@') && claim(&account(ctx, &me.did).await?).await?.is_none(); | ||
if first { | ||
set(ctx, me, &email, true).await?; | ||
crates/knotless/src/env.rs+18−0
| @@ -23,6 +23,21 @@ pub enum HandleResolver { | ||
Xrpc { service: String }, | ||
} | ||
| ||
/// Whom the commits knotless makes for an account say made them, where no | ||
/// author is named; each falls back to [`Signer::Account`]. | ||
#[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
pub enum Signer { | ||
/// The account: its handle, at `<did>@knotless.invalid`. A server | ||
/// acting for accounts, which knows no one's git. | ||
Account, | ||
/// Whom git commits as here, as `git commit` would take it: on a | ||
/// person's own machine, the CLI and MCP over stdio. | ||
Git, | ||
/// `knotless`, at the account's `<did>@knotless.invalid`: a runner, | ||
/// whichever account it runs as. | ||
Runner, | ||
} | ||
| ||
/// The appview a process reads pulls and issues from when | ||
/// `KNOTLESS_APPVIEW` is unset. | ||
pub const DEFAULT_APPVIEW: &str = "https://knotless.dev"; | ||
| @@ -91,6 +106,8 @@ pub struct Env { | ||
pub ignore_minimum: bool, | ||
/// Where `knotless upgrade` installs, when not beside this binary. | ||
pub install_dir: Option<PathBuf>, | ||
/// Whom commits made here with no author named say made them. | ||
pub signer: Signer, | ||
} | ||
| ||
impl Env { | ||
| @@ -171,6 +188,7 @@ impl Env { | ||
install_dir: get("KNOTLESS_INSTALL_DIR") | ||
.filter(|v| !v.is_empty()) | ||
.map(PathBuf::from), | ||
signer: Signer::Account, | ||
} | ||
} | ||
} | ||
crates/knotless/src/forge.rs+16−13
| @@ -17,6 +17,7 @@ | ||
use crate::approvals; | ||
use crate::checks::{self, Standing}; | ||
use crate::closing; | ||
use crate::emails; | ||
use crate::env::Ctx; | ||
use crate::fetch; | ||
use crate::fix::{Fix, Refused}; | ||
| @@ -2468,9 +2469,9 @@ pub async fn merge( | ||
let merged = if there { | ||
head.clone() | ||
} else if squash { | ||
squash_commit(git, &tree, base, head, &item, &me).await? | ||
squash_commit(ctx, git, &tree, base, head, &item, &me).await? | ||
} else { | ||
merge_commit(git, &tree, base, head, &item, &me).await? | ||
merge_commit(ctx, git, &tree, base, head, &item, &me).await? | ||
}; | ||
let others = assemble(&t.record, t.remote.did(), &entries); | ||
let spent = (!keep).then(|| spent(t.facts(), &item, &others)).flatten(); | ||
| @@ -3410,20 +3411,20 @@ fn nobody(git: &Git) -> Git { | ||
git | ||
} | ||
| ||
/// `git`, committing as `me`. | ||
pub(crate) fn committer(git: &Git, me: &Session) -> Git { | ||
/// `git`, committing as `me` does here: see [`emails::signature`]. | ||
pub(crate) async fn committer(ctx: &Ctx, git: &Git, me: &Session) -> Git { | ||
let mut git = git.clone(); | ||
let email = format!("{}@knotless.invalid", me.did); | ||
git.envs.extend([ | ||
("GIT_AUTHOR_NAME".into(), me.handle.clone()), | ||
("GIT_AUTHOR_EMAIL".into(), email.clone()), | ||
("GIT_COMMITTER_NAME".into(), me.handle.clone()), | ||
("GIT_COMMITTER_EMAIL".into(), email), | ||
]); | ||
for who in ["AUTHOR", "COMMITTER"] { | ||
let which = format!("GIT_{who}_IDENT"); | ||
let (name, email) = emails::signature(ctx, &which, &me.did, &me.handle).await; | ||
git.envs.push((format!("GIT_{who}_NAME"), name)); | ||
git.envs.push((format!("GIT_{who}_EMAIL"), email)); | ||
} | ||
git | ||
} | ||
| ||
async fn merge_commit( | ||
ctx: &Ctx, | ||
git: &Git, | ||
tree: &str, | ||
base: &str, | ||
| @@ -3432,7 +3433,8 @@ async fn merge_commit( | ||
me: &Session, | ||
) -> Result<String> { | ||
let message = format!("Merge {}\n\n{}", item.title, item.uri); | ||
committer(git, me) | ||
committer(ctx, git, me) | ||
.await | ||
.text( | ||
&["commit-tree", tree, "-p", base, "-p", head, "-m", &message], | ||
None, | ||
| @@ -3443,6 +3445,7 @@ async fn merge_commit( | ||
/// The pull as one commit on `base`: the head's author wrote it, `me` | ||
/// commits it, and the message is the pull's. | ||
pub(crate) async fn squash_commit( | ||
ctx: &Ctx, | ||
git: &Git, | ||
tree: &str, | ||
base: &str, | ||
| @@ -3450,7 +3453,7 @@ pub(crate) async fn squash_commit( | ||
item: &Item, | ||
me: &Session, | ||
) -> Result<String> { | ||
let mut git = committer(git, me); | ||
let mut git = committer(ctx, git, me).await; | ||
let author = git | ||
.text(&["log", "-1", "--format=%an%x00%ae%x00%aI", head], None) | ||
.await?; | ||
crates/knotless/src/queue.rs+8−2
| @@ -1022,7 +1022,13 @@ impl Stack { | ||
/// commit of its own on the one before, committed by `me`: as the merge | ||
/// queue lands them, one commit per pull. Each that does not apply on what | ||
/// is before it is left out. | ||
pub async fn stack(git: &Git, base: &str, pulls: &[(Item, String)], me: &Session) -> Result<Stack> { | ||
pub async fn stack( | ||
ctx: &Ctx, | ||
git: &Git, | ||
base: &str, | ||
pulls: &[(Item, String)], | ||
me: &Session, | ||
) -> Result<Stack> { | ||
let mut stack = Stack { | ||
base: base.to_string(), | ||
stacked: Vec::new(), | ||
| @@ -1033,7 +1039,7 @@ pub async fn stack(git: &Git, base: &str, pulls: &[(Item, String)], me: &Session | ||
let since = forge::since_of(git, pull, &tip, head).await?; | ||
match forge::merge_tree_from(git, since.as_deref(), &tip, head).await? { | ||
Ok(tree) => { | ||
let commit = forge::squash_commit(git, &tree, &tip, head, pull, me).await?; | ||
let commit = forge::squash_commit(ctx, git, &tree, &tip, head, pull, me).await?; | ||
let (pull, head) = (pull.clone(), head.clone()); | ||
stack.stacked.push(Stacked { pull, head, commit }); | ||
} | ||
crates/knotless/src/revert.rs+1−1
| @@ -66,7 +66,7 @@ pub async fn revert( | ||
let key = t.snap.key().expect("an opened repository has a key"); | ||
let mirror = Mirror::of(ctx, &t.remote, key).await?; | ||
mirror.sync(&t.remote, &t.snap).await?; | ||
let git = forge::committer(&mirror.git, &me); | ||
let git = forge::committer(ctx, &mirror.git, &me).await; | ||
if tip.is_empty() || !git.is_ancestor(merged, &tip).await? { | ||
bail!("{base} no longer holds the merge of {pull}, {merged}"); | ||
} | ||
crates/knotless/tests/cli.rs+21−2
| @@ -323,6 +323,25 @@ fn repository_lifecycle() { | ||
s.git(&clone, &["log", "-1", "--format=%an", "HEAD~1"]), | ||
"Ada" | ||
); | ||
// Named by no one, the author is the account, as a server commits for | ||
// it; the binary, on a person's own machine, commits as git does there. | ||
let did = &s.account.did; | ||
let by = s.git(&clone, &["log", "-1", "--format=%an <%ae>", "HEAD"]); | ||
assert_eq!(by, format!("{} <{did}@knotless.invalid>", s.account.handle)); | ||
let put = ["--put", "git.txt", file.to_str().unwrap()]; | ||
let args = [ | ||
&["repo", "commit", &url, "-b", "trunk", "-m", "git's"][..], | ||
&put, | ||
] | ||
.concat(); | ||
let (done, _, err) = s.knotless(&args, b""); | ||
assert!(done, "{err}"); | ||
s.git(&clone, &["fetch", "-q", "origin"]); | ||
let by = s.git( | ||
&clone, | ||
&["log", "-1", "--format=%an <%ae>|%cn <%ce>", "origin/trunk"], | ||
); | ||
assert_eq!(by, "Test <t@example.com>|Test <t@example.com>"); | ||
assert!(!clone.join("run.sh").exists()); | ||
// The remote remembers whose it is, and will not follow its handle to | ||
// another account. | ||
| @@ -340,12 +359,12 @@ fn repository_lifecycle() { | ||
// Asked as a push asks, it only counts until there are enough packs. | ||
assert_eq!( | ||
s.cli(&["repo", "compact", &url, "--due"], b"").1, | ||
"2 packs, 0 folded, 0 old claims dropped, 0 deleted refs' records dropped" | ||
"3 packs, 0 folded, 0 old claims dropped, 0 deleted refs' records dropped" | ||
); | ||
assert_ne!(s.cli(&["repo", "compact", &url, "--soon"], b"").0, 0); | ||
assert_eq!( | ||
s.cli(&["repo", "compact", &url], b"").1, | ||
"2 packs, 2 folded, 0 old claims dropped, 0 deleted refs' records dropped" | ||
"3 packs, 3 folded, 0 old claims dropped, 0 deleted refs' records dropped" | ||
); | ||
// Folded, its history is one pack, small enough to ride inline. | ||
let (_, storage) = s.cli(&["repo", "storage", &url], b""); | ||