Skip to content
Open
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
29 changes: 16 additions & 13 deletions crates/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -2109,7 +2109,7 @@ pub enum GitCommand {
#[arg(last = true)]
pathspec: Vec<String>,

/// 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,

Expand Down Expand Up @@ -2165,7 +2165,7 @@ pub enum GitCommand {
#[arg(last = true)]
pathspec: Vec<String>,

/// 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,

Expand Down Expand Up @@ -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,

Expand All @@ -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,

Expand All @@ -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,

Expand All @@ -2256,7 +2259,7 @@ pub enum GitCommand {
#[arg(required = true, num_args = 2..)]
revs: Vec<String>,

/// Repository path on the server
/// Repository path on the server (relative to the client's cwd)
#[arg(long, default_value = ".")]
repo: String,

Expand All @@ -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,

Expand Down Expand Up @@ -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,

Expand All @@ -2331,7 +2334,7 @@ pub enum GitCommand {
/// TSV: WORKDIR<TAB>GITDIR. 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,

Expand Down Expand Up @@ -2365,7 +2368,7 @@ pub enum GitCommand {
/// Refspecs to fetch (default: the remote's configured ones)
refspecs: Vec<String>,

/// Repository path on the server
/// Repository path on the server (relative to the client's cwd)
#[arg(long, default_value = ".")]
repo: String,

Expand Down
6 changes: 4 additions & 2 deletions crates/cli/src/learn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -287,14 +288,15 @@ 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)
yas git diff --merge-base main # worktree vs where main forked (a `base` line names it)
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 OID<TAB>NAME)
yas git ls-tree HEAD:src # descend by passing a path
yas git merge-base main feature # best common ancestors (exit 1 if unrelated)
Expand Down
183 changes: 171 additions & 12 deletions crates/cli/src/yas_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<Vec<QueryRecord>, 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<QueryRecord>, json: bool) {
for record in records {
if let QueryRecord::TreeEntry(entry) = record {
let kind = match entry.entry_kind {
Expand All @@ -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<Vec<u8>> {
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<u8> {
fn signature(out: &mut Vec<u8>, 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(
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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<u8> {
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<FsPath, String> {
let normalized = path.replace('\\', "/");
if normalized.starts_with('/') {
Expand Down Expand Up @@ -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 <a@example.com> 1700000000 -0530\n\
committer C <c@example.com> 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!(
Expand Down
Loading