diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 0e916192..fae35d45 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -2079,7 +2079,7 @@ pub enum FsCommand { pub enum GitCommand { /// Branch, ahead/behind, stash, and working-tree status Status { - /// Repository location on the server (default: server cwd) + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2109,7 +2109,7 @@ pub enum GitCommand { #[arg(last = true)] pathspec: Vec, - /// Repository location on the server (default: server cwd) + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2165,7 +2165,7 @@ pub enum GitCommand { #[arg(last = true)] pathspec: Vec, - /// Repository location on the server (default: server cwd) + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2199,12 +2199,15 @@ pub enum GitCommand { /// yas git show HEAD:src/main.rs # a file at a revision /// yas git show v1.0:Cargo.toml /// yas git show HEAD # the commit object itself + /// yas git show HEAD:src # a directory, listed like ls-tree Show { - /// REV[:PATH]. Omit PATH for the commit object; omit REV - /// (`:path`) for HEAD. + /// REV[:PATH]. REV names a commit; reach trees and blobs by PATH. + /// Omit PATH for the commit object (in `git cat-file commit` + /// layout); a directory PATH is listed like ls-tree, and `REV:` + /// lists the root tree. Omit REV (`:path`) for HEAD. spec: String, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2221,7 +2224,7 @@ pub enum GitCommand { /// REV[:PATH]; omit PATH for the root tree spec: String, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2239,7 +2242,7 @@ pub enum GitCommand { #[arg(default_value = "")] path: String, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2256,7 +2259,7 @@ pub enum GitCommand { #[arg(required = true, num_args = 2..)] revs: Vec, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2274,7 +2277,7 @@ pub enum GitCommand { /// File to blame path: String, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2309,7 +2312,7 @@ pub enum GitCommand { #[arg(default_value = "")] ref_name: String, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, @@ -2331,7 +2334,7 @@ pub enum GitCommand { /// TSV: WORKDIRGITDIR. Deduped by gitdir, so several paths /// resolving to one repository report once. Discover { - /// Directory to search (default: server cwd) + /// Directory to search (relative to the client's cwd) #[arg(default_value = ".")] path: String, @@ -2365,7 +2368,7 @@ pub enum GitCommand { /// Refspecs to fetch (default: the remote's configured ones) refspecs: Vec, - /// Repository path on the server + /// Repository path on the server (relative to the client's cwd) #[arg(long, default_value = ".")] repo: String, diff --git a/crates/cli/src/learn.md b/crates/cli/src/learn.md index e4c1ede0..bf265975 100644 --- a/crates/cli/src/learn.md +++ b/crates/cli/src/learn.md @@ -278,7 +278,8 @@ yas fs ln -s target link # symlink (omit -s for a hard link) ## Git Read-only introspection of repositories on the server. `--repo` picks the -worktree (default: cwd); `--json` emits NDJSON. +worktree; a relative path, and the default `.`, resolve against the client's +cwd, as `--root` does for `fs` and `lsp`. `--json` emits NDJSON. ```bash yas git status # branch, ahead/behind, stash, worktree @@ -287,7 +288,7 @@ yas git log # history, newest first yas git log v1.0 # from a tag yas git log main..feature # a range yas git log --follow -- src/main.rs -yas git diff # unstaged +yas git diff # unstaged (-p for hunks) yas git diff --staged # staged yas git diff main dev # between two commits yas git diff main...dev # since they diverged (from the merge base) @@ -295,6 +296,7 @@ yas git diff --merge-base main # worktree vs where main forked (a `base` lin yas git diff HEAD~2 -p -- src # with hunks, limited to a path yas git show HEAD:src/main.rs # a file's bytes at a revision yas git show HEAD # the commit object itself +yas git show HEAD:src # a directory, listed like ls-tree yas git ls-tree HEAD # one tree level (MODE TYPE OIDNAME) yas git ls-tree HEAD:src # descend by passing a path yas git merge-base main feature # best common ancestors (exit 1 if unrelated) diff --git a/crates/cli/src/yas_git.rs b/crates/cli/src/yas_git.rs index 0f6553b1..62537635 100644 --- a/crates/cli/src/yas_git.rs +++ b/crates/cli/src/yas_git.rs @@ -40,7 +40,7 @@ impl Repository { family::GIT, git::request_kind::OPEN, &Open { - source: RepositorySource::PlatformPath(path.as_bytes().to_vec()), + source: RepositorySource::PlatformPath(client_path(path)), extensions: Extensions::default(), }, true, @@ -611,10 +611,29 @@ async fn cmd_show( let mut repository = Repository::open(on, hub, &repo).await?; let (revision, path) = split_revision_path(&spec); let object = repository.resolve_one(revision).await?; - let records = repository + match path { + None => { + let commit = commit_object(&mut repository, &object) + .await + .ok_or_else(|| format!("'{revision}' did not resolve to a commit"))?; + std::io::stdout() + .write_all(&commit) + .map_err(|error| format!("writing stdout: {error}"))?; + repository.close().await?; + return Ok(0); + } + Some("") => { + let records = tree_entries(&mut repository, object, "").await?; + print_tree_entries(records, false); + repository.close().await?; + return Ok(0); + } + Some(_) => {} + } + let records = match repository .query( QueryBody::Blob { - object, + object: object.clone(), path: path.map(fs_path).transpose()?, offset: 0, max_bytes: max_len, @@ -623,7 +642,21 @@ async fn cmd_show( 1, 1, ) - .await?; + .await + { + Ok(records) => records, + Err(error) => { + let Some(path) = path else { + return Err(error); + }; + let Ok(records) = tree_entries(&mut repository, object, path).await else { + return Err(error); + }; + print_tree_entries(records, false); + repository.close().await?; + return Ok(0); + } + }; let content = records.into_iter().find_map(|record| match record { QueryRecord::Blob(content) => Some(content), _ => None, @@ -650,18 +683,30 @@ async fn cmd_ls_tree( let mut repository = Repository::open(on, hub, &repo).await?; let (revision, path) = split_revision_path(&spec); let tree = repository.resolve_one(revision).await?; - let records = repository + let records = tree_entries(&mut repository, tree, path.unwrap_or("")).await?; + print_tree_entries(records, json); + repository.close().await?; + Ok(0) +} + +async fn tree_entries( + repository: &mut Repository, + tree: ObjectId, + path: &str, +) -> Result, String> { + repository .query( QueryBody::Tree { tree, - path: path.map(fs_path).transpose()?.unwrap_or(FsPath { - components: Vec::new(), - }), + path: fs_path(path)?, }, git::MAX_QUERY_RECORDS as u16, MAX_COLLECTED_RECORDS, ) - .await?; + .await +} + +fn print_tree_entries(records: Vec, json: bool) { for record in records { if let QueryRecord::TreeEntry(entry) = record { let kind = match entry.entry_kind { @@ -684,8 +729,70 @@ async fn cmd_ls_tree( } } } - repository.close().await?; - Ok(0) +} + +/// The commit object in `git cat-file commit` layout, rebuilt from the LOG +/// record because BLOB serves only blobs. +async fn commit_object(repository: &mut Repository, object: &ObjectId) -> Option> { + let records = repository + .query( + QueryBody::Log { + spec: object_hex(object).into_bytes(), + tips: Vec::new(), + hides: Vec::new(), + path: None, + flags: yas_wire::schema::git::LOG_FULL_MESSAGE as u16, + }, + 1, + 1, + ) + .await + .ok()?; + records.into_iter().find_map(|record| match record { + QueryRecord::Commit(commit) => Some(render_commit(&commit)), + _ => None, + }) +} + +fn render_commit(commit: &git::CommitRecord) -> Vec { + fn signature(out: &mut Vec, role: &str, name: &[u8], email: &[u8], time: i64, tz: i16) { + out.extend_from_slice(role.as_bytes()); + out.push(b' '); + out.extend_from_slice(name); + out.extend_from_slice(b" <"); + out.extend_from_slice(email); + let sign = if tz < 0 { '-' } else { '+' }; + let minutes = tz.unsigned_abs(); + out.extend_from_slice( + format!("> {time} {sign}{:02}{:02}\n", minutes / 60, minutes % 60).as_bytes(), + ); + } + let mut out = format!("tree {}\n", object_hex(&commit.tree)).into_bytes(); + for parent in &commit.parents { + out.extend_from_slice(format!("parent {}\n", object_hex(parent)).as_bytes()); + } + signature( + &mut out, + "author", + &commit.author_name, + &commit.author_email, + commit.authored_unix_seconds, + commit.author_timezone_minutes, + ); + signature( + &mut out, + "committer", + &commit.committer_name, + &commit.committer_email, + commit.committed_unix_seconds, + commit.committer_timezone_minutes, + ); + out.push(b'\n'); + out.extend_from_slice(&commit.message); + if !commit.message.ends_with(b"\n") { + out.push(b'\n'); + } + out } async fn cmd_ls_files( @@ -885,7 +992,7 @@ async fn cmd_discover( &mut client, 0, QueryBody::Discover { - source: RepositorySource::PlatformPath(path.into_bytes()), + source: RepositorySource::PlatformPath(client_path(&path)), max_depth: u16::from(depth), flags, }, @@ -1179,6 +1286,15 @@ fn print_diff(diff: &git::DiffRecord, json: bool) { } } +/// Relative repository paths name the client's working directory, as FS and +/// LSP roots do. +fn client_path(path: &str) -> Vec { + std::path::absolute(path) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()) + .into_bytes() +} + fn fs_path(path: &str) -> Result { let normalized = path.replace('\\', "/"); if normalized.starts_with('/') { @@ -1266,6 +1382,49 @@ fn wire_error(error: impl std::fmt::Display) -> String { mod tests { use super::*; + #[test] + fn commit_object_uses_cat_file_layout() { + let oid = |byte| ObjectId { + algorithm: yas_wire::schema::git::OBJECT_SHA1 as u8, + bytes: vec![byte; 20], + }; + let commit = git::CommitRecord { + flags: 0, + object: oid(1), + tree: oid(0xab), + parents: vec![oid(0xcd)], + authored_unix_seconds: 1_700_000_000, + author_timezone_minutes: -330, + committed_unix_seconds: 1_700_000_100, + committer_timezone_minutes: 60, + author_name: b"A U".to_vec(), + author_email: b"a@example.com".to_vec(), + committer_name: b"C".to_vec(), + committer_email: b"c@example.com".to_vec(), + message: b"subject\n\nbody".to_vec(), + }; + assert_eq!( + String::from_utf8(render_commit(&commit)).unwrap(), + format!( + "tree {}\nparent {}\nauthor A U 1700000000 -0530\n\ + committer C 1700000100 +0100\n\nsubject\n\nbody\n", + "ab".repeat(20), + "cd".repeat(20) + ) + ); + } + + #[test] + fn relative_repository_paths_resolve_against_client_cwd() { + let cwd = std::env::current_dir().unwrap(); + assert_eq!(client_path("."), cwd.to_string_lossy().as_bytes()); + assert_eq!( + client_path("sub"), + cwd.join("sub").to_string_lossy().as_bytes() + ); + assert_eq!(client_path("/abs/repo"), b"/abs/repo"); + } + #[test] fn revision_path_and_ranges_match_git_cli_spellings() { assert_eq!(